diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2e812f1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + # Tests run on every OS the release workflows build on — otherwise a tagged + # release is the FIRST time the suite ever executes on windows/mac, and a + # failure there yields a half-published release instead of a red PR. + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-14, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - run: npm ci + + - name: Type-check + run: npm run check-types + + - name: Test + run: npm test + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - run: npm ci + + - name: Engine purity guard + run: npm run check-purity + + - name: Package extension (smoke-test the build) + run: | + npm run package + npx @vscode/vsce package -o gitstudio.vsix --no-dependencies \ + --baseImagesUrl https://github.com/GitStudioHQ/gitstudio/raw/HEAD/apps/extension \ + --baseContentUrl https://github.com/GitStudioHQ/gitstudio/blob/HEAD/apps/extension + working-directory: apps/extension + + - uses: actions/upload-artifact@v4 + with: + name: gitstudio-vsix + path: apps/extension/gitstudio.vsix + + - name: Build desktop bundles (smoke-test) + run: npm run build --workspace apps/desktop diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml new file mode 100644 index 0000000..acd4b85 --- /dev/null +++ b/.github/workflows/release-desktop.yml @@ -0,0 +1,132 @@ +name: Release Desktop + +# Builds the GitStudio desktop (Electron) app for macOS (Apple Silicon + Intel), +# Windows, and Linux and uploads the per-OS installers (.dmg / .exe / .AppImage / +# .deb) to the GitHub Release when an `app-v*` tag is pushed. +# +# Cut a release: +# 1. bump "version" in apps/desktop/package.json +# 2. git tag app-v1.0.0 && git push origin app-v1.0.0 +# +# Structure: ONE create-release job makes the Release (so four matrix jobs never +# race `gh release create` against each other), then the per-OS build jobs only +# upload assets. Desktop releases stay the repo's "latest" release — the in-app +# auto-updater resolves /releases/latest, and extension (`ext-v*`) releases are +# created with --latest=false so they can never hijack that pointer. +# +# Code-signing/notarization are OPTIONAL: electron-builder builds UNSIGNED when +# the CSC_*/APPLE_* secrets are absent. With no secrets you still get working +# (unsigned) installers attached to the Release. + +on: + push: + tags: + - 'app-v*.*.*' + workflow_dispatch: + +permissions: + contents: write + +jobs: + create-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: "Guard: tag matches package.json version" + if: startsWith(github.ref, 'refs/tags/') + working-directory: apps/desktop + run: | + node -e ' + const v = require("./package.json").version; + const tag = process.env.GITHUB_REF_NAME.replace(/^app-v/, ""); + if (v !== tag) { + console.error(`Tag app-v${tag} != package.json version ${v}`); + process.exit(1); + }' + + - name: Create the GitHub Release (once, before the matrix) + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + echo "Release ${GITHUB_REF_NAME} already exists." + exit 0 + fi + NOTES="docs/releases/${GITHUB_REF_NAME}.md" + if [ -f "$NOTES" ]; then + gh release create "${GITHUB_REF_NAME}" --latest \ + --title "GitStudio ${GITHUB_REF_NAME#app-v}" --notes-file "$NOTES" + else + gh release create "${GITHUB_REF_NAME}" --latest --generate-notes \ + --title "GitStudio ${GITHUB_REF_NAME#app-v}" + fi + + build: + needs: create-release + strategy: + fail-fast: false + matrix: + include: + # Build each mac arch NATIVELY (macos-14 = Apple Silicon, macos-15-intel + # = x64; macos-13 was retired by GitHub in Dec 2025) so node-pty's + # per-arch prebuild loads without cross-compiling. + # + # NOTE: mac deliberately does NOT upload latest-mac.yml. Both arch runners + # emit an identically-named latest-mac.yml, so a --clobber upload would + # leave the Release with ONE arch's feed and mis-update the other; the + # app skips update checks on macOS instead (see autoUpdate.ts). Windows/ + # Linux are single-arch, so their feeds are safe. + - os: macos-14 + artifact: 'apps/desktop/release/*.dmg apps/desktop/release/*.zip' + - os: macos-15-intel + artifact: 'apps/desktop/release/*.dmg apps/desktop/release/*.zip' + - os: windows-latest + artifact: 'apps/desktop/release/*.exe apps/desktop/release/*.yml apps/desktop/release/*.blockmap' + # ubuntu-22.04 (not -latest): the AppImage links the build host's glibc, + # so building on 24.04 (glibc 2.39) breaks Ubuntu 22.04/Debian 12 users. + # No *.blockmap here — AppImage/deb don't emit one, and a non-matching + # glob would fail the upload. + - os: ubuntu-22.04 + artifact: 'apps/desktop/release/*.AppImage apps/desktop/release/*.deb apps/desktop/release/*.yml' + runs-on: ${{ matrix.os }} + env: + # electron-builder reads these; when absent it builds UNSIGNED (it does not + # fail). Provide them as repo secrets to enable signing/notarization. + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} + WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install (workspace root) + run: npm ci + + - name: Type-check + run: npm run check-types --workspaces --if-present + + - name: Test + run: npm test + + - name: Build bundles (main / preload / renderer) + run: npm run build --workspace apps/desktop + + - name: Build installer (electron-builder) + working-directory: apps/desktop + run: npx electron-builder --publish never + + - name: Upload installers to the GitHub Release + if: startsWith(github.ref, 'refs/tags/') + shell: bash + run: gh release upload "${GITHUB_REF_NAME}" ${{ matrix.artifact }} --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..782699b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,115 @@ +name: Release Extension + +# Publishes the GitStudio extension to the VS Code Marketplace and Open VSX when +# an `ext-vX.Y.Z` tag is pushed, then attaches the .vsix to the GitHub Release. +# (The desktop app has its own channel: `app-v*` tags -> .github/workflows/release-desktop.yml.) +# +# One-time setup — add two repo secrets: +# VSCE_PAT Azure DevOps PAT, scope "Marketplace > Manage", for publisher `gitstudio` +# OVSX_PAT Open VSX access token (namespace `gitstudio` must exist) +# +# Cut a release: +# 1. bump "version" in apps/extension/package.json (+ a CHANGELOG entry) +# 2. git tag ext-v1.0.0 && git push origin ext-v1.0.0 +# +# The .vsix is attached to the Release BEFORE publishing, so even a failed +# publish leaves a downloadable artifact; the publish steps FAIL LOUDLY when a +# secret is missing (a silent green run that shipped nothing cost us a launch). +# After adding the missing secret, re-run via workflow_dispatch — it rebuilds +# and publishes the current package.json version without needing a new tag. + +on: + push: + tags: + - 'ext-v*.*.*' + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/extension + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + # The tag names the release, but vsce/electron-builder ship whatever is in + # package.json — a mismatch publishes the WRONG version under this tag. + - name: "Guard: tag matches package.json version" + if: startsWith(github.ref, 'refs/tags/') + run: | + node -e ' + const v = require("./package.json").version; + const tag = process.env.GITHUB_REF_NAME.replace(/^ext-v/, ""); + if (v !== tag) { + console.error(`Tag ext-v${tag} != package.json version ${v}`); + process.exit(1); + }' + + - name: Install (workspace root) + run: npm ci + working-directory: . + + - name: Type-check + run: npm run check-types --workspaces --if-present + working-directory: . + + - name: Test + run: npm test + working-directory: . + + # --baseImagesUrl: vsce's default rewrite of relative README images uses + # the repo ROOT (https://github.com/…/raw/HEAD/media/…), but this is a + # monorepo — the images live under apps/extension. Without this flag every + # store screenshot 404s. + - name: Package + run: | + npm run package + npx @vscode/vsce package -o gitstudio.vsix --no-dependencies \ + --baseImagesUrl https://github.com/GitStudioHQ/gitstudio/raw/HEAD/apps/extension \ + --baseContentUrl https://github.com/GitStudioHQ/gitstudio/blob/HEAD/apps/extension + + # `--latest=false` is load-bearing: the desktop app's auto-updater reads + # /releases/latest, and an extension release marked "latest" would point + # it at a tag with no latest.yml feed (update checks 404 forever). + - name: Attach .vsix to the GitHub Release + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + NOTES="../../docs/releases/${GITHUB_REF_NAME}.md" + if [ -f "$NOTES" ]; then + gh release create "${GITHUB_REF_NAME}" gitstudio.vsix --latest=false \ + --title "GitStudio extension ${GITHUB_REF_NAME#ext-v}" --notes-file "$NOTES" \ + || gh release upload "${GITHUB_REF_NAME}" gitstudio.vsix --clobber + else + gh release create "${GITHUB_REF_NAME}" gitstudio.vsix --latest=false --generate-notes \ + || gh release upload "${GITHUB_REF_NAME}" gitstudio.vsix --clobber + fi + + - name: Publish to VS Code Marketplace + run: | + if [ -z "$VSCE_PAT" ]; then + echo "::error::VSCE_PAT secret is missing — Marketplace publish did NOT run. Add the secret, then re-run this workflow (workflow_dispatch)." + exit 1 + fi + npx @vscode/vsce publish --packagePath gitstudio.vsix + + - name: Publish to Open VSX + run: | + if [ -z "$OVSX_PAT" ]; then + echo "::error::OVSX_PAT secret is missing — Open VSX publish did NOT run. Add the secret, then re-run this workflow (workflow_dispatch)." + exit 1 + fi + npx ovsx publish gitstudio.vsix diff --git a/.gitignore b/.gitignore index 9a7d130..9f4b149 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ node_modules/ dist/ out/ +release/ *.vsix .DS_Store .vscode-test/ + +# IDE dependency-graph plugin artifacts +.dependencygraph/ diff --git a/HANDOFF-AI-MCP.md b/HANDOFF-AI-MCP.md new file mode 100644 index 0000000..f6a89f8 --- /dev/null +++ b/HANDOFF-AI-MCP.md @@ -0,0 +1,71 @@ +# Handoff — AI + Agents + MCP (overnight build) + +## What shipped + +Two features the user asked for, built and verified overnight: + +1. **Connect any AI model in the app** + AI/agent capabilities that leverage + your own subscriptions/keys (or a local model). +2. **A first-class MCP server** exposing this repo's Git capabilities to any + outside agent, used wisely (least-privilege). + +Both reuse **one shared tool catalog** (`@gitstudio/ai/gitTools`), so the in-app +agent and the MCP server stay in lockstep. + +See [`docs/ai-and-agents.md`](docs/ai-and-agents.md) and +[`apps/mcp/README.md`](apps/mcp/README.md). + +## ⚠️ Two branches — why, and how to merge + +While I was working, **a second Claude session was concurrently editing the +desktop app** (a security/theme hardening pass: `cloneUrl.ts`, `security.test.ts`, +`theme-boot.js`, and edits to `renderer.ts` / `main.ts` / `gitBridge.ts` / +`app.css`). To avoid silently clobbering its work, I split the build: + +- **`claude/redesign-masterpiece`** (the shared branch) got the **collision-free + foundation** — commit `7e31e36`: + - `packages/ai` (`@gitstudio/ai`) — providers, catalog, connections, tasks, + agent loop, shared git-tool catalog. + - `packages/git-service/GitToolHost.ts` — the tool host over a real repo. + - `apps/mcp` (`gitstudio-mcp`) — the standalone MCP server. +- **`claude/ai-mcp-desktop`** (this branch, from `7e31e36`) got the **desktop + integration** — commit `0fd04c5` — built in an isolated git worktree so it + never raced the other session: `main/aiBridge.ts`, `main/mcpConfig.ts`, + `renderer/{assistant,aiSettings}.ts`, plus minimal edits to `renderer.ts`, + `main.ts`, `shared/ipc.ts`, `styles/app.css`, `package.json`. + +### To land everything + +```bash +# 1. Let the other session commit its desktop changes on claude/redesign-masterpiece. +# 2. Merge this branch in: +git checkout claude/redesign-masterpiece +git merge claude/ai-mcp-desktop +# 3. Expect conflicts in the files BOTH sessions touched: +# renderer.ts (nav tab + settings cards + route — small, localized), +# main.ts (AiBridge construct + ai:* handlers — one contiguous block), +# app.css (AI styles appended at the very end), +# shared/ipc.ts (AI channels/events/types — contiguous blocks). +# My additions are deliberately localized + contiguous to keep these easy. +# 4. npm install (reconciles the lockfile: new @gitstudio/ai dep edges) +# 5. npm run check-types && npm test && (cd apps/mcp && npm run build) +``` + +The worktree lives at `/tmp/gitstudio-ai-wt` (throwaway; remove with +`git worktree remove /tmp/gitstudio-ai-wt` after merging). + +## Status + +- **Tests:** 232 pass in the main repo (16 ai + 73 git-service incl. 5 new + tool-host + 13 mcp + the rest). Desktop typechecks (main + renderer) and + bundles cleanly. (Running git-service tests *inside the /tmp worktree* falsely + fails on a `tsx`+symlink path quirk — they pass in the real checkout.) +- **Verified end-to-end:** the MCP server over real stdio (initialize → + tools/list → tools/call → resources/read), read-only vs `--write` gating. +- **Not done (deliberate, to limit merge pain):** deep inline ✨ buttons in the + commit composer / diff views — the Assistant already covers those flows + ("draft a commit", "summarize my changes") in one surface. The task functions + exist in `@gitstudio/ai/tasks` and `ai:task` IPC is wired, so adding inline + buttons later is small. +- Nothing here gates Git: with no model connected, the AI affordances stay + hidden and the app behaves exactly as before. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a620b68 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 GitStudio + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..21d77cd --- /dev/null +++ b/NOTICE @@ -0,0 +1,14 @@ +GitStudio +Copyright 2026 GitStudio + +This product includes software developed for the GitStudio project +(https://gitstudio.dev). + +Portions of the shared engine and webview UI originate from Merge Studio +(https://github.com/GitStudioHQ/merge-studio), licensed under the MIT License, +and are redistributed here under the Apache License, Version 2.0. + +This product depends on third-party software, including: +- Monaco Editor (MIT), Microsoft Corporation +- vscode-diff (MIT) +- DOMPurify (Apache-2.0 / MPL-2.0) diff --git a/README.md b/README.md index 87a5582..15d664e 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,156 @@ +

+ GitStudio +

+

GitStudio

- A JetBrains-grade Git experience for VS Code & Cursor — the whole workflow, not just one piece. + A free, open-source, JetBrains-grade Git suite for VS Code, Cursor, and desktop.

+

+ VS Code Marketplace + Open VSX + Desktop app + CI + License: Apache-2.0 +

+ +

Logo & brand assets live in brand/.

+ --- +## Get GitStudio + +### VS Code / Cursor extension + +Install **GitStudio** from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=gitstudio.gitstudio) or [Open VSX](https://open-vsx.org/extension/gitstudio/gitstudio) (VSCodium, Gitpod, …), or from the command line: + +```bash +code --install-extension gitstudio.gitstudio # VS Code +cursor --install-extension gitstudio.gitstudio # Cursor +``` + +### Desktop app + +Download the installer for your platform from the [latest GitHub Release](https://github.com/GitStudioHQ/gitstudio/releases/latest) or from [gitstudio.dev](https://gitstudio.dev): + +| Platform | Installer | +|---|---| +| **macOS — Apple Silicon** | `GitStudio--arm64.dmg` | +| **macOS — Intel** | `GitStudio--x64.dmg` | +| **Windows** | `GitStudio-Setup-.exe` (NSIS — choose your install dir) | +| **Linux** | `GitStudio--x64.AppImage` (universal) · `GitStudio--x64.deb` (Debian/Ubuntu) | + +Windows and Linux (AppImage) builds check GitHub Releases and update in-app (electron-updater); macOS updates are manual until signed builds ship. + ## The idea VS Code's built-in Git is functional but flat. GitLens is great at *information* (blame, history, lenses) but doesn't own the *interaction* (merging, staging, resolving). JetBrains IDEs nail the interaction — three-pane merges, a real commit graph, hunk-level staging, inline blame that feels native — but you only get them if you live in IntelliJ. -**GitStudio brings the JetBrains-grade Git *workflow* into VS Code and Cursor, and adds an intelligence layer on top.** Think "GitLens on steroids" meets "GitBrain": the polish of a native IDE, the depth of a power-user tool, and AI that actually understands your history. +**GitStudio brings the JetBrains-grade Git *workflow* to VS Code, Cursor, and a native desktop app, and adds an intelligence layer on top.** The polish of a native IDE, the depth of a power-user tool, and AI that actually understands your history — all free, on public *and* private repos. -It ships as a **family of focused tools under one brand**, starting with the one that's already live. +## What's inside — the six pillars -## Status +Everything ships in one extension (`gitstudio.gitstudio`) and one desktop app, both built on the same shared engine: -| | | +| Pillar | What's in | |---|---| -| **Brand / publisher** | `gitstudio` (display **"GitStudio"**) — live on the VS Code Marketplace **and** Open VSX | -| **Domain** | `gitstudio.dev` (owned — for the verified-publisher badge + landing page) | -| **First product** | ✅ **[Merge Studio](https://marketplace.visualstudio.com/items?itemName=gitstudio.merge-studio)** — `gitstudio.merge-studio`, a JetBrains-style 3-pane merge + diff editor. Shipped, in active use. | -| **Release pipeline** | Token-free GitHub Actions: tag `vX.Y.Z` → auto-publishes to both registries (see [vscode-extension-starter](../vscode-extension-starter)) | -| **This repo** | Vision + scaffold for the broader suite. See [HANDOFF.md](HANDOFF.md) to start building. | +| **Visualize** | A sidebar-native Commits view (true branch topology at sidebar scale, mini author avatars on the nodes, scoped search) plus a full-screen virtualized commit graph; inline + full-file blame with a code-age heatmap; file & line history; revision navigation; reflog time-machine. | +| **Change** | Instant hunk- & line-level staging; guided commit box (amend, sign-off, author, Commit & Push); side-by-side / unified diff with word-level highlighting; 3-pane merge editor with accept ribbons — conflicts auto-open as they appear. | +| **Rewrite** | Drag-to-reorder interactive rebase (pick · reword · edit · squash · fixup · drop); a universal, reflog-powered **Undo** safety net (never hijacks `Ctrl/Cmd+Z`). | +| **Manage** | Branches (live ↑/↓ badges, fetch-in-place, pull without checkout), remotes, tags, first-class stashes, worktrees; GitHub-style branch compare; status-bar sync with in-view Push/Pull. | +| **Collaborate** | In-editor GitHub pull-request review — list, check out, diff, comment inline, submit, merge, create. | +| **Assist** | GitBrain — optional, bring-your-own-key (Anthropic or any OpenAI-compatible endpoint, including local Ollama / LM Studio) or zero-key (Copilot): AI commit messages, explain-diff, summaries. Off by default; keys live in SecretStorage; AI never gates a Git operation. | -## Product pillars +## The desktop app -Merge Studio proved the model (custom webview editors, JetBrains-faithful ribbons, a pure tested diff/merge engine). GitStudio extends that into the full workflow: +A standalone, cross-platform Git client (macOS / Windows / Linux) built on the exact same core — not a rewrite. On top of the shared graph, diff/merge, staging, rebase, and undo it adds: -1. **Merge & Diff** — *shipped as Merge Studio.* Three-pane merge with ribbons, precise side-by-side diff, optional hand-off to a real JetBrains IDE. The seed engine for everything else. -2. **Blame & authorship lens** — inline, native-feeling blame; hover for the commit, author, message, and PR; "who last touched this line and why." -3. **History & timeline** — per-file and per-line history, a repo timeline, "step through how this file evolved." -4. **Commit graph** — a real branch/commit graph (JetBrains Log-style), not a flat list. -5. **Staging that respects intent** — hunk- and line-level staging, partial commits, an interactive-rebase UI that isn't terrifying. -6. **GitBrain (the intelligence layer)** — AI commit messages, PR/changeset summaries, "explain this diff," and conflict-resolution *suggestions* in the merge editor. This is the "but better" — context-aware help grounded in the actual repo. +- **A GitHub home for your repo** — sign in with a token and get pull requests (diffs + inline review), issues, Actions runs with logs, releases, notifications, gists, orgs, and projects, all local-first. +- **An integrated terminal** (node-pty + xterm.js) that opens in your repo. +- **An AI Assistant** panel wired to `@gitstudio/ai` — connect Anthropic or any OpenAI-compatible model, including local ones. +- **Auto-update** from GitHub Releases. -> The pillars are a starting map, not a contract. The next builder should sequence them by leverage — blame + history are the highest-value, lowest-risk next steps after merge. +## MCP server -## Architecture sketch +`gitstudio-mcp` (in [`apps/mcp`](apps/mcp)) exposes a repository's Git capabilities to any MCP-compatible agent (Claude Desktop, Cursor, Copilot, Windsurf) over stdio. Read tools are always available; write/destructive tools are opt-in via environment variables. -- **One flagship extension** (`gitstudio.gitstudio`) that grows feature-by-feature — the GitLens model — rather than many tiny extensions. Merge Studio stays its own focused product; GitStudio is the suite. They share an engine, not a listing. -- **Reuse Merge Studio's core.** Its `src/engine/` (pure, unit-tested diff/merge model) and the Monaco webview ribbon/decoration layer are the reusable heart. The cleanest path is to **extract that engine into a shared package** (`@gitstudio/engine`) consumed by both extensions. -- **Webview custom editors** for rich UI (merge, graph, history), **providers + decorations** for the ambient stuff (blame, lenses), **a thin git service** over the built-in `vscode.git` API plus direct `.git` reads where speed matters (Merge Studio already does this for conflict detection). -- **GitBrain** calls the Anthropic Claude API. Default to the latest models (Opus 4.8 / Sonnet 4.6 / Haiku 4.5; Fable 5) — see the `claude-api` reference. Keep AI optional and bring-your-own-key friendly. +## Monorepo layout -## Why this can win +npm workspaces (`packages/*` + `apps/*`): -- **Distribution already exists.** GitStudio is a live, verified-ish publisher with a shipped product pulling real installs. The suite launches to a warm audience, not from zero. -- **Cursor is the wedge.** Cursor users live on Open VSX and want power tooling — GitStudio is already there. -- **The hard part is done once.** Merge Studio solved the "render a JetBrains-grade Git UI inside a webview" problem. Every pillar reuses that muscle. +``` +packages/ + engine/ Pure, unit-tested diff/merge + graph-layout model (no vscode/electron imports). + git-service/ Thin git layer: log, blame, status, staging, refs, stashes, worktrees, sync… + host-bridge/ Protocols shared between the hosts (extension / desktop) and their webviews. + webview-ui/ Shared webview front-ends: commit graph, diff/merge (Monaco), rebase. + ai/ Host-agnostic AI layer: multi-provider model registry, git AI tasks, agent + loop, and the shared git tool catalog that also backs the MCP server. +apps/ + extension/ The VS Code / Cursor extension. + desktop/ The Electron desktop app. + mcp/ gitstudio-mcp — the Model Context Protocol server. +``` + +`engine`, `host-bridge`, and `ai` are kept **pure** (no `vscode`/`electron` imports) so they stay portable and testable — enforced by `npm run check-purity`. + +## Development + +Requires **Node 22+**. + +```bash +npm ci # install all workspaces +npm test # run every workspace's tests (tsx --test) +npm run check-types # tsc --noEmit across all workspaces +npm run check-purity # assert the pure packages stay host-free +``` + +**Extension** — open the repo in VS Code and press F5 to launch an Extension Development Host with GitStudio loaded. Package a sideloadable VSIX with: + +```bash +cd apps/extension +npm run package +npx @vscode/vsce package --no-dependencies +``` + +**Desktop** — build and launch Electron: + +```bash +npm start --workspace apps/desktop # dev build + launch +npm run dist --workspace apps/desktop # installers for the host OS -> apps/desktop/release/ +``` + +## Releasing + +Both products release from tags — `ext-v*` for the extension (→ Marketplace, Open VSX, GitHub Release) and `app-v*` for the desktop app (→ per-OS installers on a GitHub Release, built natively on a 4-way matrix). CI runs typecheck + the full test suite before anything publishes. See [`RELEASING.md`](RELEASING.md) for the full playbook, including the optional signing/notarization secrets. + +## Contributing + +Issues and pull requests are welcome at [GitStudioHQ/gitstudio](https://github.com/GitStudioHQ/gitstudio). Before opening a PR, please run the three gates locally: + +```bash +npm test && npm run check-types && npm run check-purity +``` + +## Facts + +| | | +|---|---| +| **Extension** | `gitstudio.gitstudio` (publisher `gitstudio`) — [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=gitstudio.gitstudio) + [Open VSX](https://open-vsx.org/extension/gitstudio/gitstudio) | +| **Desktop** | [GitHub Releases](https://github.com/GitStudioHQ/gitstudio/releases) — `.dmg` (arm64 + x64), `.exe`, `.AppImage`, `.deb` | +| **Website** | [gitstudio.dev](https://gitstudio.dev) | +| **License** | **Apache-2.0** | +| **Sibling product** | [Merge Studio](https://marketplace.visualstudio.com/items?itemName=gitstudio.merge-studio) — `gitstudio.merge-studio`, the original 3-pane merge editor. Shares an engine, not a listing. | -## Repos +## Architecture notes -- **`../merge-studio`** — the shipped flagship; source of the reusable engine. -- **`../vscode-extension-starter`** — the token-free publish pipeline + the full "zero → published" guide, distilled from shipping Merge Studio. Fork it to bootstrap. -- **this repo** — vision + where the suite gets built. +- **One flagship extension, not a swarm** (the GitLens model). It grows pillar by pillar; Merge Studio stays a separate, focused product. +- **Webview custom editors** for rich UI (merge, graph, rebase), **providers + decorations** for ambient features (blame, history), and a **thin git service** with its own repo discovery (`git rev-parse`, symlink-safe) plus direct `.git` reads where speed matters — the views paint from local git instead of blocking on `vscode.git` activation. +- **Strict CSP + per-load nonces** on every webview; AI keys live in SecretStorage (extension) / safeStorage (desktop) and never reach a webview. +- **One core, two hosts.** The desktop app runs `@gitstudio/git-service` unchanged in Electron's main process and renders the same `@gitstudio/webview-ui` components the extension uses — behind the same `@gitstudio/host-bridge` protocol. ## License -TBD (Merge Studio is MIT). Pick before first publish. +**Apache-2.0** (see [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE)). Brand assets in [`brand/`](brand/) identify the project; don't use the GitStudio name/logo in a way that implies official endorsement. \ No newline at end of file diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..a37d343 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,101 @@ +# Releasing GitStudio + +GitStudio ships two products from this monorepo, each on its own release channel: + +| Product | Tag prefix | Workflow | Outputs | +|---|---|---|---| +| **VS Code / Cursor extension** (`apps/extension`) | `ext-v*` | `.github/workflows/release.yml` | `.vsix` → VS Code Marketplace + Open VSX + GitHub Release | +| **Desktop app** (`apps/desktop`, Electron) | `app-v*` | `.github/workflows/release-desktop.yml` | `.dmg` / `.zip` (mac ×2 arch), `.exe` (win), `.AppImage` + `.deb` (linux) → GitHub Release | + +Both release workflows also run typecheck + the full test suite first, so a broken build never publishes. `.github/workflows/ci.yml` runs the same gates on every push/PR to `main` — including the test suite on **ubuntu, macos, and windows**, the same OSes the release matrix builds on. + +Two invariants the workflows enforce — don't work around them: + +- **The tag must equal the `version` in the product's `package.json`.** Both workflows fail fast on a mismatch (vsce and electron-builder ship whatever is in `package.json`, not what the tag says). +- **Desktop releases own `/releases/latest`.** The in-app auto-updater resolves the repo's *latest* release for its feed, so extension releases are created with `--latest=false`. Never manually mark an `ext-v*` release as latest. + +--- + +## One-time setup (repo secrets) + +Add these under **Settings → Secrets and variables → Actions**. + +**Extension publish — required for the store publish steps** (the `.vsix` still attaches to the GitHub Release without them, but the publish steps **fail loudly** when missing, so a green run always means "actually published"): + +- `VSCE_PAT` — Azure DevOps PAT, scope **Marketplace → Manage**, for the `gitstudio` publisher (create the publisher once at ). → VS Code Marketplace. +- `OVSX_PAT` — Open VSX access token (create the `gitstudio` namespace once via `npx ovsx create-namespace gitstudio`). → Open VSX. + +If a publish step failed because a secret was missing: add the secret, then re-run the workflow via **Actions → Release Extension → Run workflow** — it rebuilds and publishes the current `package.json` version without a new tag. + +**Desktop code-signing / notarization** (optional; unsigned builds still attach to the Release) + +- macOS: `CSC_LINK` (base64 .p12), `CSC_KEY_PASSWORD`, and for notarization `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`. +- Windows: `WIN_CSC_LINK` (base64 .pfx), `WIN_CSC_KEY_PASSWORD`. + +`GITHUB_TOKEN` is provided automatically — no setup needed for the GitHub Release upload. + +--- + +## Cut an extension release + +```bash +# 1. Bump the version + add a CHANGELOG entry +# apps/extension/package.json -> "version": "1.0.0" +# apps/extension/CHANGELOG.md +# (optional) docs/releases/ext-v1.0.0.md -> used as the Release notes + +# 2. Commit, tag, push THE ONE TAG (never `--tags`: that pushes every stale +# local tag and can fire old release workflows) +git add apps/extension/package.json apps/extension/CHANGELOG.md +git commit -m "release(ext): 1.0.0" +git tag ext-v1.0.0 +git push origin main ext-v1.0.0 +``` + +The workflow packages `gitstudio.vsix`, attaches it to the GitHub Release (using `docs/releases/.md` as notes when present), then publishes to the Marketplace and Open VSX. Manual install for testers: + +```bash +cursor --install-extension gitstudio.vsix --force # or: +code --install-extension gitstudio.vsix --force +``` + +## Cut a desktop app release + +```bash +# 1. Bump apps/desktop/package.json -> "version": "1.0.0" +# (optional) docs/releases/app-v1.0.0.md -> used as the Release notes +git add apps/desktop/package.json +git commit -m "release(app): 1.0.0" +git tag app-v1.0.0 +git push origin main app-v1.0.0 +``` + +A `create-release` job makes the Release once (so the matrix jobs never race each other), then a 4-way matrix (**macos-14** arm64, **macos-15-intel** x64, **windows-latest**, **ubuntu-22.04**) builds each installer **natively** — deliberate: the integrated terminal's `node-pty` is a native module, and building per-arch on its own OS avoids cross-compiling its prebuild. Linux builds pin **ubuntu-22.04** so the AppImage links an old-enough glibc for Ubuntu 22.04 / Debian 12 users. The installers upload to the Release for `app-v1.0.0`: + +- **macOS** — `GitStudio-1.0.0-arm64.dmg`, `GitStudio-1.0.0-x64.dmg` (+ `.zip`) +- **Windows** — `GitStudio-Setup-1.0.0.exe` (NSIS, user-choosable install dir) +- **Linux** — `GitStudio-1.0.0-x64.AppImage` (universal), `GitStudio-1.0.0-x64.deb` (Debian/Ubuntu) + +Artifact names are pinned in `electron-builder.yml` (no spaces, arch-suffixed) so the website can link them predictably: `https://github.com/GitStudioHQ/gitstudio/releases/download/app-v/`. + +**Auto-update:** Windows and Linux update in-app (`latest.yml` / `latest-linux.yml` ship with the release). macOS update checks are intentionally disabled in the app (two per-arch runners would clobber each other's `latest-mac.yml`, and unsigned builds can't apply Squirrel.Mac updates) — mac users update via the website/Release page. + +> Unsigned macOS/Windows builds trigger the OS "unidentified developer" prompt. Add the signing secrets above to remove it. macOS notarization also needs the Apple secrets. + +--- + +## Build locally + +```bash +# Extension .vsix +cd apps/extension && npm run package && npx @vscode/vsce package --no-dependencies + +# Desktop app (host platform only — linux .deb/.AppImage need a Linux host/CI) +cd apps/desktop +npm run package # electron-builder --dir -> release//GitStudio.app (fast, no installer) +npm run dist # full installers for the host OS -> release/*.dmg, *.zip, ... +``` + +## Versioning + +Independent per product (the extension moves faster than the app). The tag↔`package.json` guard in each workflow keeps them honest; the desktop auto-update feed (`publish:` in `electron-builder.yml`, `GitStudioHQ/gitstudio`) reads the tag's Release assets. diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000..84c36c5 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,56 @@ +# GitStudio Desktop (Electron) + +The native cross-platform desktop app (macOS / Windows / Linux), milestone **M13**. +It is a **reuse** of the proven shared core, not a rewrite: + +- `@gitstudio/git-service` runs **unchanged** in Electron's Node main process + (`GitContext`, the streaming log/ref/blame providers, `NodeGitAdapter` for repo + discovery via `rev-parse --show-toplevel`). +- `@gitstudio/engine` (pure) lays out the commit graph (`computeGraphLayout`) and + builds the diff/merge models. +- `@gitstudio/host-bridge` carries the protocol; the WireRow assembly is the + shared `graphWire.buildWireRows` — the same code the VS Code graph panel uses. +- `@gitstudio/webview-ui` renders in the renderer: the `` Lit + element, the Monaco `DiffView` (2-pane) and `MergeView` (3-pane), the theme + bridge, and the JetBrains diff CSS — all **unmodified**. + +## Architecture + +| Process | File | Role | +| ------------ | -------------------------------- | ------------------------------------------------------------------------- | +| **main** | `src/main/main.ts` | `BrowserWindow`, app menu, `ipcMain.handle` endpoints (DesktopHostBridge) | +| | `src/main/gitBridge.ts` | wraps git-service + engine: graph paging, details, diff, status, conflicts, actions | +| | `src/main/repoStore.ts` | caches the open `GitContext`, recent repos | +| **preload** | `src/preload/preload.ts` | `contextBridge.exposeInMainWorld("gitstudio", …)` — typed `invoke`/`on` | +| **renderer** | `src/renderer/renderer.ts` | the app shell: titlebar, sidebar, graph, commit details, diff/merge | +| | `src/renderer/graphMount.ts` | mounts ``, adapts the graph protocol to IPC | +| | `src/renderer/diffPanel.ts` | mounts the shared `DiffView` / `MergeView` | +| | `src/renderer/desktopTheme.ts` | supplies the `--vscode-*` tokens + `vscode-dark/light` body class | +| **shared** | `src/shared/ipc.ts` | the type-only IPC contract | +| | `src/shared/graphAdapterCore.ts` | pure page→graph-message translation (unit-tested) | + +## Develop + +```bash +npm run build --workspace apps/desktop # build the three bundles + monaco worker +npm run dev --workspace apps/desktop # build + launch Electron +npm run check-types --workspace apps/desktop +npm test --workspace apps/desktop +``` + +`electron` is marked external in `esbuild.js`, so the bundles build without the +Electron binary installed. + +## Package + +```bash +npm run package --workspace apps/desktop # electron-builder --dir (host OS smoke) +npm run dist --workspace apps/desktop # full installers for the host OS +``` + +Config: `electron-builder.yml` (appId `dev.gitstudio.desktop`, productName +"GitStudio"; mac `dmg`, win `nsis`, linux `AppImage`; icon from +`brand/gitstudio-icon-512.png` via `build/icon.png`). CI builds all three on a +macOS/Windows/Ubuntu matrix on `app-v*` tags +(`.github/workflows/release-desktop.yml`); signing/publish steps skip cleanly +when their secrets are absent. diff --git a/apps/desktop/build/icon.png b/apps/desktop/build/icon.png new file mode 100644 index 0000000..ad165e8 Binary files /dev/null and b/apps/desktop/build/icon.png differ diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml new file mode 100644 index 0000000..bdf463a --- /dev/null +++ b/apps/desktop/electron-builder.yml @@ -0,0 +1,83 @@ +appId: dev.gitstudio.desktop +productName: GitStudio +copyright: Copyright © GitStudio. Licensed under Apache-2.0. + +# In an npm-workspaces repo `electron` is hoisted to the root node_modules, so +# electron-builder can't infer the version from the local package tree; pin it +# explicitly (kept in step with the devDependency in package.json). +electronVersion: 33.4.11 + +# node-pty ships N-API prebuilds that already load in Electron's ABI, so DON'T +# let electron-builder rebuild native modules from source (node-gyp needs Python +# distutils, removed in 3.12, and the rebuild fails the whole package). +npmRebuild: false + +# Only the built bundles + the page shell ship; sources stay out of the app. +# Sourcemaps are dev-only — never ship them (they bloat the app by ~20MB and +# leak source). A production esbuild run doesn't emit them, but exclude defensively +# so a stale dev `.map` left in dist/ can never end up in a shipped build. +files: + - dist/** + - "!dist/**/*.map" + - package.json + +# node-pty is a native module (the integrated terminal's PTY). Its prebuilt +# `.node` binary CANNOT load from inside the asar, so unpack it. +asarUnpack: + - "**/node_modules/node-pty/**" + +directories: + output: release + buildResources: build + +# Auto-update feed (no-op until the first app-v* release publishes installers). +publish: + provider: github + owner: GitStudioHQ + repo: gitstudio + +# A single ≥512px PNG in buildResources (build/icon.png); electron-builder +# converts it to .icns / .ico per platform automatically. +mac: + category: public.app-category.developer-tools + # dmg is the human download; zip is REQUIRED for electron-updater to deliver + # macOS auto-updates (it can't update from a .dmg). + target: + - dmg + - zip + # Include the arch so the Apple-Silicon and Intel builds (built natively on + # separate runners) don't clobber each other's assets on the Release. + artifactName: ${productName}-${version}-${arch}.${ext} + hardenedRuntime: true + gatekeeperAssess: false + +dmg: + title: GitStudio ${version} + +win: + target: + - nsis + # No spaces (the nsis default is "GitStudio Setup 1.0.0.exe"): GitHub asset + # names replace spaces with dots while electron-updater requests them with + # dashes — a guaranteed 404. Dashes also give the website stable download URLs. + artifactName: ${productName}-Setup-${version}.${ext} + +nsis: + oneClick: false + perMachine: false + allowToChangeInstallationDirectory: true + +linux: + category: Development + # AppImage = the universal, distro-agnostic download; deb = native install for + # Debian/Ubuntu/Mint (the largest Linux desktop share). + target: + - AppImage + - deb + # Pinned so the website can link GitStudio--.AppImage forever. + artifactName: ${productName}-${version}-${arch}.${ext} + maintainer: GitStudio + synopsis: A free, open-source, JetBrains-grade Git suite for your desktop. + +deb: + priority: optional diff --git a/apps/desktop/esbuild.js b/apps/desktop/esbuild.js new file mode 100644 index 0000000..8cd70a1 --- /dev/null +++ b/apps/desktop/esbuild.js @@ -0,0 +1,206 @@ +// esbuild build for the GitStudio desktop (Electron) app — three bundles plus +// Monaco's editor worker: +// • main — Electron main process (Node/CJS; `electron` external) +// • preload — contextBridge preload (Node/CJS; `electron` external) +// • renderer — the page bundle (browser/IIFE; bundles Lit + Monaco + +// the @gitstudio/* packages) +// +// The renderer mirrors the extension's webview build: the dompurify-redirect +// plugin (so Monaco carries the patched standalone dompurify), `.ttf` inlined as +// a data URL, and a standalone Monaco worker. `electron` is marked external, so +// these bundles build WITHOUT the Electron binary installed. + +const esbuild = require("esbuild"); +const path = require("path"); +const fs = require("fs"); + +const production = process.argv.includes("--production"); +const watch = process.argv.includes("--watch"); + +const repoRoot = path.resolve(__dirname, "../.."); +const rendererDir = path.resolve(__dirname, "src/renderer"); +const distDir = path.resolve(__dirname, "dist"); + +/** + * monaco-editor vendors a stale DOMPurify; redirect its internal import to the + * patched standalone dompurify (pinned via the root npm "overrides"). Identical + * to the extension's plugin. + * @type {import('esbuild').Plugin} + */ +const dompurifyRedirectPlugin = { + name: "dompurify-redirect", + setup(build) { + const patched = path.join( + path.dirname(require.resolve("dompurify")), + "purify.es.mjs", + ); + build.onResolve({ filter: /dompurify[\\/]dompurify\.js$/ }, () => ({ + path: patched, + })); + }, +}; + +/** @type {import('esbuild').Plugin} */ +const problemMatcherPlugin = { + name: "esbuild-problem-matcher", + setup(build) { + build.onStart(() => console.log("[build] started")); + build.onEnd((result) => { + for (const { text, location } of result.errors) { + console.error(`✘ [ERROR] ${text}`); + if (location) { + console.error( + ` ${location.file}:${location.line}:${location.column}:`, + ); + } + } + console.log( + `[build] finished${result.errors.length ? ` with ${result.errors.length} error(s)` : ""}`, + ); + }); + }, +}; + +/** @type {import('esbuild').BuildOptions} */ +const base = { + bundle: true, + minify: production, + sourcemap: !production, + logLevel: "silent", + tsconfig: path.resolve(__dirname, "tsconfig.json"), + plugins: [problemMatcherPlugin], +}; + +function copyStaticAssets() { + fs.mkdirSync(path.join(distDir, "renderer"), { recursive: true }); + // The page shell — cache-bust the bundle refs so a reload after a rebuild can + // NEVER serve Chromium's stale cached renderer.css / renderer.js. + const stamp = Date.now().toString(36); + const html = fs + .readFileSync(path.join(rendererDir, "index.html"), "utf8") + .replace('href="./renderer.css"', `href="./renderer.css?v=${stamp}"`) + .replace('src="./theme-boot.js"', `src="./theme-boot.js?v=${stamp}"`) + .replace('src="./renderer.js"', `src="./renderer.js?v=${stamp}"`); + fs.writeFileSync(path.join(distDir, "renderer/index.html"), html); + // The pre-paint theme bootstrap — a same-origin file so the CSP can forbid + // inline scripts. Copied verbatim (esbuild does not process it). + fs.copyFileSync( + path.join(rendererDir, "theme-boot.js"), + path.join(distDir, "renderer/theme-boot.js"), + ); + // The window/dev icon (electron-builder embeds the packaged icon separately) + // plus the in-app brand assets. The welcome hero is the squircle app-icon + // mark, theme-swapped (a light-tile sibling so it sits on the light welcome + // screen instead of floating as a dark square); the wordmark is theme-swapped + // text. The top-bar mark is an inline currentColor SVG in the renderer, so it + // needs no asset here. + const brand = { + "brand/gitstudio-icon-512.png": "icon.png", + // Light-tile sibling of the dock mark, so the dock icon can theme-swap at + // runtime (main's `appearance:dockIcon` picks light/dark per the app theme). + "brand/gitstudio-icon-light-512.png": "icon-light.png", + "brand/gitstudio-icon.svg": "brand-icon.svg", + "brand/gitstudio-icon-light.svg": "brand-icon-light.svg", + "brand/gitstudio-wordmark-light.svg": "brand-wordmark-light.svg", + "brand/gitstudio-wordmark-dark.svg": "brand-wordmark-dark.svg", + }; + for (const [src, dest] of Object.entries(brand)) { + const abs = path.join(repoRoot, src); + if (fs.existsSync(abs)) { + fs.copyFileSync(abs, path.join(distDir, "renderer", dest)); + } + } +} + +/** A plugin that re-copies the static assets after each (re)build, for watch. */ +const copyAssetsPlugin = { + name: "copy-assets", + setup(build) { + build.onEnd(() => copyStaticAssets()); + }, +}; + +async function main() { + const mainCtx = await esbuild.context({ + ...base, + entryPoints: [path.resolve(__dirname, "src/main/main.ts")], + outfile: path.join(distDir, "main/main.js"), + platform: "node", + format: "cjs", + target: "node20", + // `electron` is provided by the runtime; `node-pty` is a NATIVE module (the + // terminal bridge loads it lazily) that can't be bundled — kept external so + // `require("node-pty")` resolves from node_modules (packaged builds + // asar-unpack it). electron-updater is pure JS and MUST be bundled: the + // packaged app ships only `dist/**` (not node_modules), so leaving it + // external silently disabled auto-update in every shipped build. + external: ["electron", "node-pty"], + }); + + const preloadCtx = await esbuild.context({ + ...base, + entryPoints: [path.resolve(__dirname, "src/preload/preload.ts")], + outfile: path.join(distDir, "preload/preload.js"), + platform: "node", + format: "cjs", + target: "node20", + external: ["electron"], + }); + + const rendererCtx = await esbuild.context({ + ...base, + entryPoints: [path.resolve(rendererDir, "renderer.ts")], + outfile: path.join(distDir, "renderer/renderer.js"), + platform: "browser", + format: "iife", + target: "chrome120", + loader: { ".ttf": "dataurl" }, + plugins: [problemMatcherPlugin, dompurifyRedirectPlugin, copyAssetsPlugin], + }); + + // Monaco's editor worker, bundled standalone; loaded via a blob shim at runtime. + const workerCtx = await esbuild.context({ + ...base, + entryPoints: [ + require.resolve("monaco-editor/esm/vs/editor/editor.worker.js"), + ], + outfile: path.join(distDir, "renderer/editor.worker.js"), + platform: "browser", + format: "iife", + target: "chrome120", + }); + + const contexts = [mainCtx, preloadCtx, rendererCtx, workerCtx]; + + if (watch) { + await Promise.all(contexts.map((c) => c.watch())); + console.log("[build] watching…"); + } else { + await Promise.all(contexts.map((c) => c.rebuild())); + copyStaticAssets(); + reportSizes(); + await Promise.all(contexts.map((c) => c.dispose())); + } +} + +function reportSizes() { + const files = [ + "main/main.js", + "preload/preload.js", + "renderer/renderer.js", + "renderer/editor.worker.js", + ]; + console.log("[build] bundle sizes:"); + for (const rel of files) { + const p = path.join(distDir, rel); + if (fs.existsSync(p)) { + const kb = (fs.statSync(p).size / 1024).toFixed(1); + console.log(` ${rel.padEnd(28)} ${kb} KB`); + } + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..3a4c66c --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,41 @@ +{ + "name": "gitstudio-desktop", + "version": "1.0.0", + "private": true, + "license": "Apache-2.0", + "description": "GitStudio — the native cross-platform desktop app (Electron). Reuses @gitstudio/engine, git-service, webview-ui, and host-bridge behind host-agnostic seams.", + "author": "GitStudio ", + "homepage": "https://gitstudio.dev", + "main": "dist/main/main.js", + "scripts": { + "postinstall": "node scripts/fix-pty-perms.js", + "build": "node esbuild.js --production", + "watch": "node esbuild.js --watch", + "dev": "node esbuild.js && electron .", + "start": "node esbuild.js && electron .", + "package": "node esbuild.js --production && electron-builder --dir", + "dist": "node esbuild.js --production && electron-builder", + "test": "tsx --test \"test/**/*.test.ts\"", + "check-types": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.renderer.json" + }, + "dependencies": { + "node-pty": "^1.1.0" + }, + "devDependencies": { + "@gitstudio/ai": "*", + "@gitstudio/engine": "*", + "@gitstudio/git-service": "*", + "@gitstudio/host-bridge": "*", + "@gitstudio/webview-ui": "*", + "@types/node": "^22.0.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "electron": "^33.0.0", + "electron-builder": "^25.1.8", + "electron-updater": "^6.3.9", + "esbuild": "^0.28.1", + "monaco-editor": "^0.55.1", + "tsx": "^4.22.4", + "typescript": "^6.0.3" + } +} diff --git a/apps/desktop/scripts/fix-pty-perms.js b/apps/desktop/scripts/fix-pty-perms.js new file mode 100644 index 0000000..cd7fe11 --- /dev/null +++ b/apps/desktop/scripts/fix-pty-perms.js @@ -0,0 +1,66 @@ +// Restore the executable bit on node-pty's Unix `spawn-helper` after install. +// +// node-pty's published prebuilds ship `spawn-helper` (a tiny Mach-O/ELF binary +// node-pty execs via posix_spawn to fork the PTY). Depending on the package +// manager / hoisting, npm can extract it WITHOUT the execute bit — and then the +// first `pty.spawn()` fails with the opaque "posix_spawnp failed.", which the +// integrated terminal surfaces as "Could not start a terminal session." +// +// This runs as a `postinstall` hook so the bit is restored on every install, +// for both `npm run dev` and the file electron-builder copies into the packaged +// app (asarUnpack keeps node-pty out of the asar, preserving these perms). It's +// a no-op on Windows (ConPTY/winpty need no helper) and tolerant of node-pty not +// being installed yet. + +const fs = require("fs"); +const path = require("path"); + +if (process.platform === "win32") process.exit(0); + +/** Locate the installed node-pty package dir, tolerating workspace hoisting. */ +function findNodePty() { + try { + // Resolves through the same algorithm the app uses at runtime. + return path.dirname(require.resolve("node-pty/package.json")); + } catch { + // Fallbacks for the hoisted (repo-root) and local layouts. + for (const rel of ["../../../node_modules/node-pty", "../node_modules/node-pty"]) { + const dir = path.resolve(__dirname, rel); + if (fs.existsSync(path.join(dir, "package.json"))) return dir; + } + return undefined; + } +} + +const ptyDir = findNodePty(); +if (!ptyDir) { + // Nothing to fix (e.g. deps not installed yet); don't fail the install. + process.exit(0); +} + +// Every place node-pty may load the helper from: each prebuilt platform dir and +// a from-source build. We chmod whatever exists rather than guessing the host. +const prebuilds = path.join(ptyDir, "prebuilds"); +const candidates = []; +if (fs.existsSync(prebuilds)) { + for (const entry of fs.readdirSync(prebuilds)) { + candidates.push(path.join(prebuilds, entry, "spawn-helper")); + } +} +candidates.push(path.join(ptyDir, "build", "Release", "spawn-helper")); +candidates.push(path.join(ptyDir, "build", "Debug", "spawn-helper")); + +let fixed = 0; +for (const helper of candidates) { + if (!fs.existsSync(helper)) continue; + try { + fs.chmodSync(helper, 0o755); + fixed++; + } catch (err) { + console.warn(`[fix-pty-perms] could not chmod ${helper}: ${err.message}`); + } +} + +if (fixed > 0) { + console.log(`[fix-pty-perms] made ${fixed} node-pty spawn-helper binary(ies) executable`); +} diff --git a/apps/desktop/src/main/aiBridge.ts b/apps/desktop/src/main/aiBridge.ts new file mode 100644 index 0000000..383b045 --- /dev/null +++ b/apps/desktop/src/main/aiBridge.ts @@ -0,0 +1,826 @@ +// The desktop's AI layer: owns the user's model connections, runs the inline ✨ +// tasks and the Assistant agent, and brokers the MCP "Agent Access" config. +// +// It is the ONLY place API keys live: each connection's key is encrypted at rest +// with Electron safeStorage (userData/ai-keys/.bin) and never crosses to the +// renderer — the renderer only ever sees a redacted AiConnectionView. All model +// traffic runs here in the main process (Node fetch), so there's no CORS and no +// secret in a web context. Everything degrades silently: with no usable +// connection the ✨ affordances and the Assistant simply stay hidden; git is +// never gated or blocked by AI. + +import { app, safeStorage } 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 { + DEFAULT_AGENT_CONFIG, + EMPTY_AI_SETTINGS, + PROVIDER_PRESETS, + assist, + connectionFromPreset, + generateChangelog, + generateCommitMessage, + generatePrDescription, + explainConflict, + explainDiff, + isConnectionUsable, + knownModels, + makeProvider, + pickConnection, + resolveModelId, + reviewDiff, + runAgent, + selectTools, + suggestBranchNames, + summarizeChanges, + type AiSettings, + type Connection, + type GitTool, + type Provider, +} from "@gitstudio/ai/index"; +import { createGitToolHost } from "@gitstudio/git-service/index"; +import { mcpInfo, installMcp } from "./mcpConfig"; +import { CliProvider, cliSpecFor, detectCli, withThinking } from "./cliProvider"; +import { ConversationStore, type ChatSession } from "./assistantSessions"; +import type { RepoStore } from "./repoStore"; +import type { + AgentConfig, + AgentConfirmAnswer, + AgentRunRequest, + AiConnectionPatch, + AiConnectionView, + AiDone, + AiModelOption, + AiPresetView, + AiSettingsView, + AiTaskInput, + AiTaskName, + AiTestResult, + ChatSendRequest, + ChatSummary, + ChatView, + IpcEvents, + McpInfo, + McpInstallRequest, +} from "../shared/ipc"; + +type Send = (event: E, data: IpcEvents[E]) => void; + +/** How long the agent waits for the user to approve a write before giving up. */ +const CONFIRM_TIMEOUT_MS = 120_000; + +export class AiBridge { + private settings: AiSettings = { ...EMPTY_AI_SETTINGS }; + private loaded = false; + /** In-memory decrypted key cache, keyed by connection id. */ + private readonly keyCache = new Map(); + /** In-flight requests → their AbortController (for ai:cancel). */ + private readonly aborts = new Map(); + /** Pending agent write-confirmations → their resolver. */ + private readonly confirms = new Map void>(); + /** Cached CLI-availability per preset (is `claude`/`codex`/`gemini` installed?). */ + private readonly cliDetect = new Map(); + /** Persisted Assistant chats + their warm CLI processes (survive refresh). */ + private readonly chats = new ConversationStore(); + + constructor( + private readonly repos: RepoStore, + private readonly send: Send, + ) {} + + // ── persistence ──────────────────────────────────────────────────────────── + + private settingsPath(): string { + return join(app.getPath("userData"), "ai-settings.json"); + } + private keyPath(id: string): string { + return join(app.getPath("userData"), "ai-keys", `${id}.bin`); + } + + private async ensureLoaded(): Promise { + if (this.loaded) { + return; + } + this.loaded = true; + try { + const raw = await readFile(this.settingsPath(), "utf8"); + const parsed = JSON.parse(raw) as AiSettings; + if (parsed && Array.isArray(parsed.connections)) { + this.settings = parsed; + } + } catch { + // No settings yet — start empty. + } + } + + private async persist(): Promise { + try { + await mkdir(app.getPath("userData"), { recursive: true }); + await writeFile(this.settingsPath(), JSON.stringify(this.settings, null, 2)); + } catch { + // Best-effort; never block a UI action on persistence. + } + } + + // ── keys (encrypted at rest) ───────────────────────────────────────────────── + + private hasKeyFile(id: string): boolean { + return existsSync(this.keyPath(id)); + } + + private async loadKey(id: string): Promise { + if (this.keyCache.has(id)) { + return this.keyCache.get(id); + } + try { + const buf = await readFile(this.keyPath(id)); + const key = safeStorage.isEncryptionAvailable() + ? safeStorage.decryptString(buf) + : buf.toString("utf8"); + this.keyCache.set(id, key); + return key; + } catch { + return undefined; + } + } + + private async storeKey(id: string, key: string): Promise { + await mkdir(join(app.getPath("userData"), "ai-keys"), { recursive: true }); + const data = safeStorage.isEncryptionAvailable() + ? safeStorage.encryptString(key) + : Buffer.from(key, "utf8"); + await writeFile(this.keyPath(id), data); + this.keyCache.set(id, key); + } + + private async deleteKey(id: string): Promise { + this.keyCache.delete(id); + try { + await unlink(this.keyPath(id)); + } catch { + // already gone + } + } + + // ── views ──────────────────────────────────────────────────────────────────── + + private connView(conn: Connection): AiConnectionView { + const hasKey = this.hasKeyFile(conn.id); + // For a CLI connection, "usable" means the binary was detected on PATH + // (defaults to optimistic `true` until the first detection completes). + const usable = + conn.wire === "cli" + ? this.cliDetect.get(conn.preset) ?? true + : isConnectionUsable(conn, hasKey); + return { + id: conn.id, + label: conn.label, + preset: conn.preset, + wire: conn.wire, + baseUrl: conn.baseUrl, + models: { ...conn.models }, + needsKey: conn.needsKey, + local: conn.local === true, + hasKey, + usable, + }; + } + + /** Probe each distinct CLI preset in use so the settings view shows real status. */ + private async refreshCliDetection(): Promise { + const presets = new Set( + this.settings.connections.filter((c) => c.wire === "cli").map((c) => c.preset), + ); + await Promise.all( + [...presets].map(async (preset) => { + const spec = cliSpecFor(preset); + if (spec) { + const { ok } = await detectCli(spec.command); + this.cliDetect.set(preset, ok); + } + }), + ); + } + + private agentConfig() { + return { ...DEFAULT_AGENT_CONFIG, ...(this.settings.agent ?? {}) }; + } + + private view(): AiSettingsView { + const connections = this.settings.connections.map((c) => this.connView(c)); + return { + connections, + defaultId: this.settings.defaultId, + enabled: connections.some((c) => c.usable), + agent: this.agentConfig(), + }; + } + + async setAgentConfig(patch: Partial): Promise { + await this.ensureLoaded(); + this.settings.agent = { ...this.agentConfig(), ...patch }; + await this.persist(); + return this.view(); + } + + /** + * The models the connection's provider offers — propagated to the in-app + * picker so the user picks a real model directly (no manual setup). HTTP + * providers are queried live (`/models`); CLIs and any failures fall back to a + * known catalog. The connection's own configured models are always included. + */ + async listModels(connectionId?: string): Promise { + await this.ensureLoaded(); + const conn = + (connectionId ? this.settings.connections.find((c) => c.id === connectionId) : undefined) ?? + pickConnection(this.settings); + if (!conn) { + return []; + } + const ids: string[] = []; + // Always offer whatever the connection already has configured. + for (const t of ["mid", "deep", "fast"] as const) { + const m = conn.models[t]?.trim(); + if (m) ids.push(m); + } + if (conn.wire !== "cli") { + const live = await this.fetchModels(conn).catch(() => []); + ids.push(...live); + } + ids.push(...knownModels(conn.preset)); + // Dedupe, preserving order (configured + live first, then known). + const seen = new Set(); + const out: AiModelOption[] = []; + for (const id of ids) { + if (id && !seen.has(id)) { + seen.add(id); + out.push({ id }); + } + } + return out; + } + + /** Live-query a HTTP provider's model list (best-effort, short timeout). */ + private async fetchModels(conn: Connection): Promise { + const base = conn.baseUrl.replace(/\/+$/, ""); + const isAnthropic = conn.wire === "anthropic"; + const url = isAnthropic ? `${base}/v1/models` : `${base}/models`; + const headers: Record = {}; + const key = await this.loadKey(conn.id); + if (isAnthropic) { + if (!key) return []; + headers["x-api-key"] = key; + headers["anthropic-version"] = "2023-06-01"; + } else if (key) { + headers["Authorization"] = `Bearer ${key}`; + } + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 4000); + try { + const res = await fetch(url, { headers, signal: ctrl.signal }); + if (!res.ok) return []; + const json = (await res.json()) as { + data?: Array<{ id?: string; name?: string }>; + models?: Array<{ id?: string; name?: string }>; + }; + const list = json.data ?? json.models ?? []; + return list.map((m) => m.id ?? m.name ?? "").filter((s): s is string => s.length > 0); + } catch { + return []; + } finally { + clearTimeout(timer); + } + } + + async getSettings(): Promise { + await this.ensureLoaded(); + await this.refreshCliDetection(); + return this.view(); + } + + catalog(): AiPresetView[] { + return PROVIDER_PRESETS.map((p) => ({ + id: p.id, + label: p.label, + blurb: p.blurb, + wire: p.wire, + baseUrl: p.baseUrl, + needsKey: p.needsKey, + local: p.local === true, + keyUrl: p.keyUrl, + icon: p.icon, + note: p.note, + models: { ...p.models }, + })); + } + + // ── connection CRUD ────────────────────────────────────────────────────────── + + async addConnection(presetId: string): Promise { + await this.ensureLoaded(); + const conn = connectionFromPreset(presetId, randomUUID()); + this.settings.connections.push(conn); + if (!this.settings.defaultId) { + this.settings.defaultId = conn.id; + } + await this.persist(); + return this.view(); + } + + async updateConnection(patch: AiConnectionPatch): Promise { + await this.ensureLoaded(); + const conn = this.settings.connections.find((c) => c.id === patch.id); + if (conn) { + if (typeof patch.label === "string") conn.label = patch.label; + if (typeof patch.baseUrl === "string") conn.baseUrl = patch.baseUrl; + if (patch.models) conn.models = { ...patch.models }; + await this.persist(); + } + return this.view(); + } + + async removeConnection(id: string): Promise { + await this.ensureLoaded(); + this.settings.connections = this.settings.connections.filter((c) => c.id !== id); + if (this.settings.defaultId === id) { + this.settings.defaultId = this.settings.connections[0]?.id; + } + await this.deleteKey(id); + await this.persist(); + return this.view(); + } + + async setDefault(id: string): Promise { + await this.ensureLoaded(); + if (this.settings.connections.some((c) => c.id === id)) { + this.settings.defaultId = id; + await this.persist(); + } + return this.view(); + } + + async setKey(id: string, key: string): Promise { + await this.ensureLoaded(); + if (key.trim().length === 0) { + await this.deleteKey(id); + } else { + await this.storeKey(id, key.trim()); + } + return this.view(); + } + + async test(id: string): Promise { + await this.ensureLoaded(); + const conn = this.settings.connections.find((c) => c.id === id); + if (!conn) { + return { ok: false, message: "Connection not found." }; + } + // CLI connections: just confirm the binary is installed (don't spend quota). + if (conn.wire === "cli") { + const spec = cliSpecFor(conn.preset); + if (!spec) { + return { ok: false, message: "Unknown local CLI." }; + } + const { ok, version } = await detectCli(spec.command); + this.cliDetect.set(conn.preset, ok); + return ok + ? { ok: true, message: `Found \`${spec.command}\`${version ? ` (${version})` : ""}.` } + : { ok: false, message: `\`${spec.command}\` isn't installed or not on PATH. ${spec.install}` }; + } + const provider = this.providerFor(conn); + try { + const r = await provider.chat( + [{ role: "user", content: "Reply with exactly: OK" }], + { model: "fast", maxTokens: 16 }, + ); + const model = resolveModelId(conn, "fast") ?? "model"; + if (r.text.trim().length > 0 || r.stopReason === "stop") { + return { ok: true, message: `Connected — ${model} responded.`, model }; + } + return { ok: false, message: "The model returned an empty response." }; + } catch (err) { + return { ok: false, message: err instanceof Error ? err.message : String(err) }; + } + } + + // ── provider resolution ────────────────────────────────────────────────────── + + private providerFor(conn: Connection): Provider { + if (conn.wire === "cli") { + return new CliProvider({ + preset: conn.preset, + cwd: this.repos.current()?.root, + resolveModel: (tier) => resolveModelId(conn, tier ?? "mid"), + label: `${conn.label}${conn.models.mid ? ` · ${conn.models.mid}` : ""}`, + }); + } + return makeProvider(conn, () => this.loadKey(conn.id)); + } + + private async resolveProvider(connectionId?: string, task?: string): Promise<{ provider: Provider; conn: Connection } | undefined> { + await this.ensureLoaded(); + let conn: Connection | undefined; + if (connectionId) { + conn = this.settings.connections.find((c) => c.id === connectionId); + } + conn ??= pickConnection(this.settings, task); + if (!conn) { + return undefined; + } + if (!isConnectionUsable(conn, this.hasKeyFile(conn.id))) { + return undefined; + } + return { provider: this.providerFor(conn), conn }; + } + + // ── one-shot tasks ─────────────────────────────────────────────────────────── + + async runTask(requestId: string, task: AiTaskName, input: AiTaskInput): Promise { + const resolved = await this.resolveProvider(input.connectionId, task); + if (!resolved) { + return { requestId, ok: false, message: "No AI model is connected. Add one in Settings ▸ AI." }; + } + const ctx = this.repos.getContext(); + if (!ctx) { + return { requestId, ok: false, message: "No repository is open." }; + } + const host = createGitToolHost(ctx); + const abort = new AbortController(); + this.aborts.set(requestId, abort); + const onDelta = (delta: string) => this.send("ai:delta", { requestId, delta }); + const taskCtx = { signal: abort.signal, onDelta }; + + try { + const { provider } = resolved; + let text: string | null = null; + switch (task) { + case "commitMessage": { + const diff = input.diff ?? (await host.diff({ staged: true })); + if (!diff.trim()) { + return { requestId, ok: false, message: "Nothing is staged to summarize." }; + } + const recent = input.commits ?? (await host.log({ limit: 10 })).map((c) => c.subject); + text = await generateCommitMessage(provider, diff, { recentSubjects: recent, ctx: taskCtx }); + break; + } + case "explainDiff": { + const diff = input.diff ?? (await this.gatherDiff(host, input)); + if (!diff.trim()) return { requestId, ok: false, message: "No changes to explain." }; + text = await explainDiff(provider, diff, taskCtx); + break; + } + case "summarizeChanges": { + const diff = input.diff ?? (await this.gatherDiff(host, input)); + if (!diff.trim()) return { requestId, ok: false, message: "No changes to summarize." }; + text = await summarizeChanges(provider, diff, taskCtx); + break; + } + case "prDescription": { + const base = input.base ?? "main"; + const cmp = input.commits ? undefined : await host.compare(base, "HEAD"); + const commits = input.commits ?? (cmp?.commits ?? []).map((c) => c.subject); + const diff = input.diff ?? (await host.diff({ base, head: "HEAD" })); + text = await generatePrDescription(provider, commits, diff, taskCtx); + break; + } + case "reviewDiff": { + const diff = input.diff ?? (await this.gatherDiff(host, input)); + if (!diff.trim()) return { requestId, ok: false, message: "No changes to review." }; + text = await reviewDiff(provider, diff, taskCtx); + break; + } + case "explainConflict": { + const conflict = input.conflict ?? (await this.gatherConflict(input.path)); + if (!conflict) { + return { requestId, ok: false, message: "Couldn't read the conflict." }; + } + text = await explainConflict(provider, conflict, taskCtx); + break; + } + case "changelog": { + const base = input.base; + const range = base ? `${base}..HEAD` : "HEAD"; + const commits = input.commits ?? (await host.log({ ref: range, limit: 200 })).map((c) => c.subject); + text = await generateChangelog(provider, commits, { ctx: taskCtx }); + break; + } + case "branchName": { + text = await suggestBranchNames(provider, input.description ?? "", taskCtx); + break; + } + case "assist": { + text = await assist(provider, input.description ?? "", taskCtx); + break; + } + default: + return { requestId, ok: false, message: `Unknown task: ${task}` }; + } + if (text === null) { + return { requestId, ok: false, message: "The model returned nothing." }; + } + return { requestId, ok: true, text }; + } catch (err) { + return { requestId, ok: false, message: err instanceof Error ? err.message : String(err) }; + } finally { + this.aborts.delete(requestId); + } + } + + private async gatherDiff(host: ReturnType, input: AiTaskInput): Promise { + if (input.sha) { + // The commit's diff vs its first parent. + return host.diff({ base: `${input.sha}^`, head: input.sha, path: input.path }); + } + if (input.base) { + // Honour an explicit head (PR / Compare diff base…head); else compare base…HEAD. + return host.diff({ base: input.base, head: input.head ?? "HEAD", path: input.path }); + } + // Default: the unstaged working-tree diff (fall back to staged if empty). + const working = await host.diff({ path: input.path }); + return working.trim() ? working : host.diff({ staged: true, path: input.path }); + } + + private async gatherConflict(path?: string): Promise<{ path: string; base?: string; ours: string; theirs: string } | undefined> { + const ctx = this.repos.getContext(); + if (!ctx || !path) { + return undefined; + } + const read = async (stage: number): Promise => { + const r = await ctx.process.run(["show", `:${stage}:${path}`]).catch(() => null); + return r && r.code === 0 ? r.stdout : ""; + }; + const ours = await read(2); + const theirs = await read(3); + if (!ours && !theirs) { + return undefined; + } + const base = await read(1); + return { path, base: base || undefined, ours, theirs }; + } + + cancel(requestId: string): void { + this.aborts.get(requestId)?.abort(); + this.aborts.delete(requestId); + // Deny any pending confirmations for this request so the agent unwinds. + for (const [callId, resolve] of this.confirms) { + if (callId.startsWith(requestId)) { + resolve(false); + this.confirms.delete(callId); + } + } + } + + // ── agent ──────────────────────────────────────────────────────────────────── + + async runAgentTask(req: AgentRunRequest): Promise { + const { requestId } = req; + const resolved = await this.resolveProvider(req.connectionId, "agent"); + if (!resolved) { + return { requestId, ok: false, message: "No AI model is connected. Add one in Settings ▸ AI." }; + } + const ctx = this.repos.getContext(); + if (!ctx) { + return { requestId, ok: false, message: "Open a repository first." }; + } + const host = createGitToolHost(ctx); + const tools = selectTools({ write: req.allowWrite, destructive: req.allowDestructive }); + const abort = new AbortController(); + this.aborts.set(requestId, abort); + + try { + const cfg = this.agentConfig(); + const result = await runAgent(req.goal, { + provider: resolved.provider, + host, + tools, + model: req.model ?? cfg.model, + modelId: req.modelId ?? cfg.modelId, + thinking: req.thinking ?? cfg.thinking, + signal: abort.signal, + onTextDelta: (delta) => this.send("ai:delta", { requestId, delta }), + onEvent: (e) => { + this.send("ai:agentEvent", { + requestId, + kind: e.type, + text: "text" in e ? e.text : undefined, + tool: "name" in e ? e.name : undefined, + args: e.type === "tool_call" ? e.args : undefined, + isError: e.type === "tool_result" ? e.isError : undefined, + callId: "id" in e ? e.id : undefined, + }); + }, + confirm: (tool, args) => this.awaitConfirm(requestId, tool, args), + }); + return { requestId, ok: result.stopped !== "error", text: result.text }; + } catch (err) { + return { requestId, ok: false, message: err instanceof Error ? err.message : String(err) }; + } finally { + this.aborts.delete(requestId); + } + } + + /** Ask the renderer to approve a write/destructive tool, resolving on its answer. */ + private awaitConfirm(requestId: string, tool: GitTool, args: Record): Promise { + const callId = `${requestId}:${randomUUID()}`; + this.send("ai:confirmRequest", { + requestId, + callId, + tool: tool.name, + title: tool.title, + summary: summarizeArgs(tool, args), + mode: tool.mode === "destructive" ? "destructive" : "write", + }); + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.confirms.delete(callId); + resolve(false); + }, CONFIRM_TIMEOUT_MS); + this.confirms.set(callId, (approved) => { + clearTimeout(timer); + resolve(approved); + }); + }); + } + + confirmAnswer(answer: AgentConfirmAnswer): void { + const resolve = this.confirms.get(answer.callId); + if (resolve) { + this.confirms.delete(answer.callId); + resolve(answer.approved); + } + } + + // ── MCP "Agent Access" ─────────────────────────────────────────────────────── + + mcpInfo(): McpInfo { + return mcpInfo(this.repos.current()?.root); + } + + mcpInstall(req: McpInstallRequest): { ok: boolean; message: string } { + return installMcp(this.repos.current()?.root, req); + } + + // ── Assistant chats (persisted sessions) ───────────────────────────────────── + + private static chatView(s: ChatSession): ChatView { + return { + id: s.id, + title: s.title, + connectionId: s.connectionId, + turns: s.turns.map((t) => ({ role: t.role, text: t.text })), + }; + } + + async chatList(): Promise { + const root = this.repos.current()?.root; + if (!root) return []; + return (await this.chats.list(root)).map((s) => ({ id: s.id, title: s.title, updatedAt: s.updatedAt })); + } + + async chatGet(id: string): Promise { + const s = await this.chats.get(id); + return s ? AiBridge.chatView(s) : undefined; + } + + async chatCurrent(): Promise { + const root = this.repos.current()?.root; + if (!root) return undefined; + const s = await this.chats.current(root); + return s ? AiBridge.chatView(s) : undefined; + } + + async chatNew(makeCurrent = true): Promise { + await this.ensureLoaded(); + const root = this.repos.current()?.root; + const conn = pickConnection(this.settings); + if (!root || !conn) return undefined; + const s = await this.chats.create(root, conn.id, randomUUID(), makeCurrent); + return AiBridge.chatView(s); + } + + async chatSetCurrent(id: string): Promise { + const root = this.repos.current()?.root; + if (root) await this.chats.setCurrent(root, id); + } + + async chatDelete(id: string): Promise { + await this.chats.delete(id); + } + + /** Send a message in a chat — warm CLI session for a CLI provider, multi-turn agent for HTTP. */ + async chatSend(req: ChatSendRequest): Promise { + const { chatId, requestId } = req; + const session = await this.chats.get(chatId); + if (!session) { + return { requestId, ok: false, message: "This chat no longer exists." }; + } + const resolved = await this.resolveProvider(session.connectionId, "agent"); + if (!resolved) { + return { requestId, ok: false, message: "No AI model is connected. Add one in Settings ▸ AI." }; + } + const ctx = this.repos.getContext(); + if (!ctx) { + return { requestId, ok: false, message: "Open a repository first." }; + } + const cfg = this.agentConfig(); + const abort = new AbortController(); + this.aborts.set(requestId, abort); + // History (prior turns) is captured BEFORE we append this user message. + const history = session.turns.map((t) => ({ role: t.role, content: t.text })); + await this.chats.appendTurn(chatId, { role: "user", text: req.goal, at: Date.now() }); + + try { + if (resolved.conn.wire === "cli") { + const model = req.modelId ?? resolveModelId(resolved.conn, cfg.model); + const warm = this.chats.warmFor(chatId, { cwd: ctx.root, model, resumeId: session.cliSessionId }); + // Cold start (the agent process needs booting) reads as "Loading", not + // a silent spinner; a warm session goes straight to "Thinking". + if (!warm.warm) { + this.send("ai:agentEvent", { requestId, kind: "status", text: "Loading the agent" }); + } + const prompt = withThinking(req.goal, req.thinking ?? cfg.thinking); + let acc = ""; + const text = await warm.send(prompt, { + onDelta: (d) => { + acc += d; + this.send("ai:delta", { requestId, delta: d }); + }, + signal: abort.signal, + }); + await this.chats.setCliSessionId(chatId, warm.id); + const final = (text || acc).trim(); + await this.chats.appendTurn(chatId, { role: "assistant", text: final, at: Date.now() }); + return { requestId, ok: true, text: final }; + } + + const host = createGitToolHost(ctx); + const tools = selectTools({ write: req.allowWrite, destructive: req.allowDestructive }); + const result = await runAgent(req.goal, { + provider: resolved.provider, + host, + tools, + model: cfg.model, + modelId: req.modelId ?? cfg.modelId, + thinking: req.thinking ?? cfg.thinking, + history, + signal: abort.signal, + onTextDelta: (delta) => this.send("ai:delta", { requestId, delta }), + onEvent: (e) => { + this.send("ai:agentEvent", { + requestId, + kind: e.type, + text: "text" in e ? e.text : undefined, + tool: "name" in e ? e.name : undefined, + args: e.type === "tool_call" ? e.args : undefined, + isError: e.type === "tool_result" ? e.isError : undefined, + callId: "id" in e ? e.id : undefined, + }); + }, + confirm: (tool, args) => this.awaitConfirm(requestId, tool, args), + }); + await this.chats.appendTurn(chatId, { role: "assistant", text: result.text, at: Date.now() }); + return { requestId, ok: result.stopped !== "error", text: result.text }; + } catch (err) { + return { requestId, ok: false, message: err instanceof Error ? err.message : String(err) }; + } finally { + this.aborts.delete(requestId); + } + } + + /** Kill warm CLI processes (called on app quit). */ + dispose(): void { + this.chats.disposeAll(); + } +} + +/** A short, human-readable summary of what a tool call will do, for the confirm UI. */ +function summarizeArgs(tool: GitTool, args: Record): string { + switch (tool.name) { + case "git_commit": + return `Commit staged changes:\n“${String(args.message ?? "").split("\n")[0]}”`; + case "git_stage": + return args.all ? "Stage all changes." : `Stage: ${asList(args.paths)}`; + case "git_unstage": + return args.all ? "Unstage everything." : `Unstage: ${asList(args.paths)}`; + case "git_create_branch": + return `Create branch “${String(args.name ?? "")}”${args.checkout ? " and switch to it" : ""}.`; + case "git_checkout": + return `Switch to “${String(args.ref ?? "")}”.`; + case "git_stash_save": + return `Stash working-tree changes${args.message ? ` (“${String(args.message)}”)` : ""}.`; + case "git_discard": + return `Permanently discard changes to: ${asList(args.paths)}`; + case "git_delete_branch": + return `Delete branch “${String(args.name ?? "")}”${args.force ? " (force)" : ""}.`; + case "git_reset": + return `Reset (${String(args.mode ?? "")}) to ${String(args.ref ?? "")}.`; + default: + return `${tool.title}: ${JSON.stringify(args)}`; + } +} + +function asList(v: unknown): string { + return Array.isArray(v) ? v.map(String).join(", ") : String(v ?? ""); +} diff --git a/apps/desktop/src/main/assistantSessions.ts b/apps/desktop/src/main/assistantSessions.ts new file mode 100644 index 0000000..b1250ec --- /dev/null +++ b/apps/desktop/src/main/assistantSessions.ts @@ -0,0 +1,195 @@ +// The durable home for Assistant chats. Because it lives in the MAIN process and +// persists to disk, a renderer refresh (or a full app restart) doesn't lose your +// conversation: the renderer just re-fetches the transcript, and — for a local +// CLI — reconnects to the still-warm process (or resumes the session by id). One +// chat == one session, regardless of provider. + +import { app } from "electron"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { WarmCliSession } from "./warmCliSession"; + +export interface ChatTurn { + role: "user" | "assistant"; + text: string; + at: number; +} + +export interface ChatSession { + id: string; + repoRoot: string; + connectionId: string; + title: string; + turns: ChatTurn[]; + /** Claude Code session id, so the conversation resumes after a full restart. */ + cliSessionId?: string; + createdAt: number; + updatedAt: number; +} + +/** Keep at most this many chats persisted (most-recent first). */ +const MAX_SESSIONS = 60; + +export class ConversationStore { + private sessions = new Map(); + /** chatId → the active warm CLI process (runtime only, never persisted). */ + private warm = new Map(); + /** repoRoot → the chat the user last had open there. */ + private currentByRepo = new Map(); + private loaded = false; + + private path(): string { + return join(app.getPath("userData"), "ai-sessions.json"); + } + + private async ensureLoaded(): Promise { + if (this.loaded) return; + this.loaded = true; + try { + const raw = JSON.parse(await readFile(this.path(), "utf8")) as { + sessions?: ChatSession[]; + currentByRepo?: Record; + }; + for (const s of raw.sessions ?? []) { + if (s && s.id) this.sessions.set(s.id, s); + } + for (const [repo, id] of Object.entries(raw.currentByRepo ?? {})) { + this.currentByRepo.set(repo, id); + } + } catch { + /* no sessions yet */ + } + } + + private async persist(): Promise { + try { + await mkdir(app.getPath("userData"), { recursive: true }); + // Newest first, capped — drop the oldest beyond the cap. + const sessions = [...this.sessions.values()] + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, MAX_SESSIONS); + const keep = new Set(sessions.map((s) => s.id)); + for (const id of [...this.sessions.keys()]) { + if (!keep.has(id)) this.sessions.delete(id); + } + await writeFile( + this.path(), + JSON.stringify({ sessions, currentByRepo: Object.fromEntries(this.currentByRepo) }, null, 2), + ); + } catch { + /* best-effort */ + } + } + + // ── reads ── + + async list(repoRoot: string): Promise { + await this.ensureLoaded(); + return [...this.sessions.values()] + .filter((s) => s.repoRoot === repoRoot) + .sort((a, b) => b.updatedAt - a.updatedAt); + } + + async get(id: string): Promise { + await this.ensureLoaded(); + return this.sessions.get(id); + } + + /** The chat the user last had open in this repo (most recent as a fallback). */ + async current(repoRoot: string): Promise { + await this.ensureLoaded(); + const id = this.currentByRepo.get(repoRoot); + const cur = id ? this.sessions.get(id) : undefined; + if (cur && cur.repoRoot === repoRoot) return cur; + return (await this.list(repoRoot))[0]; + } + + // ── writes ── + + async create(repoRoot: string, connectionId: string, id: string, makeCurrent = true): Promise { + await this.ensureLoaded(); + const now = Date.now(); + const s: ChatSession = { id, repoRoot, connectionId, title: "New chat", turns: [], createdAt: now, updatedAt: now }; + this.sessions.set(id, s); + // Footer AI tabs pass makeCurrent=false so they don't steal the full + // Assistant's "current chat" (the one it restores on open). + if (makeCurrent) this.currentByRepo.set(repoRoot, id); + await this.persist(); + return s; + } + + async setCurrent(repoRoot: string, id: string): Promise { + await this.ensureLoaded(); + this.currentByRepo.set(repoRoot, id); + await this.persist(); + } + + async appendTurn(id: string, turn: ChatTurn): Promise { + const s = this.sessions.get(id); + if (!s) return; + s.turns.push(turn); + s.updatedAt = turn.at; + if (s.title === "New chat" && turn.role === "user") { + s.title = turn.text.slice(0, 60).replace(/\s+/g, " ").trim() || "New chat"; + } + await this.persist(); + } + + async setCliSessionId(id: string, cliSessionId: string | undefined): Promise { + const s = this.sessions.get(id); + if (s && cliSessionId && s.cliSessionId !== cliSessionId) { + s.cliSessionId = cliSessionId; + await this.persist(); + } + } + + async delete(id: string): Promise { + await this.ensureLoaded(); + this.warm.get(id)?.session.dispose(); + this.warm.delete(id); + const s = this.sessions.get(id); + this.sessions.delete(id); + if (s) { + for (const [repo, cur] of this.currentByRepo) { + if (cur === id) this.currentByRepo.delete(repo); + } + } + await this.persist(); + } + + // ── warm CLI sessions ── + + /** + * The warm Claude Code process for a chat. Reused while alive; respawned (and + * resumed by session id) when idle/dead or when the model changes mid-chat. + */ + warmFor(id: string, opts: { cwd: string | undefined; model?: string; resumeId?: string }): WarmCliSession { + const existing = this.warm.get(id); + if (existing && existing.session.warm && existing.model === opts.model) { + return existing.session; + } + // Model changed (or process gone): drop the old one, carry context via resume. + if (existing) { + const carry = existing.session.id ?? opts.resumeId; + existing.session.dispose(); + opts = { ...opts, resumeId: carry }; + } + const session = new WarmCliSession({ + cwd: opts.cwd, + model: opts.model, + resumeId: opts.resumeId, + onExit: () => { + // Drop the reference once the process is gone (a later send respawns). + if (this.warm.get(id)?.session === session) this.warm.delete(id); + }, + }); + this.warm.set(id, { session, model: opts.model }); + return session; + } + + /** Kill every warm process (on app quit). */ + disposeAll(): void { + for (const { session } of this.warm.values()) session.dispose(); + this.warm.clear(); + } +} diff --git a/apps/desktop/src/main/autoUpdate.ts b/apps/desktop/src/main/autoUpdate.ts new file mode 100644 index 0000000..8ff9e01 --- /dev/null +++ b/apps/desktop/src/main/autoUpdate.ts @@ -0,0 +1,40 @@ +// Minimal auto-update stub. Wires electron-updater's GitHub provider when the +// app is packaged; in dev (or when no releases exist) it is a no-op rather than +// an error, so the app runs identically with or without a release feed. + +export interface AutoUpdateOptions { + isDev: boolean; +} + +export function initAutoUpdate(opts: AutoUpdateOptions): void { + if (opts.isDev) { + return; + } + if (process.platform === "darwin") { + // No macOS update channel yet: the release ships per-arch dmg/zip from two + // runners whose latest-mac.yml feeds would clobber each other, so the feed + // is deliberately not uploaded (see release-desktop.yml) — and unsigned + // builds couldn't apply a Squirrel.Mac update anyway. Skip the check + // instead of 404ing on every launch; mac users update via the website. + return; + } + // 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). With autoDownload=false and no update-available + // listener the checker was a no-op that never delivered an update. + autoUpdater.autoDownload = true; + autoUpdater.on("error", () => { + // Swallow: a repo with no published releases yields a 404 here, which + // is expected until the first `app-v*` tag ships installers. + }); + autoUpdater.checkForUpdates().catch(() => { + // No release feed yet — stay silent. + }); + }) + .catch(() => { + // electron-updater not installed in this build; updates are disabled. + }); +} diff --git a/apps/desktop/src/main/cliProvider.ts b/apps/desktop/src/main/cliProvider.ts new file mode 100644 index 0000000..f91f68f --- /dev/null +++ b/apps/desktop/src/main/cliProvider.ts @@ -0,0 +1,316 @@ +// A Provider that drives a locally-installed agent CLI — Claude Code (`claude`), +// Codex (`codex`), or the Gemini CLI (`gemini`) — in non-interactive mode, using +// the CLI's OWN login/subscription instead of an API key. This is how GitStudio +// "works with local claude code / codex" alongside BYO-key HTTP providers. +// +// It implements the same @gitstudio/ai `Provider` interface the HTTP providers +// do, so the tasks and the Assistant use it transparently. It can't live in the +// host-agnostic core (it spawns a process), so it lives here in the main process. +// Tool-calling isn't exposed over the CLI boundary (`supportsTools = false`); the +// CLI is its own agent, so for the Assistant it answers the goal directly, +// grounded in the repo it's run inside. + +import { spawn } from "node:child_process"; +import { AiError, type ChatMessage, type ChatOptions, type ChatResult, type ModelTier, type Provider } from "@gitstudio/ai/index"; + +/** How to invoke one CLI in non-interactive "print" mode. */ +interface CliSpec { + command: string; + /** Build argv (excluding the binary) for a one-shot prompt + optional model. */ + args(prompt: string, model?: string): string[]; + /** A friendly install hint surfaced when the binary is missing. */ + install: string; + /** + * Optional streaming mode: argv that makes the CLI emit newline-delimited JSON + * events, plus a parser that turns ONE such line into an incremental text + * delta (`text`) and/or the complete answer (`final`, used as a fallback when + * the CLI doesn't emit token deltas). When present, the provider streams the + * response live instead of waiting for the whole thing. + */ + streamArgs?(prompt: string, model?: string): string[]; + parseStream?(line: string): { text?: string; final?: string }; +} + +/** preset id → CLI spec. Keep model flags conservative + widely supported. */ +export const CLI_SPECS: Record = { + "claude-code": { + command: "claude", + // `--strict-mcp-config` (with no --mcp-config) skips loading the user's global + // MCP servers — the Assistant only needs Claude Code's built-in tools, and + // connecting to a dozen remote MCP servers on every call is a big, variable + // chunk of the cold-start latency. Claude Code's own Bash/Read/etc. stay. + args: (prompt, model) => ["-p", "--strict-mcp-config", ...(model ? ["--model", model] : []), prompt], + install: "Install Claude Code and run `claude login` (docs.anthropic.com/claude-code).", + // Claude Code's stream-json + partial messages emits token-level text deltas. + streamArgs: (prompt, model) => [ + "-p", + "--strict-mcp-config", + ...(model ? ["--model", model] : []), + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + prompt, + ], + parseStream: (line) => { + let o: ClaudeStreamLine; + try { + o = JSON.parse(line) as ClaudeStreamLine; + } catch { + return {}; + } + // Token-level deltas (the responsive path). + if (o.type === "stream_event" && o.event?.type === "content_block_delta") { + const d = o.event.delta; + if (d?.type === "text_delta" && typeof d.text === "string") { + return { text: d.text }; + } + return {}; + } + // Fallbacks (used only if no token deltas arrive): the final result, or a + // completed assistant message's text. + if (o.type === "result" && typeof o.result === "string") { + return { final: o.result }; + } + if (o.type === "assistant" && Array.isArray(o.message?.content)) { + const t = o.message!.content + .filter((b) => b.type === "text" && typeof b.text === "string") + .map((b) => b.text as string) + .join(""); + if (t) return { final: t }; + } + return {}; + }, + }, + codex: { + command: "codex", + args: (prompt, model) => ["exec", ...(model ? ["--model", model] : []), prompt], + install: "Install the Codex CLI and sign in (github.com/openai/codex).", + }, + "gemini-cli": { + command: "gemini", + args: (prompt, model) => ["-p", ...(model ? ["--model", model] : []), prompt], + install: "Install the Gemini CLI and sign in (github.com/google-gemini/gemini-cli).", + }, +}; + +interface ClaudeStreamLine { + type?: string; + result?: string; + event?: { type?: string; delta?: { type?: string; text?: string } }; + message?: { content?: Array<{ type?: string; text?: string }> }; +} + +export function cliSpecFor(preset: string): CliSpec | undefined { + return CLI_SPECS[preset]; +} + +/** Strip ANSI color/escape sequences a CLI might emit even in print mode. */ +// eslint-disable-next-line no-control-regex +const ANSI = /\[[0-9;]*[A-Za-z]/g; + +export interface CliProviderOptions { + preset: string; + /** Working directory — the open repo, so the CLI grounds itself correctly. */ + cwd: string | undefined; + /** Resolve a tier to a concrete model name (or undefined to use the CLI default). */ + resolveModel: (tier: ModelTier | undefined) => string | undefined; + label: string; +} + +export class CliProvider implements Provider { + readonly id = "cli"; + readonly supportsTools = false; + + constructor(private readonly opts: CliProviderOptions) {} + + get label(): string { + return this.opts.label; + } + + async chat(messages: ChatMessage[], opts: ChatOptions = {}): Promise { + let text = ""; + await this.run(messages, opts, (chunk) => { + text += chunk; + }); + return { text: text.trim(), toolCalls: [], stopReason: "stop" }; + } + + async streamText( + messages: ChatMessage[], + onDelta: (text: string) => void, + opts: ChatOptions = {}, + ): Promise { + let text = ""; + await this.run(messages, opts, (chunk) => { + text += chunk; + onDelta(chunk); + }); + const trimmed = text.trim(); + return trimmed.length > 0 ? trimmed : null; + } + + /** Spawn the CLI, stream stdout through `onChunk`, resolve on clean exit. */ + private run( + messages: ChatMessage[], + opts: ChatOptions, + onChunk: (text: string) => void, + ): Promise { + const spec = CLI_SPECS[this.opts.preset]; + if (!spec) { + return Promise.reject(new AiError(`Unknown local CLI: ${this.opts.preset}.`)); + } + const prompt = withThinking(flatten(messages), opts.thinking); + const model = (opts.modelId ?? this.opts.resolveModel(opts.model)); + // Stream token-by-token when the CLI supports a JSON event stream; otherwise + // fall back to forwarding raw stdout (which most CLIs buffer to the end). + const streaming = !!(spec.streamArgs && spec.parseStream); + const argv = streaming ? spec.streamArgs!(prompt, model) : spec.args(prompt, model); + + return new Promise((resolve, reject) => { + let child; + try { + child = spawn(spec.command, argv, { + cwd: this.opts.cwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + reject(new AiError(`Couldn't launch \`${spec.command}\`. ${spec.install}`)); + return; + } + + let stderr = ""; + const onAbort = () => child.kill("SIGTERM"); + if (opts.signal) { + if (opts.signal.aborted) { + child.kill("SIGTERM"); + } else { + opts.signal.addEventListener("abort", onAbort, { once: true }); + } + } + + child.stdout.setEncoding("utf8"); + if (streaming) { + // Parse newline-delimited JSON events: emit text deltas live; remember a + // `final` answer as a fallback for when no token deltas were emitted. + let buffer = ""; + let streamedAny = false; + let final = ""; + child.stdout.on("data", (d: string) => { + buffer += d; + let nl: number; + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line) continue; + const { text, final: f } = spec.parseStream!(line); + if (text) { + streamedAny = true; + onChunk(text); + } + if (f) final = f; + } + }); + child.on("close", (code: number | null) => { + opts.signal?.removeEventListener("abort", onAbort); + if (opts.signal?.aborted) return resolve(); + if (!streamedAny && final) onChunk(final.replace(ANSI, "")); + if (code === 0 || streamedAny || final) return resolve(); + const detail = stderr.trim().split("\n").slice(-3).join(" ").slice(0, 300); + reject(new AiError(`\`${spec.command}\` exited with code ${code}${detail ? `: ${detail}` : "."}`)); + }); + } else { + child.stdout.on("data", (d: string) => onChunk(d.replace(ANSI, ""))); + child.on("close", (code: number | null) => { + opts.signal?.removeEventListener("abort", onAbort); + if (opts.signal?.aborted) return resolve(); + if (code === 0) return resolve(); + const detail = stderr.trim().split("\n").slice(-3).join(" ").slice(0, 300); + reject(new AiError(`\`${spec.command}\` exited with code ${code}${detail ? `: ${detail}` : "."}`)); + }); + } + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (d: string) => (stderr += d)); + + child.on("error", (err: NodeJS.ErrnoException) => { + opts.signal?.removeEventListener("abort", onAbort); + if (err.code === "ENOENT") { + reject(new AiError(`The \`${spec.command}\` CLI isn't installed or not on PATH. ${spec.install}`)); + } else { + reject(new AiError(`\`${spec.command}\` failed to start: ${err.message}`)); + } + }); + }); + } +} + +/** + * Nudge the CLI's reasoning depth via the prompt. Claude Code (and most agent + * CLIs) take their thinking budget from the request, so a short directive is the + * portable lever: "extended" asks it to think hard; "off" asks for a direct, + * concise reply; "auto" leaves its default behavior alone. + */ +export function withThinking(prompt: string, thinking?: "off" | "auto" | "extended"): string { + if (thinking === "extended") { + return `${prompt}\n\nThink hard and reason carefully before you answer.`; + } + if (thinking === "off") { + return `${prompt}\n\nAnswer directly and concisely, without extended reasoning.`; + } + return prompt; +} + +/** Flatten the chat messages into a single prompt for a non-interactive CLI. */ +function flatten(messages: ChatMessage[]): string { + const system = messages + .filter((m) => m.role === "system") + .map((m) => m.content) + .join("\n\n") + .trim(); + const convo = messages + .filter((m) => m.role === "user" || m.role === "assistant") + .map((m) => (m.role === "assistant" ? `Assistant: ${m.content}` : m.content)) + .join("\n\n") + .trim(); + return system ? `${system}\n\n${convo}` : convo; +} + +/** + * Detect whether a CLI is installed (and grab its version), by running + * ` --version` with a short timeout. Cached availability lets the + * settings UI show "Ready" vs "Not installed" without a network call. + */ +export function detectCli(command: string): Promise<{ ok: boolean; version?: string }> { + return new Promise((resolve) => { + let out = ""; + let done = false; + const finish = (r: { ok: boolean; version?: string }) => { + if (!done) { + done = true; + resolve(r); + } + }; + let child; + try { + child = spawn(command, ["--version"], { stdio: ["ignore", "pipe", "ignore"] }); + } catch { + finish({ ok: false }); + return; + } + const timer = setTimeout(() => { + child.kill("SIGKILL"); + finish({ ok: false }); + }, 4000); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (d: string) => (out += d)); + child.on("error", () => { + clearTimeout(timer); + finish({ ok: false }); + }); + child.on("close", (code: number | null) => { + clearTimeout(timer); + finish({ ok: code === 0, version: out.trim().split("\n")[0] || undefined }); + }); + }); +} diff --git a/apps/desktop/src/main/cloneBridge.ts b/apps/desktop/src/main/cloneBridge.ts new file mode 100644 index 0000000..af5088c --- /dev/null +++ b/apps/desktop/src/main/cloneBridge.ts @@ -0,0 +1,202 @@ +// Clone / browse-repos backend. +// +// CONTRACT (keep these signatures — main.ts depends on them): +// • pickCloneDir(): native folder picker for the clone *parent* directory. +// • startClone(req, onProgress): runs `git clone --progress`, parses the +// progress lines to CloneProgress (forwarded via onProgress → clone:progress +// event), and resolves with the absolute repo path on success. +// • listGhRepos(client, search?): the signed-in user's clonable repos via the +// GitHub REST API (GET /user/repos, owner+collaborator+org, sorted by recency), +// optionally filtered by `search`. + +import { dialog } from "electron"; +import { spawn } from "node:child_process"; +import { join } from "node:path"; +import type { CloneProgress, CloneRequest, CloneResult, GhRepoBrief } from "../shared/ipc"; +import type { GitHubClient } from "./githubClient"; +import { ALLOWED_PROTOCOLS, validateCloneUrl } from "./cloneUrl"; + +/** In-flight `git clone` children, so they can be killed on app/window teardown + * rather than orphaned (a long clone would otherwise keep running after quit). */ +const activeClones = new Set>(); + +/** Terminate any running clone — called when the window closes / app quits. */ +export function killActiveClones(): void { + for (const child of activeClones) { + child.kill("SIGTERM"); + } + activeClones.clear(); +} + +/** Native "choose a folder" dialog; returns the absolute path or undefined. */ +export async function pickCloneDir(): Promise { + const r = await dialog.showOpenDialog({ + properties: ["openDirectory", "createDirectory"], + title: "Choose a folder to clone into", + }); + 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 { + const explicit = req.name?.trim(); + if (explicit) return explicit; + // Strip a trailing slash, then a trailing ".git", and take the last path segment. + const trimmed = req.url.trim().replace(/\/+$/, ""); + const seg = trimmed.split(/[\\/]/).pop() ?? ""; + return seg.replace(/\.git$/i, ""); +} + + +/** A progress line looks like "Receiving objects: 42% (…)" — sometimes "remote: " prefixed. */ +const PERCENT_RE = /^(?:remote: )?([A-Za-z ]+):\s+(\d+)%/; + +/** Clone `req.url` into `req.parentDir/`, streaming progress. */ +export async function startClone( + req: CloneRequest, + onProgress: (p: CloneProgress) => void, +): Promise { + const url = req.url?.trim(); + if (!url) { + return { ok: false, message: "No repository URL was provided." }; + } + const urlError = validateCloneUrl(url); + if (urlError) { + return { ok: false, message: urlError }; + } + if (!req.parentDir) { + return { ok: false, message: "No destination folder was chosen." }; + } + const name = targetName(req); + if (!name) { + return { ok: false, message: "Couldn't derive a folder name from the URL." }; + } + // 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 new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn( + "git", + ["-c", "protocol.ext.allow=never", "-c", "protocol.fd.allow=never", "clone", "--progress", "--", url, name], + { + cwd: req.parentDir, + // Never block the clone waiting on an interactive credential prompt + // (it would hang the UI), and constrain the transports git may use. + env: { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + GIT_ALLOW_PROTOCOL: ALLOWED_PROTOCOLS, + }, + }, + ); + } catch (err) { + resolve({ ok: false, message: messageOf(err) }); + return; + } + + activeClones.add(child); + let lastStderr = ""; + let settled = false; + const finish = (r: CloneResult) => { + if (settled) return; + settled = true; + activeClones.delete(child); + resolve(r); + }; + + // git writes its progress to stderr; the carriage-return updates arrive as a + // single growing line, so split on both \n and \r and keep the dangling tail. + let buf = ""; + const emitLine = (line: string) => { + const text = line.trim(); + if (!text) return; + lastStderr = text; + const m = PERCENT_RE.exec(text); + if (m) { + onProgress({ phase: m[1].trim(), percent: Number(m[2]), raw: text }); + } else if (!/\(\d+\/\d+\)/.test(text)) { + // Forward informative, non-counter lines sparingly (e.g. "Cloning into …"). + onProgress({ phase: text, raw: text }); + } + }; + + child.stderr?.on("data", (chunk: Buffer) => { + buf += chunk.toString(); + const parts = buf.split(/\r\n|\r|\n/); + buf = parts.pop() ?? ""; + for (const p of parts) emitLine(p); + }); + + child.on("error", (err) => { + finish({ ok: false, message: messageOf(err) }); + }); + + child.on("close", (code) => { + if (buf.trim()) emitLine(buf); + if (code === 0) { + finish({ ok: true, root: join(req.parentDir, name) }); + } else { + finish({ ok: false, message: lastStderr || `git clone exited ${code}` }); + } + }); + }); +} + +function messageOf(err: unknown): string { + const m = err instanceof Error ? err.message : String(err); + return m.includes("ENOENT") ? "git was not found on your PATH." : m; +} + +interface RawGhRepo { + full_name?: string; + name?: string; + owner?: { login?: string }; + description?: string | null; + private?: boolean; + fork?: boolean; + clone_url?: string; + ssh_url?: string; + default_branch?: string; + stargazers_count?: number; + language?: string | null; + pushed_at?: string; + updated_at?: string; +} + +/** List the signed-in user's clonable GitHub repositories. */ +export async function listGhRepos(client: GitHubClient, search?: string): Promise { + const repos = await client.request( + "GET", + "/user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member", + ); + let out: GhRepoBrief[] = (repos ?? []).map((r) => ({ + fullName: r.full_name ?? "", + name: r.name ?? "", + owner: r.owner?.login ?? "", + description: r.description ?? null, + private: !!r.private, + fork: !!r.fork, + cloneUrl: r.clone_url ?? "", + sshUrl: r.ssh_url ?? "", + defaultBranch: r.default_branch ?? "main", + stars: r.stargazers_count ?? 0, + language: r.language ?? null, + updatedAt: r.pushed_at ?? r.updated_at ?? "", + })); + + const q = search?.trim().toLowerCase(); + if (q) { + out = out.filter( + (r) => + r.fullName.toLowerCase().includes(q) || + (r.description ?? "").toLowerCase().includes(q), + ); + } + + out.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0)); + return out; +} diff --git a/apps/desktop/src/main/cloneUrl.ts b/apps/desktop/src/main/cloneUrl.ts new file mode 100644 index 0000000..bbc746c --- /dev/null +++ b/apps/desktop/src/main/cloneUrl.ts @@ -0,0 +1,45 @@ +// Pure, dependency-free validation for user-supplied `git clone` URLs. Kept +// separate from cloneBridge (which imports electron) so it can be unit-tested. + +/** Protocols git is permitted to use for a clone (passed via GIT_ALLOW_PROTOCOL). + * This hard-blocks the `ext`/`fd` remote-helper transports, which can run + * arbitrary shell commands embedded in a URL (CVE-2017-1000117 class). */ +export const ALLOWED_PROTOCOLS = "https:http:git:ssh:file"; + +/** + * Validate a user-supplied clone URL. Returns an error message string when the + * URL is rejected, or null when it is safe to pass to `git clone`. + * + * The two real attacks this closes: + * 1. Remote-helper transports — `ext::sh -c ""` and `::` + * make git execute commands. We reject any `scheme::` form outright (and + * GIT_ALLOW_PROTOCOL is set as defense-in-depth at spawn time). + * 2. Option injection — a URL beginning with `-` is parsed by git as a flag + * (e.g. `--upload-pack=…`) rather than a positional. We reject leading `-`. + */ +export function validateCloneUrl(raw: string): string | null { + const url = raw.trim(); + if (!url) return "No repository URL was provided."; + if (url.startsWith("-")) { + return "That doesn't look like a valid repository URL."; + } + // `scheme::address` is git's remote-helper syntax (ext::, fd::, …) — never allow it. + if (/^[a-z][a-z0-9+.-]*::/i.test(url)) { + return "That URL uses an unsupported transport."; + } + // Explicit `scheme://` URLs must use an allowed protocol. + const m = /^([a-z][a-z0-9+.-]*):\/\//i.exec(url); + if (m) { + if (!ALLOWED_PROTOCOLS.split(":").includes(m[1].toLowerCase())) { + return "That URL uses an unsupported scheme."; + } + return null; + } + // SCP-like ([user@]host:path — the user is optional in git's syntax) and bare + // absolute local paths are fine. (ext::/scheme:: and leading "-" are already + // rejected above, so this can't re-admit them.) + if (/^([^\s/]+@)?[^\s/:]+:.+/.test(url) || url.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(url)) { + return null; + } + return "That doesn't look like a valid repository URL."; +} diff --git a/apps/desktop/src/main/gitBridge.ts b/apps/desktop/src/main/gitBridge.ts new file mode 100644 index 0000000..a2c2862 --- /dev/null +++ b/apps/desktop/src/main/gitBridge.ts @@ -0,0 +1,1394 @@ +// The DesktopHostBridge: the main-process implementation of the IPC contract. +// Every handler wraps the SAME @gitstudio/git-service providers + @gitstudio/ +// engine the VS Code extension uses, so the desktop app is a reuse of the proven +// core, not a rewrite. The graph handler in particular streams commits → +// computeGraphLayout → buildWireRows, the exact transformation the extension's +// graphPanel performs (now factored into @gitstudio/host-bridge/graphWire and +// shared by both hosts). + +import { readFile, readdir, writeFile, stat } from "node:fs/promises"; +import { join, resolve, sep } from "node:path"; +import { homedir } from "node:os"; +import { computeGraphLayout } from "@gitstudio/engine/graph/layout"; +import type { GraphInputCommit } from "@gitstudio/engine/graph/layout"; +import { computeHunks, applySelectedChanges } from "@gitstudio/engine/staging/applyLineChanges"; +import type { LineRange } from "@gitstudio/engine/staging/applyLineChanges"; +import { buildWireRows } from "@gitstudio/host-bridge/graphWire"; +import type { + CommitRecord, + GitContext, + GitRef, +} from "@gitstudio/git-service/index"; +import type { + BranchInfo, + ChangedFile, + CommitActionRequest, + CommitActionResult, + CommitDetailsPayload, + CompareCommit, + CompareMode, + CompareResult, + ConflictModel, + FileDiff, + GitIdentity, + GitOpState, + GraphPage, + HeadCommit, + HeadInfo, + RefInfo, + RepoFile, + RowStat, + SshKey, + StashInfo, + SyncStatus, + TreeEntry, + WorktreeInfo, +} from "../shared/ipc"; +import type { WireRef } from "@gitstudio/host-bridge/graphProtocol"; +import type { CommitFileChange } from "@gitstudio/host-bridge/git"; +import type { RepoStore } from "./repoStore"; + +/** Commits per graph page — matches the extension's PAGE_SIZE. */ +const PAGE_SIZE = 500; + +/** Max blob size the read-only file viewer / README will load (512 KiB). */ +const FILE_CAP_BYTES = 512 * 1024; + +/** + * True when a renderer-supplied ref / branch name / SHA can't be mistaken by + * git for a command-line option (it doesn't begin with "-"). Without this a + * value like `--upload-pack=…` reaches git as a flag rather than a positional + * (option injection). Git itself forbids ref names that start with "-", so this + * never rejects a legitimate value. + */ +export function safeArg(v: unknown): v is string { + return typeof v === "string" && v.length > 0 && !v.startsWith("-"); +} + +/** Standard rejection for an unsafe ref/name reaching a mutation. */ +const UNSAFE_REF_RESULT: CommitActionResult = { + ok: false, + changed: false, + message: "That value isn't a valid git reference.", +}; + +/** + * Resolves a renderer-supplied repo-relative path and REFUSES anything that + * escapes the repository root ("../../…" or an absolute path). safeArg alone + * only blocks option injection — without this containment check a hostile + * renderer payload like `../../.zshenv` turns writeFile/readFile into an + * arbitrary file write/read primitive outside the repo. + */ +function containedPath(root: string, rel: string): string | undefined { + const abs = resolve(root, rel); + const base = resolve(root); + if (abs === base || abs.startsWith(base + sep)) { + return abs; + } + return undefined; +} + +export class GitBridge { + /** sha → record, accumulated as the graph pages stream in (for details). */ + private records = new Map(); + /** Every loaded input commit, so a page append relayouts the full DAG. */ + private loaded: GraphInputCommit[] = []; + private refsBySha = new Map(); + private currentHeadSha = ""; + private loadedRoot: string | undefined; + + constructor(private readonly repos: RepoStore) {} + + private ctx(): GitContext | undefined { + return this.repos.getContext(); + } + + // ── Graph ──────────────────────────────────────────────────────────────── + + /** + * Streams a page of `git log --all`, lays it out with the engine, decorates + * the rows with ref chips, and returns the wire rows. On the first page + * (skip 0) it resets the accumulated state and reloads the refs; later pages + * relayout the full loaded DAG so cross-page lanes stay continuous — exactly + * the extension's loadInitial / loadMore behavior, server-side. + */ + async graphLoad(opts: { skip?: number; maxCount?: number }): Promise { + const ctx = this.ctx(); + if (!ctx) { + return { rows: [], head: "", totalColumns: 1, hasMore: false, nextSkip: 0 }; + } + + const maxCount = opts.maxCount ?? PAGE_SIZE; + const skip = opts.skip ?? 0; + const fresh = skip === 0 || ctx.root !== this.loadedRoot; + + if (fresh) { + this.records.clear(); + this.loaded = []; + this.loadedRoot = ctx.root; + await this.loadRefs(ctx); + } + + const page = await this.readPage(ctx, fresh ? 0 : skip, maxCount); + const before = fresh ? 0 : this.loaded.length; + this.loaded = fresh ? page : this.loaded.concat(page); + const hasMore = page.length === maxCount; + + const layout = computeGraphLayout(this.loaded, { colorCount: 8 }); + const allRows = buildWireRows({ + rows: layout.rows, + records: this.records, + refsBySha: this.refsBySha, + }); + + return { + rows: allRows.slice(before), + head: this.currentHeadSha, + totalColumns: layout.totalColumns, + hasMore, + nextSkip: this.loaded.length, + }; + } + + private async readPage( + ctx: GitContext, + skip: number, + maxCount: number, + ): Promise { + const page: GraphInputCommit[] = []; + for await (const commit of ctx.log.streamCommits({ + revRange: "--all", + maxCount, + skip, + })) { + this.records.set(commit.sha, commit); + page.push({ sha: commit.sha, parents: commit.parents }); + } + return page; + } + + private async loadRefs(ctx: GitContext): Promise { + this.refsBySha.clear(); + this.currentHeadSha = ""; + let refs: GitRef[] = []; + try { + refs = await ctx.refs.listRefs(); + } catch { + refs = []; + } + for (const ref of refs) { + if (ref.type === "stash") { + continue; + } + const list = this.refsBySha.get(ref.sha); + if (list) { + list.push(ref); + } else { + this.refsBySha.set(ref.sha, [ref]); + } + if (ref.type === "head" && ref.isCurrent) { + this.currentHeadSha = ref.sha; + } + } + } + + // ── Refs / HEAD ──────────────────────────────────────────────────────────── + + async refsList(): Promise { + const ctx = this.ctx(); + if (!ctx) { + return []; + } + try { + const refs = await ctx.refs.listRefs(); + return refs.map((r) => ({ + type: r.type, + name: r.name, + fullName: r.fullName, + sha: r.sha, + isCurrent: r.isCurrent, + upstream: r.upstream, + })); + } catch { + return []; + } + } + + async head(): Promise { + const ctx = this.ctx(); + if (!ctx) { + return undefined; + } + try { + const h = await ctx.refs.getHead(); + return h.detached + ? { detached: true, sha: h.sha } + : { detached: false, branch: h.branch, sha: h.sha }; + } catch { + return undefined; + } + } + + // ── Commit details ───────────────────────────────────────────────────────── + + async commitDetails(sha: string): Promise { + const ctx = this.ctx(); + if (!ctx) { + return undefined; + } + let record = this.records.get(sha); + if (!record) { + for await (const c of ctx.log.streamCommits({ revRange: sha, maxCount: 1 })) { + record = c; + break; + } + } + if (!record) { + return undefined; + } + let files: CommitFileChange[]; + try { + files = await ctx.commitDetails.getCommitFiles(sha, record.parents[0]); + } catch { + files = []; + } + const refs: WireRef[] = (this.refsBySha.get(sha) ?? []) + .filter((r) => r.type !== "stash") + .map((r): WireRef => { + if (r.type === "tag") return { kind: "tag", name: r.name }; + if (r.type === "remote") return { kind: "remoteHead", name: r.name }; + return r.isCurrent + ? { kind: "currentHead", name: r.name } + : { kind: "head", name: r.name }; + }); + const hasRemote = [...this.refsBySha.values()].some((list) => + list.some((r) => r.type === "remote"), + ); + return { + kind: "commit", + sha: record.sha, + shortSha: record.sha.slice(0, 7), + parents: record.parents, + author: record.author, + authorEmail: record.authorEmail, + authorDate: record.authorDate, + committer: record.committer, + committerEmail: record.committerEmail, + committerDate: record.committerDate, + subject: record.subject, + body: record.body, + refs, + files, + hasRemote, + }; + } + + /** CHANGES-column stats (file count + add/del) for the given (visible) shas. */ + async rowStats(shas: string[]): Promise { + const ctx = this.ctx(); + if (!ctx) { + return []; + } + const out: RowStat[] = []; + await Promise.all( + shas.slice(0, 60).map(async (sha) => { + let record = this.records.get(sha); + if (!record) { + for await (const c of ctx.log.streamCommits({ + revRange: sha, + maxCount: 1, + })) { + record = c; + break; + } + } + if (!record) { + return; + } + try { + const files = await ctx.commitDetails.getCommitFiles( + sha, + record.parents[0], + ); + let add = 0, + del = 0; + for (const f of files) { + if (f.additions > 0) add += f.additions; + if (f.deletions > 0) del += f.deletions; + } + out.push({ sha, files: files.length, additions: add, deletions: del }); + } catch { + out.push({ sha, files: 0, additions: 0, deletions: 0 }); + } + }), + ); + return out; + } + + /** Changed files for a commit via `git show --name-status` (or root-diff). */ + private async commitFiles( + ctx: GitContext, + record: CommitRecord, + ): Promise { + const range = + record.parents.length > 0 ? `${record.parents[0]}..${record.sha}` : record.sha; + const args = + record.parents.length > 0 + ? ["diff", "--name-status", "-M", range] + : ["show", "--name-status", "-M", "--format=", record.sha]; + const result = await ctx.process.run(args); + return parseNameStatus(result.stdout); + } + + // ── Working-tree status & diff ───────────────────────────────────────────── + + async status(): Promise { + const ctx = this.ctx(); + if (!ctx) { + return []; + } + try { + const result = await ctx.process.run(["status", "--porcelain=v1", "-z"]); + return parsePorcelainStatus(result.stdout); + } catch { + // A held index.lock, a repo deleted under us, a corrupt index — return an + // empty working tree rather than rejecting into the renderer (which would + // leave the Changes view stuck on its skeleton). + return []; + } + } + + async diffFiles(): Promise { + return this.status(); + } + + /** + * The two sides of a file diff. For a working-tree file, left = HEAD/index, + * right = the working text; for a commit, left = parent, right = the commit's + * version. Reuses StagingProvider.headContent / ConflictProvider.getHeadVersion + * — the same content readers the extension's diff panel uses. + */ + async fileDiff(req: { path: string; sha?: string }): Promise { + const ctx = this.ctx(); + if (!ctx) { + return undefined; + } + const rel = req.path; + + if (req.sha) { + const right = await showAt(ctx, req.sha, rel); + const parent = await parentOf(ctx, req.sha); + const left = parent ? await showAt(ctx, parent, rel) : ""; + return { + path: rel, + leftLabel: parent ? `${parent.slice(0, 7)} ${rel}` : `(new) ${rel}`, + rightLabel: `${req.sha.slice(0, 7)} ${rel}`, + leftText: left, + rightText: right, + conflicted: false, + }; + } + + // Working-tree diff: is it conflicted? + const conflicted = await ctx.conflict.isConflicted(rel).catch(() => false); + const headText = await ctx.staging.headContent(rel).catch(() => ""); + const workingText = await readWorking(ctx, rel); + return { + path: rel, + leftLabel: `HEAD ${rel}`, + rightLabel: `Working Tree ${rel}`, + leftText: headText, + rightText: workingText, + conflicted, + }; + } + + /** The three sides of a conflicted file for the shared 3-pane MergeView. */ + async conflictModel(path: string): Promise { + const ctx = this.ctx(); + if (!ctx) { + return undefined; + } + const workingText = await readWorking(ctx, path); + const versions = await ctx.conflict.getConflictVersions(path, { workingText }); + return { + path, + hasBase: versions.hasBase, + base: versions.base, + ours: versions.ours, + theirs: versions.theirs, + result: workingText, + oursLabel: "Current Change (ours)", + theirsLabel: "Incoming Change (theirs)", + }; + } + + // ── Blame ────────────────────────────────────────────────────────────────── + + async blameFile(path: string): Promise { + const ctx = this.ctx(); + if (!ctx) { + return undefined; + } + try { + return await ctx.blame.blameFile(path); + } catch { + return undefined; + } + } + + // ── Working-tree staging + commit (Changes view) ──────────────────────────── + + async stage(path: string): Promise { + return this.staged(async (ctx) => ctx.staging.stageFile(path)); + } + async unstage(path: string): Promise { + return this.staged(async (ctx) => ctx.staging.unstageFile(path)); + } + async discard(path: string): Promise { + return this.staged(async (ctx) => { + // `git checkout --` only restores TRACKED paths; an untracked file must + // be removed via `git clean` instead (StagingProvider's own contract) — + // otherwise Discard on a new file always fails with "pathspec did not + // match any file(s) known to git". + const st = await ctx.process.run(["status", "--porcelain=v1", "-z", "--", path]); + const untracked = st.code === 0 && st.stdout.startsWith("??"); + return untracked + ? ctx.staging.cleanFiles([path]) + : ctx.staging.discardChanges(path); + }); + } + async stageAll(): Promise { + return this.staged(async (ctx) => ctx.process.run(["add", "-A"])); + } + async unstageAll(): Promise { + return this.staged(async (ctx) => ctx.process.run(["reset"])); + } + async commit(req: { message: string; amend?: boolean }): Promise { + const ctx = this.ctx(); + if (!ctx) { + return { ok: false, changed: false, message: "No repository open." }; + } + if (!req.message.trim() && !req.amend) { + return { ok: false, changed: false, message: "A commit message is required." }; + } + return this.serialize(async () => { + const r = await ctx.staging.commit(req.message, { amend: req.amend }); + return { ok: r.ok, changed: r.ok, message: r.ok ? undefined : r.stderr }; + }); + } + + // ── Stashes ───────────────────────────────────────────────────────────────── + + async stashList(): Promise { + const ctx = this.ctx(); + if (!ctx) { + return []; + } + try { + return (await ctx.stashes.list()).map((s) => ({ + sha: s.sha, + ref: s.ref, + message: s.message, + time: s.time, + })); + } catch { + return []; + } + } + async stashApply(ref: string): Promise { + if (!safeArg(ref)) return UNSAFE_REF_RESULT; + return this.staged(async (ctx) => ctx.stashes.apply(ref)); + } + async stashPop(ref: string): Promise { + if (!safeArg(ref)) return UNSAFE_REF_RESULT; + return this.staged(async (ctx) => ctx.stashes.pop(ref)); + } + async stashDrop(ref: string): Promise { + if (!safeArg(ref)) return UNSAFE_REF_RESULT; + return this.staged(async (ctx) => ctx.stashes.drop(ref)); + } + async stashSave(opts: { message?: string; includeUntracked?: boolean }): Promise { + return this.staged(async (ctx) => + ctx.stashes.save({ message: opts.message, includeUntracked: opts.includeUntracked }), + ); + } + + // ── Worktrees ───────────────────────────────────────────────────────────────── + + async worktreeList(): Promise { + const ctx = this.ctx(); + if (!ctx) { + return []; + } + try { + return (await ctx.worktrees.list()).map((w) => ({ + path: w.path, + head: w.head, + branch: w.branch, + bare: w.bare, + locked: w.locked, + prunable: w.prunable, + current: w.path === ctx.root, + })); + } catch { + return []; + } + } + async worktreeAdd(path: string, ref: string, newBranch?: boolean): Promise { + if (!safeArg(ref)) return UNSAFE_REF_RESULT; + return this.staged(async (ctx) => ctx.worktrees.add(path, ref, { newBranch })); + } + async worktreeRemove(opts: { path: string; force?: boolean }): Promise { + return this.staged(async (ctx) => ctx.worktrees.remove(opts.path, { force: opts.force })); + } + + // ── Compare (base…head) ─────────────────────────────────────────────────────── + + async compareRefs(req: { + base: string; + head: string; + mode?: CompareMode; + }): Promise { + const ctx = this.ctx(); + if (!ctx) { + return undefined; + } + const { base, head } = req; + if (!safeArg(base) || !safeArg(head)) { + return undefined; + } + const threeDot = req.mode !== "two-dot"; // default: GitHub-style 3-dot + const commits: CompareCommit[] = []; + try { + for await (const c of ctx.log.streamCommits({ revRange: `${base}..${head}`, maxCount: 400 })) { + commits.push({ + sha: c.sha, + shortSha: c.sha.slice(0, 7), + subject: c.subject, + author: c.author, + date: c.authorDate, + }); + } + } catch { + // leave commits empty + } + let files: ChangedFile[] = []; + try { + // 3-dot (base...head) = "what head introduced since the merge-base"; + // 2-dot (base head) = the literal difference between the two tips. + const range = threeDot ? [`${base}...${head}`] : [base, head]; + const r = await ctx.process.run(["diff", "--name-status", "-M", ...range]); + files = parseNameStatus(r.stdout); + } catch { + files = []; + } + const behind = await this.revCount(ctx, `${head}..${base}`); + return { commits, files, ahead: commits.length, behind }; + } + + async compareFileDiff(req: { + base: string; + head: string; + path: string; + mode?: CompareMode; + }): Promise { + const ctx = this.ctx(); + if (!ctx) { + return undefined; + } + if (!safeArg(req.base) || !safeArg(req.head)) { + return undefined; + } + const threeDot = req.mode !== "two-dot"; + // 3-dot diffs the merge-base of (base, head) against head. + let leftRef = req.base; + if (threeDot) { + try { + const mb = await ctx.process.run(["merge-base", req.base, req.head]); + if (mb.code === 0 && mb.stdout.trim()) leftRef = mb.stdout.trim(); + } catch { + leftRef = req.base; + } + } + const left = await showAt(ctx, leftRef, req.path); + const right = await showAt(ctx, req.head, req.path); + return { + path: req.path, + leftLabel: `${threeDot ? req.base + " (merge-base)" : req.base} ${req.path}`, + rightLabel: `${req.head} ${req.path}`, + leftText: left, + rightText: right, + conflicted: false, + }; + } + + // ── Code browser (GitHub-style file tree at HEAD) ─────────────────────────── + + /** + * Lists the immediate children of a directory at HEAD via + * `git ls-tree --long -z HEAD -- /`. The trailing slash + non-recursive + * ls-tree gives exactly one level (folders + files); -z is NUL-delimited so + * paths with spaces parse cleanly. Sorted folders-first then alphabetical — + * github.com's order. An empty `path` lists the repo root. + */ + /** + * The tip commit of HEAD plus the total commit count — backs the Code + * browser's "latest commit" bar. Two cheap calls (`log -1` + `rev-list + * --count`); failures degrade to `undefined` (the bar is simply omitted). + */ + async headCommit(): Promise { + const ctx = this.ctx(); + if (!ctx) { + return undefined; + } + const SEP = "\x00"; + try { + const r = await ctx.process.run([ + "log", + "-1", + "--no-color", + `--format=%H${SEP}%h${SEP}%an${SEP}%ae${SEP}%at${SEP}%s`, + "HEAD", + ]); + if (r.code !== 0 || !r.stdout.trim()) { + return undefined; + } + const [sha, shortSha, author, authorEmail, at, subject] = r.stdout + .replace(/\n$/, "") + .split(SEP); + let total = 0; + const c = await ctx.process.run(["rev-list", "--count", "HEAD"]); + if (c.code === 0) { + total = parseInt(c.stdout.trim(), 10) || 0; + } + return { + sha: sha ?? "", + shortSha: shortSha ?? "", + author: author ?? "", + authorEmail: authorEmail ?? "", + date: parseInt(at ?? "", 10) || 0, + subject: subject ?? "", + total, + }; + } catch { + return undefined; + } + } + + async treeList(req: { path: string }): Promise { + const ctx = this.ctx(); + if (!ctx) { + return []; + } + const dir = req.path.replace(/^\/+|\/+$/g, ""); + const spec = dir ? `${dir}/` : ""; + try { + const args = ["ls-tree", "--long", "-z", "HEAD", "--", ...(spec ? [spec] : [])]; + const r = await ctx.process.run(args); + if (r.code !== 0) { + return []; + } + const entries = parseLsTree(r.stdout); + entries.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "tree" ? -1 : 1; // folders first + } + return a.name.localeCompare(b.name); + }); + return entries; + } catch { + return []; + } + } + + /** + * Reads a blob's text at HEAD via `git show HEAD:`. Probes the size + * first (so huge files never hit the buffer) and flags binary content (a NUL + * byte) — mirroring the empty-string fallbacks used by showAt elsewhere. + */ + async fileText(req: { path: string }): Promise { + const ctx = this.ctx(); + if (!ctx) { + return undefined; + } + const rel = req.path.replace(/^\/+/, ""); + if (!rel) { + return undefined; + } + try { + const probe = await ctx.process.run(["ls-tree", "--long", "-z", "HEAD", "--", rel]); + if (probe.code !== 0 || !probe.stdout.trim()) { + return undefined; // not a tracked path at HEAD + } + const probed = parseLsTree(probe.stdout)[0]; + if (probed && probed.type !== "blob") { + return undefined; // it's a directory, not a file + } + if (probed && typeof probed.size === "number" && probed.size > FILE_CAP_BYTES) { + return { path: rel, text: "", truncated: true }; + } + const r = await ctx.process.run(["show", `HEAD:${rel}`]); + if (r.code !== 0) { + return undefined; + } + // Binary: a NUL byte, OR a high density of U+FFFD replacement chars — git's + // stdout is decoded utf8, so a non-UTF-8 / NUL-free binary surfaces as FFFD. + if (r.stdout.includes("\0") || replacementRatio(r.stdout) > 0.3) { + return { path: rel, text: "", binary: true }; + } + if (r.stdout.length > FILE_CAP_BYTES) { + return { path: rel, text: "", truncated: true }; + } + return { path: rel, text: r.stdout }; + } catch { + return undefined; + } + } + + // ── Settings: git identity + local SSH keys ───────────────────────────────── + + /** The global git author identity (`git config --global user.name/email`). */ + async gitIdentity(): Promise { + const ctx = this.ctx(); + if (!ctx) { + return { name: "", email: "" }; + } + const read = async (key: string): Promise => { + try { + const r = await ctx.process.run(["config", "--global", key]); + return r.code === 0 ? r.stdout.trim() : ""; + } catch { + return ""; + } + }; + return { name: await read("user.name"), email: await read("user.email") }; + } + + /** Set the global git author identity. */ + async setGitIdentity(req: GitIdentity): Promise { + const ctx = this.ctx(); + if (!ctx) { + return { ok: false, changed: false, message: "No repository open." }; + } + const name = req.name.trim(); + const email = req.email.trim(); + // A value starting with "-" would be read by `git config` as an option. + if ((name && name.startsWith("-")) || (email && email.startsWith("-"))) { + return { ok: false, changed: false, message: "Name and email can't start with “-”." }; + } + try { + if (name) { + await ctx.process.run(["config", "--global", "user.name", name]); + } + if (email) { + await ctx.process.run(["config", "--global", "user.email", email]); + } + return { ok: true, changed: true }; + } catch (err) { + return { ok: false, changed: false, message: err instanceof Error ? err.message : String(err) }; + } + } + + /** List the local SSH public keys under ~/.ssh (read-only). */ + async sshKeys(): Promise { + try { + const dir = join(homedir(), ".ssh"); + const files = await readdir(dir); + const out: SshKey[] = []; + for (const f of files) { + if (!f.endsWith(".pub")) { + continue; + } + try { + const content = (await readFile(join(dir, f), "utf8")).trim(); + const parts = content.split(/\s+/); + out.push({ file: f, type: parts[0] || "", comment: parts.slice(2).join(" ") }); + } catch { + // unreadable key file — skip + } + } + out.sort((a, b) => a.file.localeCompare(b.file)); + return out; + } catch { + return []; + } + } + + // ── Sync (control remote changes) ─────────────────────────────────────────── + + async syncStatus(): Promise { + const ctx = this.ctx(); + if (!ctx) { + return { ahead: 0, behind: 0, noUpstream: true }; + } + let branch: string | undefined; + try { + const h = await ctx.refs.getHead(); + branch = h.detached ? undefined : h.branch; + } catch { + branch = undefined; + } + const upstream = (await ctx.sync.currentUpstream().catch(() => null)) ?? undefined; + if (!upstream) { + return { branch, ahead: 0, behind: 0, noUpstream: true }; + } + const ab = await ctx.sync.aheadBehind().catch(() => ({ ahead: 0, behind: 0 })); + return { branch, upstream, ahead: ab.ahead, behind: ab.behind, noUpstream: false }; + } + + async syncFetch(): Promise { + return this.staged((ctx) => ctx.sync.fetch()); + } + async syncPull(): Promise { + return this.staged((ctx) => ctx.sync.pull()); + } + async syncPush(opts: { setUpstream?: boolean } | undefined): Promise { + return this.staged((ctx) => ctx.sync.push({ setUpstream: opts?.setUpstream })); + } + + /** Fast-forward a local branch straight from its upstream WITHOUT checking it + * out: `git fetch :`. Git itself refuses + * a non-fast-forward and the currently checked-out branch, so the worktree + * is never touched. */ + async branchPullFf(name: string): Promise { + if (!safeArg(name)) return UNSAFE_REF_RESULT; + return this.staged(async (ctx) => { + const up = await ctx.process.run([ + "for-each-ref", + "--format=%(upstream:short)", + `refs/heads/${name}`, + ]); + const upstream = up.stdout.trim(); + const slash = upstream.indexOf("/"); + if (up.code !== 0 || slash <= 0) { + return { ok: false, stderr: `'${name}' has no upstream to pull from.` }; + } + return ctx.process.run([ + "fetch", + upstream.slice(0, slash), + `${upstream.slice(slash + 1)}:${name}`, + ]); + }); + } + + // ── Branch management ─────────────────────────────────────────────────────── + + /** One `for-each-ref` gives every local branch with upstream + ahead/behind. */ + async branchesList(): Promise { + const ctx = this.ctx(); + if (!ctx) { + return []; + } + const SEP = "\x1f"; + const fmt = + `%(refname:short)${SEP}%(HEAD)${SEP}%(upstream:short)${SEP}` + + `%(upstream:track)${SEP}%(committerdate:unix)${SEP}%(contents:subject)`; + let out = ""; + try { + const r = await ctx.process.run([ + "for-each-ref", + `--format=${fmt}`, + "--sort=-committerdate", + "refs/heads", + ]); + out = r.stdout; + } catch { + return []; + } + const branches: BranchInfo[] = []; + for (const line of out.split("\n")) { + if (!line.trim()) continue; + const [name, head, upstream, track, date, subject] = line.split(SEP); + const { ahead, behind } = parseTrack(track ?? ""); + branches.push({ + name, + current: head === "*", + upstream: upstream || undefined, + ahead, + behind, + subject: subject ?? "", + date: Number(date) || 0, + }); + } + return branches; + } + + async branchCreate(req: { name: string; checkout?: boolean }): Promise { + if (!safeArg(req.name)) return UNSAFE_REF_RESULT; + return this.staged((ctx) => + req.checkout ? ctx.branches.checkoutNew(req.name) : ctx.branches.create(req.name), + ); + } + async branchDelete(req: { name: string; force?: boolean }): Promise { + if (!safeArg(req.name)) return UNSAFE_REF_RESULT; + return this.staged((ctx) => ctx.branches.delete(req.name, { force: req.force })); + } + + private async revCount(ctx: GitContext, range: string): Promise { + try { + const r = await ctx.process.run(["rev-list", "--count", range]); + return Number(r.stdout.trim()) || 0; + } catch { + return 0; + } + } + + /** + * Serialize working-tree / index / ref mutations. A fast double-action (a + * double-clicked Stage, or a checkout fired while a stage is mid-flight) would + * otherwise run two `git` processes against the same index at once and hit + * `index.lock`, or leave a half-applied state. Every mutation runs through this + * single chain; reads stay concurrent. + */ + private mutationChain: Promise = Promise.resolve(); + private serialize(op: () => Promise): Promise { + const result = this.mutationChain.then(op, op); + // Keep the chain alive whatever this op does; swallow on the chain copy so a + // failed mutation can't surface as an unhandled rejection (the caller still + // receives the real outcome via `result`). + this.mutationChain = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + /** Run a working-tree mutation, mapping the git-service result to the IPC shape. */ + private async staged( + op: (ctx: GitContext) => Promise<{ ok?: boolean; code?: number; stderr?: string }>, + ): Promise { + const ctx = this.ctx(); + if (!ctx) { + return { ok: false, changed: false, message: "No repository open." }; + } + return this.serialize(async () => { + try { + const r = await op(ctx); + const ok = r.ok ?? r.code === 0; + return { ok, changed: ok, message: ok ? undefined : r.stderr?.trim() }; + } catch (err) { + return { ok: false, changed: false, message: String(err) }; + } + }); + } + + // ── Commit actions (graph context menu) ───────────────────────────────────── + + /** + * Runs a git action against a commit via `ctx.process.run`. Destructive ops + * (reset --hard) are still confirm-gated in the renderer before this fires. + */ + async commitAction(req: CommitActionRequest): Promise { + const ctx = this.ctx(); + if (!ctx) { + return { ok: false, changed: false, message: "No repository open." }; + } + if (req.action !== "copy-sha" && !safeArg(req.sha)) { + return UNSAFE_REF_RESULT; + } + if ((req.action === "branch" || req.action === "tag") && !safeArg(req.name)) { + return UNSAFE_REF_RESULT; + } + const args = actionArgs(req); + if (!args) { + // copy-sha is handled entirely in the renderer; nothing to run here. + return { ok: true, changed: false }; + } + return this.serialize(async () => { + try { + const result = await ctx.process.run(args); + if (result.code !== 0) { + return { ok: false, changed: false, message: result.stderr.trim() }; + } + return { ok: true, changed: true }; + } catch (err) { + return { ok: false, changed: false, message: String(err) }; + } + }); + } + + // ── Branch ops (merge / rebase / rename / upstream) ───────────────────────── + + async branchMerge(req: { name: string; noFf?: boolean }): Promise { + if (!safeArg(req.name)) return UNSAFE_REF_RESULT; + return this.staged((ctx) => ctx.branches.merge(req.name, { noFf: req.noFf })); + } + + async branchRebase(req: { onto: string }): Promise { + if (!safeArg(req.onto)) return UNSAFE_REF_RESULT; + return this.staged((ctx) => ctx.branches.rebaseOnto(req.onto)); + } + + async branchRename(req: { from: string; to: string }): Promise { + if (!safeArg(req.from) || !safeArg(req.to)) return UNSAFE_REF_RESULT; + return this.staged((ctx) => ctx.branches.rename(req.from, req.to)); + } + + async branchSetUpstream(req: { name: string; upstream: string }): Promise { + if (!safeArg(req.name) || !safeArg(req.upstream)) return UNSAFE_REF_RESULT; + return this.staged((ctx) => ctx.branches.setUpstream(req.name, req.upstream)); + } + + async branchDeleteRemote(req: { remote: string; name: string }): Promise { + if (!safeArg(req.remote) || !safeArg(req.name)) return UNSAFE_REF_RESULT; + return this.staged((ctx) => ctx.branches.deleteRemoteBranch(req.remote, req.name)); + } + + // ── In-progress operation state + abort/continue ──────────────────────────── + + async opState(): Promise { + const ctx = this.ctx(); + const empty: GitOpState = { + merging: false, + rebasing: false, + cherryPicking: false, + reverting: false, + conflicts: 0, + }; + if (!ctx) return empty; + const present = async (gitPath: string): Promise => { + try { + const r = await ctx.process.run(["rev-parse", "--git-path", gitPath]); + if (r.code !== 0) return false; + await stat(join(ctx.root, r.stdout.trim())); + return true; + } catch { + return false; + } + }; + let conflicts = 0; + try { + conflicts = (await ctx.conflict.listConflicts()).length; + } catch { + conflicts = 0; + } + const [merging, rebaseM, rebaseA, cherryPicking, reverting] = await Promise.all([ + present("MERGE_HEAD"), + present("rebase-merge"), + present("rebase-apply"), + present("CHERRY_PICK_HEAD"), + present("REVERT_HEAD"), + ]); + return { + merging, + rebasing: rebaseM || rebaseA, + cherryPicking, + reverting, + conflicts, + }; + } + + private runResult(args: string[]): Promise { + return this.staged(async (ctx) => { + const r = await ctx.process.run(args); + return { ok: r.code === 0, code: r.code, stderr: r.stderr }; + }); + } + + mergeAbort(): Promise { + return this.runResult(["merge", "--abort"]); + } + mergeContinue(): Promise { + return this.runResult(["commit", "--no-edit"]); + } + rebaseAbort(): Promise { + return this.runResult(["rebase", "--abort"]); + } + rebaseContinue(): Promise { + return this.runResult(["-c", "core.editor=true", "rebase", "--continue"]); + } + rebaseSkip(): Promise { + return this.runResult(["-c", "core.editor=true", "rebase", "--skip"]); + } + + // ── Tag creation (the Branches view's "Create tag here…") ─────────────────── + + tagCreate(req: { name: string; ref?: string; message?: string }): Promise { + if (!safeArg(req.name)) return Promise.resolve(UNSAFE_REF_RESULT); + if (req.ref && !safeArg(req.ref)) return Promise.resolve(UNSAFE_REF_RESULT); + return this.staged((ctx) => + ctx.tags.create(req.name, { + ref: req.ref, + message: req.message, + annotated: req.message !== undefined && req.message.length > 0, + }), + ); + } + + // ── Hunk / line staging (working ⇄ index) ─────────────────────────────────── + + async stageLines(req: { path: string; lines: number[]; reverse?: boolean }): Promise { + const ctx = this.ctx(); + if (!ctx) return { ok: false, changed: false, message: "No repository open." }; + return this.serialize(async () => { + try { + const rel = req.path; + const ranges = linesToRanges(req.lines); + if (!ranges.length) return { ok: false, changed: false, message: "No lines selected." }; + let original: string; + let modified: string; + if (req.reverse) { + // Unstage: roll the selected index changes back to HEAD. + original = await ctx.staging.indexContent(rel); + modified = await ctx.staging.headContent(rel); + } else { + // Stage: apply the selected working-tree changes onto the index. + original = await ctx.staging.indexContent(rel); + modified = await readWorking(ctx, rel); + } + const hunks = computeHunks(original, modified); + const selected = hunks.filter((h) => ranges.some((r) => rangesOverlap(h.modified, r))); + if (!selected.length) return { ok: false, changed: false, message: "Nothing to apply in the selection." }; + const content = applySelectedChanges(original, modified, selected.map((h) => h.modified)); + await ctx.staging.stageContent(rel, content); + return { ok: true, changed: true }; + } catch (err) { + return { ok: false, changed: false, message: String(err) }; + } + }); + } + + // ── Conflict resolution write-back ────────────────────────────────────────── + + async conflictList(): Promise { + const ctx = this.ctx(); + if (!ctx) return []; + try { + return await ctx.conflict.listConflicts(); + } catch { + return []; + } + } + + async conflictResolve(req: { path: string; content: string }): Promise { + const ctx = this.ctx(); + if (!ctx) return { ok: false, changed: false, message: "No repository open." }; + if (!safeArg(req.path)) return UNSAFE_REF_RESULT; + return this.serialize(async () => { + try { + const abs = containedPath(ctx.root, req.path); + if (!abs) return { ok: false, changed: false, message: "Path escapes the repository." }; + await writeFile(abs, req.content, "utf8"); + const r = await ctx.process.run(["add", "--", req.path]); + if (r.code !== 0) return { ok: false, changed: false, message: r.stderr.trim() }; + return { ok: true, changed: true }; + } catch (err) { + return { ok: false, changed: false, message: String(err) }; + } + }); + } + + async conflictTakeSide(req: { path: string; side: "ours" | "theirs" }): Promise { + const ctx = this.ctx(); + if (!ctx) return { ok: false, changed: false, message: "No repository open." }; + if (!safeArg(req.path)) return UNSAFE_REF_RESULT; + const stage = req.side === "ours" ? "2" : "3"; + return this.serialize(async () => { + try { + const show = await ctx.process.run(["show", `:${stage}:${req.path}`]); + if (show.code !== 0) return { ok: false, changed: false, message: show.stderr.trim() }; + const abs = containedPath(ctx.root, req.path); + if (!abs) return { ok: false, changed: false, message: "Path escapes the repository." }; + await writeFile(abs, show.stdout, "utf8"); + const r = await ctx.process.run(["add", "--", req.path]); + if (r.code !== 0) return { ok: false, changed: false, message: r.stderr.trim() }; + return { ok: true, changed: true }; + } catch (err) { + return { ok: false, changed: false, message: String(err) }; + } + }); + } +} + +/** Group a sorted, de-duplicated list of 1-based line numbers into 0-based + * inclusive {start,end} ranges (consecutive lines merge into one range). */ +function linesToRanges(lines: number[]): LineRange[] { + const sorted = Array.from(new Set(lines.filter((n) => Number.isInteger(n) && n >= 1))).sort( + (a, b) => a - b, + ); + const ranges: LineRange[] = []; + for (const n of sorted) { + const zero = n - 1; + const last = ranges[ranges.length - 1]; + if (last && zero === last.end + 1) last.end = zero; + else ranges.push({ start: zero, end: zero }); + } + return ranges; +} + +/** Whether two inclusive line ranges overlap (zero-width spans treated as a point). */ +function rangesOverlap(a: LineRange, b: LineRange): boolean { + const aEnd = a.end < a.start ? a.start : a.end; + const bEnd = b.end < b.start ? b.start : b.end; + return a.start <= bEnd && b.start <= aEnd; +} + +/** The git argv for a commit action, or undefined for renderer-only actions. */ +function actionArgs(req: CommitActionRequest): string[] | undefined { + switch (req.action) { + case "checkout": + return ["checkout", req.sha]; + case "branch": + return req.name ? ["branch", req.name, req.sha] : undefined; + case "tag": + return req.name ? ["tag", req.name, req.sha] : undefined; + case "cherry-pick": + return ["cherry-pick", req.sha]; + case "revert": + return ["revert", "--no-edit", req.sha]; + case "reset-soft": + return ["reset", "--soft", req.sha]; + case "reset-mixed": + return ["reset", "--mixed", req.sha]; + case "reset-hard": + return ["reset", "--hard", req.sha]; + case "copy-sha": + return undefined; + } +} + +// ── content helpers ────────────────────────────────────────────────────────── + +async function showAt(ctx: GitContext, sha: string, rel: string): Promise { + const r = await ctx.process.run(["show", `${sha}:${rel}`]); + return r.code === 0 ? r.stdout : ""; +} + +/** + * Fraction of U+FFFD replacement chars in a utf8-decoded string. A non-UTF-8 or + * NUL-free binary blob surfaces as a high density of these; legit text (even + * Latin-1 prose with occasional accents) stays well below the 0.3 cutoff. + */ +function replacementRatio(s: string): number { + if (!s.length) { + return 0; + } + let n = 0; + for (let i = 0; i < s.length; i++) { + if (s.charCodeAt(i) === 0xfffd) { + n++; + } + } + return n / s.length; +} + +/** + * Parses `git ls-tree --long -z HEAD` output. Records are NUL-separated; each is + * ` SP SP SP+ TAB ` + * e.g. "100644 blob a1b2c3… 1234\tsrc/main.ts" or "040000 tree d4e5… -\tsrc". + * `--long` adds the right-aligned size column ("-" for trees). The path is + * everything after the TAB (so spaces are preserved). Submodules (type + * "commit") are skipped. + */ +export function parseLsTree(stdout: string): TreeEntry[] { + const out: TreeEntry[] = []; + for (const rec of stdout.split("\0")) { + if (!rec) { + continue; + } + const tab = rec.indexOf("\t"); + if (tab < 0) { + continue; + } + const meta = rec.slice(0, tab).trim().split(/\s+/); // [mode, type, oid, size] + const path = rec.slice(tab + 1); + const rawType = meta[1]; + if (rawType !== "tree" && rawType !== "blob") { + continue; // skip submodules / anything unexpected + } + const sizeField = meta[3]; + const size = + rawType === "blob" && sizeField && sizeField !== "-" ? Number(sizeField) : undefined; + const slash = path.lastIndexOf("/"); + const name = slash >= 0 ? path.slice(slash + 1) : path; + out.push({ name, path, type: rawType, ...(size !== undefined ? { size } : {}) }); + } + return out; +} + +async function parentOf(ctx: GitContext, sha: string): Promise { + const r = await ctx.process.run(["rev-parse", `${sha}^`]); + const parent = r.stdout.trim(); + return r.code === 0 && parent.length > 0 ? parent : undefined; +} + +/** + * Reads the on-disk working-tree text of a file. The desktop main process has + * real fs access (this is what an Electron host adds over a webview), so we read + * the actual file; if it's gone (a deletion) we fall back to the index, then + * HEAD, so the diff still shows the prior content on the left. + */ +async function readWorking(ctx: GitContext, rel: string): Promise { + try { + const abs = containedPath(ctx.root, rel); + if (!abs) return ""; + return await readFile(abs, "utf8"); + } catch { + const indexed = await ctx.staging.indexContent(rel).catch(() => ""); + return indexed || (await ctx.staging.headContent(rel).catch(() => "")); + } +} + +// ── parse helpers ──────────────────────────────────────────────────────────── + +/** Parses git's `%(upstream:track)` field, e.g. "[ahead 2, behind 1]" / "[gone]". */ +export function parseTrack(track: string): { ahead: number; behind: number } { + const a = track.match(/ahead (\d+)/); + const b = track.match(/behind (\d+)/); + return { ahead: a ? Number(a[1]) : 0, behind: b ? Number(b[1]) : 0 }; +} + +/** Parses `git diff --name-status` (tab-separated, newline-delimited). */ +export function parseNameStatus(stdout: string): ChangedFile[] { + const files: ChangedFile[] = []; + for (const line of stdout.split("\n")) { + if (!line) { + continue; + } + const parts = line.split("\t"); + const code = parts[0] ?? ""; + const status = code.charAt(0); + // Renames/copies carry two paths (R100\told\tnew); take the destination. + const path = parts.length >= 3 ? parts[2] : parts[1] ?? ""; + if (path) { + files.push({ path, status }); + } + } + return files; +} + +/** + * Parses `git status --porcelain=v1 -z` into changed files, flattening the + * two-column XY status into one entry per path with the staged flag set. + */ +export function parsePorcelainStatus(stdout: string): ChangedFile[] { + const files: ChangedFile[] = []; + const entries = stdout.split("\0").filter((e) => e.length > 0); + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const x = entry.charAt(0); + const y = entry.charAt(1); + let path = entry.slice(3); + // Renames consume the next NUL-delimited token (the original path). + if (x === "R" || y === "R" || x === "C" || y === "C") { + i++; + } + if (!path) { + continue; + } + // A record can carry BOTH an index half (x) and a worktree half (y) — + // e.g. "MM" = staged edit plus a newer unstaged edit. Emitting only the + // index side hides the worktree half from the Changes view, and a commit + // then silently excludes the newer edits. + const hasStaged = x !== " " && x !== "?"; + const hasUnstaged = y !== " "; + if (hasStaged) { + files.push({ path, status: x.trim() || "?", staged: true }); + } + if (hasUnstaged || !hasStaged) { + files.push({ path, status: y.trim() || "?", staged: false }); + } + } + return files; +} diff --git a/apps/desktop/src/main/github/actions.ts b/apps/desktop/src/main/github/actions.ts new file mode 100644 index 0000000..c3fd858 --- /dev/null +++ b/apps/desktop/src/main/github/actions.ts @@ -0,0 +1,719 @@ +// GitHub Actions section logic for the desktop app — standalone functions over +// the shared `GitHubClient` primitives (`request` / `requestBody`). Repo-scoped: +// every function takes (client, owner, repo, …); main.ts dispatches via +// `github.withRepo((c, o, r) => …)`. +// +// READ functions throw on failure (the renderer wraps them in try/catch → +// errorState + Retry). MUTATION functions never throw — they return a +// CommitActionResult-shaped `{ ok, changed, message }` and the renderer toasts. +// `changed` is always false here: Actions never touch the local working tree +// (unlike pr:checkout / pr:merge), so the commit graph never needs a refresh. +// +// Everything is REST. GitHub exposes NO GraphQL mutations for re-running / +// cancelling / dispatching Actions, and the read surface (runs/jobs/workflows) +// is REST-only — so this module deliberately never touches `graphql()`. + +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 type { + ArtifactInfo, + CommitActionResult, + RepoSecretInfo, + RepoVariableInfo, + WorkflowDispatchInput, + WorkflowInfo, + WorkflowJob, + WorkflowRun, + WorkflowRunDetail, +} from "../../shared/ipc"; + +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; + path?: string; + state?: string; + html_url?: string; +} +interface RawArtifact { + id: number; + name?: string; + size_in_bytes?: number; + expired?: boolean; + created_at?: string; +} +interface RawSecret { + name?: string; + updated_at?: string; +} +interface RawVariable { + name?: string; + value?: string; + updated_at?: string; +} + +// ── 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, + name: w.name ?? w.path ?? "(workflow)", + path: w.path ?? "", + state: w.state ?? "", + htmlUrl: w.html_url ?? "", + }; +} +function mapArtifact(a: RawArtifact): ArtifactInfo { + return { + id: a.id, + name: a.name ?? "(artifact)", + sizeBytes: a.size_in_bytes ?? 0, + expired: a.expired ?? false, + createdAt: a.created_at ?? "", + }; +} +function mapSecret(s: RawSecret): RepoSecretInfo { + return { name: s.name ?? "", updatedAt: s.updated_at ?? "" }; +} +function mapVariable(v: RawVariable): RepoVariableInfo { + return { name: v.name ?? "", value: v.value ?? "", updatedAt: v.updated_at ?? "" }; +} + +// ── Authed raw fetch (text / binary, following GitHub's signed redirect) ────── +// +// Logs and artifact zips are NOT JSON: GitHub answers `…/logs` and +// `…/artifacts/{id}/zip` with a 302 to a short-lived signed blob URL (S3 / Azure) +// that must be fetched WITHOUT our `Authorization` header — forwarding the Bearer +// to the blob store is rejected. The shared client only does JSON, so this module +// fetches these two endpoints itself. We read the bearer off the client (its +// constructor stores `getToken`), then fetch with `redirect: "manual"` to capture +// the `Location` and GET it bare. (If GitHub answers 200 directly — no redirect — +// we use that body.) Self-contained; the client and bridge are untouched. + +/** Read the bearer token the client was constructed with, or throw (mirrors the + * client's own "Not connected to GitHub." guard). The token lives in a private + * closure field; we reach it through a typed view rather than widening to `any`. */ +function bearer(client: GitHubClient): string { + const token = (client as unknown as { getToken: TokenGetter }).getToken(); + if (!token) throw new Error("Not connected to GitHub."); + return token; +} + +/** Common headers for the authed first hop to api.github.com. */ +function ghHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "GitStudio", + }; +} + +/** Turn a non-2xx GitHub response into a clean Error (parallels the client). */ +async function rawError(res: Response): Promise { + let detail = ""; + try { + detail = ((await res.json()) as { message?: string })?.message ?? ""; + } catch { + /* non-JSON body */ + } + if (res.status === 401) return new Error("Your GitHub token is invalid or expired."); + if (res.status === 403) return new Error(detail || "GitHub denied the request (permissions or rate limit)."); + if (res.status === 404) return new Error(detail || "Not found on GitHub."); + return new Error(detail || `GitHub request failed (HTTP ${res.status}).`); +} + +/** + * GET a redirecting GitHub endpoint and return the final `Response`. Hits the API + * with `redirect: "manual"`; on a 3xx, re-GETs the `Location` with NO auth header + * (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 { + let res: Response; + try { + res = await fetch(`${API_BASE}${path}`, { headers: ghHeaders(token), redirect: "manual" }); + } catch { + throw new Error("Couldn't reach GitHub. Check your network connection."); + } + // undici surfaces the real 3xx (not an opaque response) with a readable Location. + if (res.status >= 300 && res.status < 400) { + const loc = res.headers.get("location"); + if (!loc) throw new Error("GitHub returned a redirect with no location."); + try { + res = await fetch(loc); + } catch { + throw new Error("Couldn't download from GitHub's storage. Check your network connection."); + } + } + if (!res.ok) throw await rawError(res); + return res; +} + +// ── Reads (throw on API error) ─────────────────────────────────────────────── + +/** Recent workflow runs for the repo (capped at 30, newest first). */ +export async function listRuns(client: GitHubClient, owner: string, repo: string): Promise { + const raw = await client.request<{ workflow_runs?: RawRun[] }>( + "GET", + `/repos/${enc(owner)}/${enc(repo)}/actions/runs?per_page=30`, + ); + return (raw.workflow_runs ?? []).map(mapRun); +} + +/** A single run plus its jobs (GET /actions/runs/{id} + /jobs), for the detail pane. */ +export async function getRunDetail( + client: GitHubClient, + owner: string, + repo: string, + id: number, +): Promise { + const run = await client.request( + "GET", + `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${id}`, + ); + const jobsRaw = await client.request<{ jobs?: RawJob[] }>( + "GET", + `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${id}/jobs?per_page=100`, + ); + return { run: mapRun(run), jobs: (jobsRaw.jobs ?? []).map(mapJob) }; +} + +/** All workflows declared in this repo (GET /actions/workflows). */ +export async function listWorkflows( + client: GitHubClient, + owner: string, + repo: string, +): Promise { + const raw = await client.request<{ workflows?: RawWorkflow[] }>( + "GET", + `/repos/${enc(owner)}/${enc(repo)}/actions/workflows?per_page=100`, + ); + return (raw.workflows ?? []).map(mapWorkflow); +} + +/** + * Parse a workflow's `on.workflow_dispatch.inputs` so the renderer can build a + * real dispatch form. GitHub's REST API exposes inputs NOWHERE, so we read the + * workflow YAML: GET the workflow (to learn its `path`) → GET `/contents/{path}` + * (base64 YAML) → `parseDispatchInputs`. Best-effort and dependency-free (the + * repo bundles no YAML lib); returns [] when there are no inputs — which is + * still valid, the form just shows the ref field. + */ +export async function getDispatchInputs( + client: GitHubClient, + owner: string, + repo: string, + id: number, +): Promise { + const wf = await client.request( + "GET", + `/repos/${enc(owner)}/${enc(repo)}/actions/workflows/${id}`, + ); + if (!wf.path) return []; + const file = await client.request<{ content?: string; encoding?: string }>( + "GET", + `/repos/${enc(owner)}/${enc(repo)}/contents/${wf.path.split("/").map(enc).join("/")}`, + ); + if (!file.content) return []; + const encoding: BufferEncoding = file.encoding === "base64" || !file.encoding ? "base64" : "utf8"; + const yaml = Buffer.from(file.content, encoding).toString("utf8"); + return parseDispatchInputs(yaml); +} + +/** + * Plain-text logs for ONE job (GET /actions/jobs/{jobId}/logs). GitHub 302s to a + * signed text URL; `fetchSignedRedirect` follows it and we return the body. The + * renderer drops the text into an in-app `
` viewer (no more github.com).
+ */
+export async function jobLog(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { jobId: number },
+): Promise {
+  const res = await fetchSignedRedirect(
+    bearer(client),
+    `/repos/${enc(owner)}/${enc(repo)}/actions/jobs/${req.jobId}/logs`,
+  );
+  return res.text();
+}
+
+/**
+ * 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.
+ */
+export async function runLog(
+  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
+  }
+  return parts.join("\n");
+}
+
+/** Artifacts produced by a run (GET /actions/runs/{id}/artifacts). */
+export async function artifacts(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  runId: number,
+): Promise {
+  const raw = await client.request<{ artifacts?: RawArtifact[] }>(
+    "GET",
+    `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${runId}/artifacts?per_page=100`,
+  );
+  return (raw.artifacts ?? []).map(mapArtifact);
+}
+
+/** All repo Actions secrets — names + updatedAt only (values are write-only). */
+export async function secrets(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+): Promise {
+  const raw = await client.request<{ secrets?: RawSecret[] }>(
+    "GET",
+    `/repos/${enc(owner)}/${enc(repo)}/actions/secrets?per_page=100`,
+  );
+  return (raw.secrets ?? []).map(mapSecret);
+}
+
+/** All repo Actions variables — name, value, updatedAt (values ARE readable). */
+export async function variables(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+): Promise {
+  const raw = await client.request<{ variables?: RawVariable[] }>(
+    "GET",
+    `/repos/${enc(owner)}/${enc(repo)}/actions/variables?per_page=100`,
+  );
+  return (raw.variables ?? []).map(mapVariable);
+}
+
+// ── Mutations (never throw — return CommitActionResult) ──────────────────────
+
+const ok = (msg?: string): CommitActionResult => ({ ok: true, changed: false, message: msg });
+const fail = (err: unknown): CommitActionResult => ({
+  ok: false,
+  changed: false,
+  message: err instanceof Error ? err.message : String(err),
+});
+
+/** Re-run every job in a run (POST /actions/runs/{id}/rerun). */
+export async function rerunRun(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  id: number,
+): Promise {
+  try {
+    await client.requestBody("POST", `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${id}/rerun`, {});
+    return ok();
+  } catch (err) {
+    return fail(err);
+  }
+}
+
+/** Re-run only the failed jobs (POST /actions/runs/{id}/rerun-failed-jobs). */
+export async function rerunFailedJobs(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  id: number,
+): Promise {
+  try {
+    await client.requestBody(
+      "POST",
+      `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${id}/rerun-failed-jobs`,
+      {},
+    );
+    return ok();
+  } catch (err) {
+    return fail(err);
+  }
+}
+
+/** Cancel an in-progress run (POST /actions/runs/{id}/cancel). */
+export async function cancelRun(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  id: number,
+): Promise {
+  try {
+    await client.requestBody("POST", `/repos/${enc(owner)}/${enc(repo)}/actions/runs/${id}/cancel`, {});
+    return ok();
+  } catch (err) {
+    return fail(err);
+  }
+}
+
+/**
+ * Manually trigger a `workflow_dispatch` (POST /actions/workflows/{id}/dispatches).
+ * GitHub requires every input value to be a STRING in the payload (even
+ * booleans/numbers); the renderer already collects strings and omits empties so
+ * the workflow's declared defaults apply server-side. Returns 204 No Content on
+ * success — `requestBody` treats any 2xx as success.
+ */
+export async function dispatchWorkflow(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { workflowId: number; ref: string; inputs: Record },
+): Promise {
+  try {
+    const body: { ref: string; inputs?: Record } = { ref: req.ref };
+    if (req.inputs && Object.keys(req.inputs).length) body.inputs = req.inputs;
+    await client.requestBody(
+      "POST",
+      `/repos/${enc(owner)}/${enc(repo)}/actions/workflows/${req.workflowId}/dispatches`,
+      body,
+    );
+    return ok();
+  } catch (err) {
+    return fail(err);
+  }
+}
+
+/**
+ * Download a run artifact's ZIP to the user's Downloads folder
+ * (GET /actions/artifacts/{id}/zip → signed redirect → bytes → ~/Downloads).
+ * Returns ok with the saved absolute path so the renderer can toast it. The
+ * filename is sanitized and `.zip`-suffixed; we never overwrite blindly — a
+ * collision gets a `(1)`, `(2)`… suffix. Expired artifacts 410 → clean message.
+ */
+export async function downloadArtifact(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { id: number; name: string },
+): Promise {
+  try {
+    const res = await fetchSignedRedirect(
+      bearer(client),
+      `/repos/${enc(owner)}/${enc(repo)}/actions/artifacts/${req.id}/zip`,
+    );
+    const bytes = Buffer.from(await res.arrayBuffer());
+    const dest = await uniqueDownloadPath(safeFileName(req.name) + ".zip");
+    await writeFile(dest, bytes);
+    return ok(`Saved to ${dest}`);
+  } catch (err) {
+    return fail(err);
+  }
+}
+
+/**
+ * Create or update a repo Actions secret. Encrypting the value needs a libsodium
+ * sealed box against the repo's public key — and this app bundles NO crypto
+ * dependency (no libsodium-wrappers / tweetnacl), so we cannot encrypt safely.
+ * Rather than ship a broken write, we return a clear, actionable message. Listing
+ * + delete (and ALL of variables) work without crypto and are fully implemented.
+ */
+export async function setSecret(
+  _client: GitHubClient,
+  _owner: string,
+  _repo: string,
+  _req: { name: string; value: string },
+): Promise {
+  return {
+    ok: false,
+    changed: false,
+    message:
+      "Creating or updating secrets needs the libsodium encryption library, which isn't bundled in this build. You can still delete secrets here; to add one, use github.com for now.",
+  };
+}
+
+/** Delete a repo Actions secret (DELETE /actions/secrets/{name}). */
+export async function deleteSecret(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  name: string,
+): Promise {
+  try {
+    await client.requestBody(
+      "DELETE",
+      `/repos/${enc(owner)}/${enc(repo)}/actions/secrets/${enc(name)}`,
+      {},
+    );
+    return ok();
+  } catch (err) {
+    return fail(err);
+  }
+}
+
+/**
+ * Create or update a repo Actions variable. Variables are plaintext (no crypto),
+ * so this works fully. GitHub has no upsert: PATCH the existing variable, and on
+ * a 404 (it doesn't exist yet) fall back to POST to create it.
+ */
+export async function setVariable(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { name: string; value: string },
+): Promise {
+  const base = `/repos/${enc(owner)}/${enc(repo)}/actions/variables`;
+  try {
+    await client.requestBody("PATCH", `${base}/${enc(req.name)}`, {
+      name: req.name,
+      value: req.value,
+    });
+    return ok();
+  } catch (err) {
+    if (!isNotFound(err)) return fail(err);
+    // Doesn't exist yet → create it.
+    try {
+      await client.requestBody("POST", base, { name: req.name, value: req.value });
+      return ok();
+    } catch (createErr) {
+      return fail(createErr);
+    }
+  }
+}
+
+/** Delete a repo Actions variable (DELETE /actions/variables/{name}). */
+export async function deleteVariable(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  name: string,
+): Promise {
+  try {
+    await client.requestBody(
+      "DELETE",
+      `/repos/${enc(owner)}/${enc(repo)}/actions/variables/${enc(name)}`,
+      {},
+    );
+    return ok();
+  } catch (err) {
+    return fail(err);
+  }
+}
+
+// ── Download helpers ─────────────────────────────────────────────────────────
+
+/** The client's 404 maps to "Not found on GitHub." — detect it to drive the
+ *  variable PATCH→POST upsert without a second HEAD round-trip. */
+function isNotFound(err: unknown): boolean {
+  return err instanceof Error && /not found on github/i.test(err.message);
+}
+
+/** Sanitize an artifact name into a single safe path segment (no separators,
+ *  control chars, or leading dots), so the save path can't escape Downloads. */
+function safeFileName(name: string): string {
+  const cleaned = (name || "artifact")
+    .replace(/[/\\:*?"<>| -]+/g, "_")
+    .replace(/^\.+/, "")
+    .replace(/[. ]+$/, "")
+    .slice(0, 200)
+    .trim();
+  return cleaned || "artifact";
+}
+
+/** A non-clobbering path in ~/Downloads: "name.zip", then "name (1).zip", … */
+async function uniqueDownloadPath(fileName: string): Promise {
+  const dir = join(homedir(), "Downloads");
+  const dot = fileName.lastIndexOf(".");
+  const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
+  const ext = dot > 0 ? fileName.slice(dot) : "";
+  for (let i = 0; i < 1000; i++) {
+    const candidate = join(dir, i === 0 ? fileName : `${stem} (${i})${ext}`);
+    if (!(await pathExists(candidate))) return candidate;
+  }
+  // Astronomically unlikely; fall back to a timestamped name.
+  return join(dir, `${stem}-${Date.now()}${ext}`);
+}
+
+/** True when a file/dir exists at `p` (no throw). */
+async function pathExists(p: string): Promise {
+  try {
+    await access(p);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+// ── Minimal YAML reader for `on.workflow_dispatch.inputs:` ────────────────────
+
+/**
+ * A deliberately small indent-walking parser — enough to drive the dispatch
+ * form, no external YAML dependency. Handles scalar inputs plus `required`,
+ * `default`, `description`, `type`, and `type: choice` with an `options:` list.
+ * Returns [] for the list form `on: [workflow_dispatch]` and when there are no
+ * inputs. Best-effort: if it yields nothing the form still shows the ref field,
+ * which is a valid dispatch (POST accepts `{ ref }` with no inputs).
+ */
+function parseDispatchInputs(yaml: string): WorkflowDispatchInput[] {
+  const lines = yaml.split(/\r?\n/);
+  const indentOf = (s: string): number => s.length - s.replace(/^\s+/, "").length;
+
+  // Find `workflow_dispatch:` then its `inputs:` child.
+  let i = lines.findIndex((l) => /^\s*workflow_dispatch\s*:/.test(l));
+  if (i < 0) return [];
+  const wdIndent = indentOf(lines[i]);
+  let inputsIndent = -1;
+  i += 1;
+  for (; i < lines.length; i++) {
+    const l = lines[i];
+    if (l.trim() === "" || l.trim().startsWith("#")) continue;
+    const ind = indentOf(l);
+    if (ind <= wdIndent) return []; // left the workflow_dispatch block — no inputs
+    if (/^\s*inputs\s*:/.test(l)) {
+      inputsIndent = ind;
+      i += 1;
+      break;
+    }
+  }
+  if (inputsIndent < 0) return [];
+
+  const out: WorkflowDispatchInput[] = [];
+  let cur: WorkflowDispatchInput | undefined;
+  let inOptions = false;
+  // The column of the first real input key; once locked, only keys at exactly
+  // this indent start a new input — so child props (options:, description:, …)
+  // at a deeper indent are never misread as inputs.
+  let keyIndent = -1;
+  for (; i < lines.length; i++) {
+    const rawLine = lines[i];
+    if (rawLine.trim() === "" || rawLine.trim().startsWith("#")) continue;
+    const ind = indentOf(rawLine);
+    if (ind <= inputsIndent) break; // dedented out of `inputs:`
+    const line = rawLine.trim();
+
+    // A new input key sits one level under `inputs:`.
+    const keyM = line.match(/^([A-Za-z0-9_.-]+)\s*:\s*$/);
+    const isNewKey =
+      !!keyM &&
+      !line.startsWith("-") &&
+      (keyIndent < 0 ? ind <= inputsIndent + 4 : ind === keyIndent);
+    if (isNewKey && keyM) {
+      if (keyIndent < 0) keyIndent = ind;
+      cur = { name: keyM[1], description: "", required: false, default: "", type: "string" };
+      out.push(cur);
+      inOptions = false;
+      continue;
+    }
+    if (!cur) continue;
+
+    if (inOptions) {
+      const optM = line.match(/^-\s*(.+)$/);
+      if (optM) {
+        (cur.options ??= []).push(stripYamlScalar(optM[1]));
+        continue;
+      }
+      inOptions = false;
+    }
+
+    const kv = line.match(/^(description|required|default|type|options)\s*:\s*(.*)$/);
+    if (!kv) continue;
+    const key = kv[1];
+    const value = kv[2].trim();
+    if (key === "options") {
+      inOptions = true;
+      cur.options = [];
+    } else if (key === "required") {
+      cur.required = /^true$/i.test(stripYamlScalar(value));
+    } else if (key === "type") {
+      cur.type = stripYamlScalar(value) || "string";
+    } else if (key === "default") {
+      cur.default = stripYamlScalar(value);
+    } else if (key === "description") {
+      cur.description = stripYamlScalar(value);
+    }
+  }
+  return out;
+}
+
+/** Strip surrounding single/double quotes from a YAML scalar. */
+function stripYamlScalar(s: string): string {
+  const t = s.trim();
+  if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
+    return t.slice(1, -1);
+  }
+  return t;
+}
diff --git a/apps/desktop/src/main/github/gists.ts b/apps/desktop/src/main/github/gists.ts
new file mode 100644
index 0000000..aff2202
--- /dev/null
+++ b/apps/desktop/src/main/github/gists.ts
@@ -0,0 +1,150 @@
+// Gists — the user-scoped GitHub section (NOT repo-scoped). These standalone
+// functions are invoked from main.ts via `github.withClient((c) => gistX(c, …))`,
+// so they take only the client (no owner/repo). They hit `/gists` and
+// `/gists/{id}` directly, authed as the current user via the Bearer token.
+//
+// Convention (post-2026-06-27): read functions THROW on API error so the
+// renderer can show a real errorState + Retry; mutation functions never throw —
+// they return a CommitActionResult ({ ok, changed, message? }) so the view can
+// toast the message. `changed` is always false for gist ops (gists touch no
+// local git state); on create, `message` carries the new gist id so the caller
+// can select it after a refresh.
+
+import { GitHubClient, enc, mapUser, RawUser } from "../githubClient";
+import type {
+  CommitActionResult,
+  GistCreate,
+  GistFile,
+  GistInfo,
+  GistUpdate,
+} from "../../shared/ipc";
+
+// ── Raw API shapes (the JSON GitHub returns) ─────────────────────────────────
+
+interface RawGistFile {
+  filename?: string;
+  language?: string | null;
+  type?: string;
+  size?: number;
+  raw_url?: string;
+  content?: string;
+  truncated?: boolean;
+}
+
+interface RawGist {
+  id: string;
+  description: string | null;
+  public: boolean;
+  html_url: string;
+  owner?: RawUser | null;
+  created_at: string;
+  updated_at: string;
+  comments?: number;
+  files: Record;
+}
+
+// ── Mappers (raw → the typed ipc shapes the renderer consumes) ───────────────
+
+function mapGistFile(key: string, f: RawGistFile): GistFile {
+  return {
+    filename: f.filename ?? key,
+    language: f.language ?? "",
+    type: f.type ?? "",
+    size: f.size ?? 0,
+    rawUrl: f.raw_url ?? "",
+    content: f.content ?? "",
+    truncated: f.truncated ?? false,
+  };
+}
+
+function mapGist(g: RawGist): GistInfo {
+  const files: GistFile[] = Object.entries(g.files ?? {})
+    .filter((entry): entry is [string, RawGistFile] => entry[1] != null)
+    .map(([key, f]) => mapGistFile(key, f));
+  return {
+    id: g.id,
+    description: g.description ?? "",
+    public: g.public,
+    htmlUrl: g.html_url,
+    owner: mapUser(g.owner ?? null),
+    createdAt: g.created_at,
+    updatedAt: g.updated_at,
+    fileCount: files.length,
+    files,
+    comments: g.comments ?? 0,
+  };
+}
+
+function errMessage(err: unknown): string {
+  return err instanceof Error ? err.message : String(err);
+}
+
+// ── Reads (throw on error) ───────────────────────────────────────────────────
+
+/** The authenticated user's own gists, newest first (GitHub default: updated desc).
+ *  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");
+  return raw.map(mapGist);
+}
+
+/** A single gist with full file `content` (and `truncated` flags for big files). */
+export async function getGist(client: GitHubClient, id: string): Promise {
+  const raw = await client.request("GET", `/gists/${enc(id)}`);
+  return mapGist(raw);
+}
+
+// ── Mutations (return CommitActionResult; never throw) ───────────────────────
+
+/** Create a single-file gist. On success, `message` carries the new gist id. */
+export async function createGist(
+  client: GitHubClient,
+  input: GistCreate,
+): Promise {
+  try {
+    const created = await client.request("POST", "/gists", {
+      description: input.description,
+      public: input.public,
+      files: { [input.filename]: { content: input.content } },
+    });
+    return { ok: true, changed: false, message: created.id };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/** Edit a gist's description and its (single) file. GitHub keys files by their
+ *  CURRENT name; to rename, set `filename` on the value to the new name; to
+ *  change content, set `content`. Keep the key = the existing filename. */
+export async function updateGist(
+  client: GitHubClient,
+  input: GistUpdate,
+): Promise {
+  try {
+    const file: { content: string; filename?: string } = { content: input.content };
+    if (input.newFilename && input.newFilename !== input.filename) {
+      file.filename = input.newFilename;
+    }
+    await client.request("PATCH", `/gists/${enc(input.id)}`, {
+      description: input.description,
+      files: { [input.filename]: file },
+    });
+    return { ok: true, changed: false };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/** Permanently delete a gist (204 on success). */
+export async function deleteGist(
+  client: GitHubClient,
+  id: string,
+): Promise {
+  try {
+    await client.request("DELETE", `/gists/${enc(id)}`);
+    return { ok: true, changed: false };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
diff --git a/apps/desktop/src/main/github/issues.ts b/apps/desktop/src/main/github/issues.ts
new file mode 100644
index 0000000..e71b231
--- /dev/null
+++ b/apps/desktop/src/main/github/issues.ts
@@ -0,0 +1,406 @@
+// Issues — the repo-scoped GitHub Issues surface for the desktop app.
+//
+// Standalone, self-contained functions over the shared `GitHubClient`
+// primitives. main.ts wires each channel through `github.withRepo((c, o, r) =>
+// …)`, so every function here takes `(client, owner, repo, …args)`.
+//
+// Convention (post-2026-06-27):
+//   • READ functions THROW on error (the client primitives already throw via
+//     `toError`) so the renderer can show a real error state.
+//   • MUTATION functions never throw — they return a CommitActionResult-shaped
+//     `{ ok, changed, message? }` so the renderer can toast cleanly.
+//
+// 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 type {
+  CommitActionResult,
+  GitHubUser,
+  IssueComment,
+  IssueDetail,
+  IssueInfo,
+  MilestoneInfo,
+  RepoLabel,
+} from "../../shared/ipc";
+
+// ── 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;
+  description?: string | null;
+}
+interface RawMilestone {
+  number: number;
+  title: string;
+  state: string;
+  due_on?: string | null;
+  open_issues: number;
+  closed_issues: number;
+}
+
+// ── 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 };
+}
+
+function mapMilestone(m: RawMilestone): MilestoneInfo {
+  return {
+    number: m.number,
+    title: m.title,
+    state: m.state === "closed" ? "closed" : "open",
+    dueOn: m.due_on ?? null,
+    openIssues: m.open_issues,
+    closedIssues: m.closed_issues,
+  };
+}
+
+/** Coerce any thrown value into a clean, user-facing message. */
+function errMessage(err: unknown): string {
+  return err instanceof Error ? err.message : String(err);
+}
+
+// ── Reads (THROW on error) ───────────────────────────────────────────────────
+
+/**
+ * Open (or closed/all) issues for the repo, newest-updated first. The `issues`
+ * endpoint also returns PRs, so we drop anything carrying a `pull_request` node.
+ */
+export async function listIssues(
+  client: GitHubClient,
+  owner: string,
+  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`,
+  );
+  return raw.filter((i) => !i.pull_request).map(mapIssue);
+}
+
+/**
+ * One issue plus its comment timeline (oldest → newest, GitHub's default order)
+ * and the current assignee logins. The issue read throws on error; the comments
+ * read is best-effort (a comment-fetch hiccup shouldn't blank the whole detail).
+ */
+export async function getIssueDetail(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  n: number,
+): Promise {
+  const issue = mapIssue(
+    await client.request("GET", `/repos/${enc(owner)}/${enc(repo)}/issues/${n}`),
+  );
+  const comments = await client
+    .request(
+      "GET",
+      `/repos/${enc(owner)}/${enc(repo)}/issues/${n}/comments?per_page=100`,
+    )
+    .then((raw) => raw.map(mapComment))
+    .catch(() => [] as IssueComment[]);
+  return { issue, comments, assignees: issue.assignees.map((a) => a.login) };
+}
+
+/** The repo's defined labels, for the label picker (GET …/labels). */
+export async function listLabels(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+): Promise {
+  const raw = await client.request(
+    "GET",
+    `/repos/${enc(owner)}/${enc(repo)}/labels?per_page=100`,
+  );
+  return raw.map(mapLabel);
+}
+
+/** Repo-level label CRUD (the maintainer's label management surface). */
+export async function createLabel(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { name: string; color: string; description?: string },
+): Promise {
+  try {
+    await client.requestBody("POST", `/repos/${enc(owner)}/${enc(repo)}/labels`, {
+      name: req.name,
+      color: req.color.replace(/^#/, ""),
+      description: req.description ?? "",
+    });
+    return { ok: true, changed: true };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+export async function updateLabel(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { name: string; newName?: string; color?: string; description?: string },
+): Promise {
+  try {
+    const body: Record = {};
+    if (req.newName) body.new_name = req.newName;
+    if (req.color) body.color = req.color.replace(/^#/, "");
+    if (req.description !== undefined) body.description = req.description;
+    await client.requestBody(
+      "PATCH",
+      `/repos/${enc(owner)}/${enc(repo)}/labels/${enc(req.name)}`,
+      body,
+    );
+    return { ok: true, changed: true };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+export async function deleteLabel(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  name: string,
+): Promise {
+  try {
+    await client.request("DELETE", `/repos/${enc(owner)}/${enc(repo)}/labels/${enc(name)}`);
+    return { ok: true, changed: true };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/**
+ * Every milestone (open AND closed) for the repo, for the milestone filter and
+ * the per-issue milestone picker. `state=all` so closed milestones still show
+ * (an issue can carry a closed milestone). Newest-due first is GitHub's default.
+ */
+export async function milestones(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+): Promise {
+  const raw = await client.request(
+    "GET",
+    `/repos/${enc(owner)}/${enc(repo)}/milestones?state=all&per_page=100`,
+  );
+  return raw.map(mapMilestone);
+}
+
+// ── Mutations (never throw — return CommitActionResult) ──────────────────────
+
+/**
+ * Open a new issue. Returns the created issue's `number` so the caller can
+ * select it. This is its own result shape (carries `number`) per the channel.
+ */
+export async function createIssue(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { title: string; body?: string },
+): Promise<{ ok: boolean; number?: number; message?: string }> {
+  const title = req.title.trim();
+  if (!title) {
+    return { ok: false, message: "An issue needs a title." };
+  }
+  try {
+    const created = await client.request(
+      "POST",
+      `/repos/${enc(owner)}/${enc(repo)}/issues`,
+      { title, body: req.body ?? "" },
+    );
+    return { ok: true, number: created.number };
+  } catch (err) {
+    return { ok: false, message: errMessage(err) };
+  }
+}
+
+/** Post a comment on an issue (POST …/issues/{n}/comments). */
+export async function commentIssue(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { number: number; body: string },
+): Promise {
+  const body = req.body.trim();
+  if (!body) {
+    return { ok: false, changed: false, message: "Write a comment first." };
+  }
+  try {
+    await client.requestBody(
+      "POST",
+      `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}/comments`,
+      { body },
+    );
+    return { ok: true, changed: true };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/** Close or reopen an issue (PATCH state). */
+export async function setIssueState(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { number: number; state: "open" | "closed" },
+): Promise {
+  try {
+    await client.requestBody("PATCH", `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}`, {
+      state: req.state,
+    });
+    return { ok: true, changed: true };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/** Edit an issue's title and/or body (PATCH). */
+export async function editIssue(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { number: number; title?: string; body?: string },
+): Promise {
+  const fields: { title?: string; body?: string } = {};
+  if (typeof req.title === "string") fields.title = req.title.trim();
+  if (typeof req.body === "string") fields.body = req.body;
+  if (fields.title !== undefined && fields.title === "") {
+    return { ok: false, changed: false, message: "An issue needs a title." };
+  }
+  if (fields.title === undefined && fields.body === undefined) {
+    return { ok: false, changed: false, message: "Nothing to update." };
+  }
+  try {
+    await client.requestBody(
+      "PATCH",
+      `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}`,
+      fields,
+    );
+    return { ok: true, changed: true };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/**
+ * Replace the issue's full label set (PUT …/issues/{n}/labels). PUT replaces
+ * the whole set, which is exactly what the label-toggle picker needs.
+ */
+export async function setIssueLabels(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { number: number; labels: string[] },
+): Promise {
+  try {
+    await client.requestBody(
+      "PUT",
+      `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}/labels`,
+      { labels: req.labels },
+    );
+    return { ok: true, changed: true };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/**
+ * Replace the issue's assignee set. GitHub has no single "replace assignees"
+ * call, but the `assignees` array on PATCH /issues/{n} replaces the set
+ * atomically. Non-collaborator logins are silently dropped by GitHub (no 422);
+ * the renderer re-fetches afterward to show the authoritative set.
+ */
+export async function setIssueAssignees(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { number: number; assignees: string[] },
+): Promise {
+  try {
+    await client.requestBody("PATCH", `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}`, {
+      assignees: req.assignees,
+    });
+    return { ok: true, changed: true };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/**
+ * Set or clear the issue's milestone (PATCH /issues/{n}). `milestone` is the
+ * milestone number to assign, or `null` to remove it — GitHub accepts a literal
+ * `null` on this field to clear it, which is exactly what the picker's "No
+ * milestone" choice sends.
+ */
+export async function setMilestone(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { number: number; milestone: number | null },
+): Promise {
+  try {
+    await client.requestBody("PATCH", `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}`, {
+      milestone: req.milestone,
+    });
+    return { ok: true, changed: true };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
diff --git a/apps/desktop/src/main/github/notifications.ts b/apps/desktop/src/main/github/notifications.ts
new file mode 100644
index 0000000..2369497
--- /dev/null
+++ b/apps/desktop/src/main/github/notifications.ts
@@ -0,0 +1,130 @@
+// The Notifications section's GitHub logic (the user's inbox). Runs in the
+// Electron MAIN process. Unlike the repo-scoped sections (PRs / Issues /
+// Releases), the GitHub Activity/Notifications API is ACCOUNT-scoped — the
+// /notifications endpoints take no owner/repo — so these functions take only
+// the client (+ args). main.ts invokes them via `github.withClient(...)`.
+//
+// All three endpoints are REST-only (the Activity API has no GraphQL form). The
+// OAuth token already carries the `notifications` scope GET /notifications,
+// PATCH /notifications/threads/{id}, and PUT /notifications require, so no new
+// scope is needed; the existing `request`/`requestBody` primitives set Bearer.
+
+import { GitHubClient, enc } from "../githubClient";
+import type { NotificationActionResult, NotificationThread } from "../../shared/ipc";
+
+/** Options for the inbox listing (mirrors the IPC request shape). */
+export interface ListNotificationsOptions {
+  /** Include already-read threads (GET /notifications?all=true). */
+  all?: boolean;
+  /** Restrict to threads the user is directly participating in. */
+  participating?: boolean;
+}
+
+/**
+ * The user's notification inbox across every watched repo. A READ method, so it
+ * THROWS on auth / rate-limit / network failure — the renderer surfaces a real
+ * error state + Retry rather than a misleading "inbox zero". Capped at 50 (no
+ * "load more" in v1, matching the other list views).
+ */
+export async function listNotifications(
+  client: GitHubClient,
+  opts: ListNotificationsOptions = {},
+): Promise {
+  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()}`);
+  return raw.map(mapNotification);
+}
+
+/**
+ * Mark a single thread as read (GitHub also stops surfacing it as unread). A
+ * MUTATION, so it returns the `{ ok, message }` result shape and never throws —
+ * the renderer toasts. PATCH .../threads/{id} returns 205 with no body; the
+ * client's `requestBody` only checks `res.ok`, which 2xx satisfies.
+ */
+export async function markNotificationRead(
+  client: GitHubClient,
+  id: string,
+): Promise {
+  try {
+    await client.requestBody("PATCH", `/notifications/threads/${enc(id)}`, {});
+    return { ok: true };
+  } catch (err) {
+    return { ok: false, message: err instanceof Error ? err.message : String(err) };
+  }
+}
+
+/**
+ * Mark EVERY notification in the user's inbox as read. PUT /notifications
+ * returns 202 (accepted; processed async on GitHub's side), so a re-list right
+ * after may briefly still show threads — the next refresh reconciles.
+ */
+export async function markAllNotificationsRead(
+  client: GitHubClient,
+): Promise {
+  try {
+    await client.requestBody("PUT", "/notifications", { read: true });
+    return { ok: true };
+  } catch (err) {
+    return { ok: false, message: err instanceof Error ? err.message : String(err) };
+  }
+}
+
+// ── 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
new file mode 100644
index 0000000..c4b85d5
--- /dev/null
+++ b/apps/desktop/src/main/github/orgs.ts
@@ -0,0 +1,123 @@
+// The Organizations section's GitHub logic (main process). Four pure, read-only
+// REST list calls — orgs the signed-in user belongs to, plus each org's repos,
+// teams, and members. These are USER-scoped (not repo-scoped), so the functions
+// take only the client; main.ts invokes them via `github.withClient((c) => …)`.
+//
+// All four THROW on API error (the client's request() throws a clean Error via
+// toError(): 401 → token invalid, 403 → permissions/rate-limit, 404 → not found),
+// so the renderer paints an errorState + Retry rather than a misleading empty
+// list. There are no mutations here, so the {ok,changed,message} result shape is
+// not used anywhere in this module.
+
+import { GitHubClient, enc } from "../githubClient";
+import type { OrgInfo, OrgMember, OrgRepo, OrgTeam } from "../../shared/ipc";
+
+// ── Raw GitHub shapes (only the fields we map) ────────────────────────────────
+
+/** `/user/orgs` returns the SHORT org object: no `html_url`/`name`, so we
+ *  synthesize the profile URL from the login in the mapper. */
+interface RawOrg {
+  login: string;
+  name?: string | null;
+  avatar_url?: string;
+  description?: string | null;
+}
+interface RawRepo {
+  name: string;
+  full_name: string;
+  html_url: string;
+  description?: string | null;
+  private?: boolean;
+  fork?: boolean;
+  archived?: boolean;
+  language?: string | null;
+  stargazers_count?: number;
+  pushed_at?: string;
+}
+interface RawTeam {
+  name: string;
+  slug: string;
+  description?: string | null;
+  privacy?: string;
+  html_url?: string;
+}
+interface RawMember {
+  login: string;
+  avatar_url?: string;
+  html_url?: string;
+}
+
+// ── Raw → public mappers ──────────────────────────────────────────────────────
+
+function mapOrg(o: RawOrg): OrgInfo {
+  return {
+    login: o.login,
+    name: o.name ?? null,
+    avatarUrl: o.avatar_url ?? null,
+    description: o.description ?? null,
+    htmlUrl: `https://github.com/${o.login}`,
+  };
+}
+function mapRepo(r: RawRepo): OrgRepo {
+  return {
+    name: r.name,
+    fullName: r.full_name,
+    htmlUrl: r.html_url,
+    description: r.description ?? null,
+    private: r.private ?? false,
+    fork: r.fork ?? false,
+    archived: r.archived ?? false,
+    language: r.language ?? null,
+    stargazersCount: r.stargazers_count ?? 0,
+    pushedAt: r.pushed_at ?? "",
+  };
+}
+function mapTeam(t: RawTeam): OrgTeam {
+  return {
+    name: t.name,
+    slug: t.slug,
+    description: t.description ?? null,
+    privacy: t.privacy ?? "",
+    htmlUrl: t.html_url ?? "",
+  };
+}
+function mapMember(m: RawMember): OrgMember {
+  return {
+    login: m.login,
+    avatarUrl: m.avatar_url ?? null,
+    htmlUrl: m.html_url ?? `https://github.com/${m.login}`,
+  };
+}
+
+// ── Read functions (user-scoped; throw on error) ─────────────────────────────
+
+/** 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`);
+  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",
+    `/orgs/${enc(org)}/repos?sort=pushed&direction=desc&per_page=100`,
+  );
+  return raw.map(mapRepo);
+}
+
+/** An org's teams. Requires `read:org` + org membership; non-members get a 403
+ *  that surfaces as the renderer's errorState + Retry. */
+export async function listOrgTeams(client: GitHubClient, org: string): Promise {
+  const raw = await client.request("GET", `/orgs/${enc(org)}/teams?per_page=100`);
+  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`);
+  return raw.map(mapMember);
+}
diff --git a/apps/desktop/src/main/github/projects.ts b/apps/desktop/src/main/github/projects.ts
new file mode 100644
index 0000000..86bdad0
--- /dev/null
+++ b/apps/desktop/src/main/github/projects.ts
@@ -0,0 +1,238 @@
+// Projects v2 (the GitHub "Projects" board) for the desktop app's GitHub section.
+//
+// Projects v2 has NO REST API — everything here goes through the client's
+// `graphql()` primitive, which already throws (via toError) on HTTP failure
+// AND on `json.errors[0].message`. So the READ functions carry NO local
+// try/catch: scope / rate-limit / network errors propagate to the renderer's
+// errorState (the throw-on-read convention). The MUTATIONS stay wrapped and
+// return a CommitActionResult-shaped object ({ ok, changed, message }).
+//
+// Repo-scoped reads take (client, owner, repo); the board read + the two
+// mutations act on opaque GraphQL node ids, so they take (client, …ids). main.ts
+// drives all of them through `github.withRepo((c, o, r) => …)`, which already
+// guards the not-connected / not-on-github.com cases before we are reached.
+
+import { GitHubClient } from "../githubClient";
+import type { CommitActionResult, ProjectBoard, ProjectInfo } from "../../shared/ipc";
+
+// ── Raw GraphQL shapes (this module owns its own Raw* interfaces + mappers) ────
+
+interface RawProjectsData {
+  repository?: {
+    projectsV2?: {
+      nodes?: ({
+        id: string;
+        number: number;
+        title: string;
+        shortDescription?: string | null;
+        url: string;
+        closed: boolean;
+        updatedAt?: string | null;
+        items?: { totalCount: number } | null;
+      } | null)[];
+    } | null;
+  } | null;
+}
+
+interface RawBoardField {
+  id: string;
+  name: string;
+  options?: ({ id: string; name: string; color?: string | null } | null)[] | null;
+}
+
+interface RawBoardContent {
+  __typename?: string;
+  number?: number | null;
+  title?: string | null;
+  url?: string | null;
+  state?: string | null;
+  author?: { login?: string | null } | null;
+}
+
+interface RawBoardItem {
+  id: string;
+  updatedAt?: string | null;
+  type?: string | null;
+  fieldValueByName?: { optionId?: string | null; name?: string | null } | null;
+  content?: RawBoardContent | null;
+}
+
+interface RawBoardData {
+  node?: {
+    field?: RawBoardField | null;
+    items?: { nodes?: (RawBoardItem | null)[] | null } | null;
+  } | null;
+}
+
+// ── Reads (THROW on error) ────────────────────────────────────────────────────
+
+/**
+ * The repo's GitHub Projects (v2), newest-updated first. Capped at the 20 most
+ * recently touched — the same magnitude as the PR/issue lists. Errors propagate.
+ */
+export async function listProjects(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+): Promise {
+  const data = await client.graphql(
+    `query($owner:String!,$repo:String!){
+      repository(owner:$owner,name:$repo){
+        projectsV2(first:20,orderBy:{field:UPDATED_AT,direction:DESC}){
+          nodes{ id number title shortDescription url closed updatedAt items{totalCount} }
+        }
+      }
+    }`,
+    { owner, repo },
+  );
+  const nodes = data?.repository?.projectsV2?.nodes ?? [];
+  return nodes
+    .filter((p): p is NonNullable => !!p)
+    .map((p) => ({
+      id: p.id,
+      number: p.number,
+      title: p.title,
+      shortDescription: p.shortDescription ?? "",
+      url: p.url,
+      itemCount: p.items?.totalCount ?? 0,
+      closed: p.closed,
+      updatedAt: p.updatedAt ?? "",
+    }));
+}
+
+/**
+ * A project's board: its "Status" single-select field (the columns) plus every
+ * item (cards) with its content + current Status value. Items are paged to the
+ * first 100, ordered by board POSITION so columns read top-to-bottom as they do
+ * on github.com. Errors propagate.
+ *
+ * The board read acts on an opaque ProjectV2 node id, so it ignores owner/repo —
+ * they're accepted to keep the (client, owner, repo, …args) shape main.ts wires
+ * through `github.withRepo`.
+ */
+export async function getProjectBoard(
+  client: GitHubClient,
+  _owner: string,
+  _repo: string,
+  projectId: string,
+): Promise {
+  const data = await client.graphql(
+    `query($id:ID!){
+      node(id:$id){
+        ... on ProjectV2 {
+          field(name:"Status"){
+            ... on ProjectV2SingleSelectField {
+              id name options{ id name color }
+            }
+          }
+          items(first:100,orderBy:{field:POSITION,direction:ASC}){
+            nodes{
+              id updatedAt type
+              fieldValueByName(name:"Status"){
+                ... on ProjectV2ItemFieldSingleSelectValue { optionId name }
+              }
+              content{
+                __typename
+                ... on Issue       { number title url state author{login} }
+                ... on PullRequest { number title url state author{login} }
+                ... on DraftIssue  { title }
+              }
+            }
+          }
+        }
+      }
+    }`,
+    { id: projectId },
+  );
+  const proj = data?.node;
+  const rawField = proj?.field;
+  const field: ProjectBoard["field"] = rawField
+    ? {
+        id: rawField.id,
+        name: rawField.name,
+        options: (rawField.options ?? [])
+          .filter((o): o is NonNullable => !!o)
+          .map((o) => ({ id: o.id, name: o.name, color: o.color ?? "" })),
+      }
+    : null;
+  const items = (proj?.items?.nodes ?? [])
+    .filter((n): n is RawBoardItem => !!n && !!n.content)
+    .map((n) => {
+      const c = n.content as RawBoardContent;
+      return {
+        id: n.id,
+        type: n.type ?? "",
+        title: c.title ?? "(untitled)",
+        number: typeof c.number === "number" ? c.number : null,
+        state: c.state ?? "",
+        url: c.url ?? null,
+        author: c.author?.login ?? "",
+        statusOptionId: n.fieldValueByName?.optionId ?? null,
+        statusName: n.fieldValueByName?.name ?? "",
+        updatedAt: n.updatedAt ?? "",
+      };
+    });
+  return { field, items };
+}
+
+// ── Mutations (return { ok, changed, message }) ───────────────────────────────
+
+/**
+ * Move an item to a Status option, or clear its Status when `optionId` is null.
+ * Clearing uses a different mutation (clearProjectV2ItemFieldValue) than setting
+ * (updateProjectV2ItemFieldValue). Needs the WRITE `project` scope; without it
+ * GitHub returns a 403 whose message we surface verbatim (no crash).
+ */
+export async function moveProjectItem(
+  client: GitHubClient,
+  _owner: string,
+  _repo: string,
+  req: { projectId: string; itemId: string; fieldId: string; optionId: string | null },
+): Promise {
+  try {
+    if (req.optionId === null) {
+      await client.graphql<{ clearProjectV2ItemFieldValue?: { clientMutationId?: string | null } }>(
+        `mutation($p:ID!,$i:ID!,$f:ID!){
+          clearProjectV2ItemFieldValue(input:{projectId:$p,itemId:$i,fieldId:$f}){ clientMutationId }
+        }`,
+        { p: req.projectId, i: req.itemId, f: req.fieldId },
+      );
+    } else {
+      await client.graphql<{ updateProjectV2ItemFieldValue?: { projectV2Item?: { id: string } | null } }>(
+        `mutation($p:ID!,$i:ID!,$f:ID!,$o:String!){
+          updateProjectV2ItemFieldValue(input:{
+            projectId:$p,itemId:$i,fieldId:$f,value:{singleSelectOptionId:$o}
+          }){ projectV2Item{ id } }
+        }`,
+        { p: req.projectId, i: req.itemId, f: req.fieldId, o: req.optionId },
+      );
+    }
+    return { ok: true, changed: true };
+  } catch (err) {
+    return { ok: false, changed: false, message: err instanceof Error ? err.message : String(err) };
+  }
+}
+
+/**
+ * Add an existing issue/PR (by its content node id) to a project. v1.1 scaffold:
+ * fully wired end to end so a future "Add item" affordance only needs a content
+ * id. Needs the WRITE `project` scope.
+ */
+export async function addProjectItem(
+  client: GitHubClient,
+  _owner: string,
+  _repo: string,
+  req: { projectId: string; contentId: string },
+): Promise {
+  try {
+    await client.graphql<{ addProjectV2ItemById?: { item?: { id: string } | null } }>(
+      `mutation($p:ID!,$c:ID!){
+        addProjectV2ItemById(input:{projectId:$p,contentId:$c}){ item{ id } }
+      }`,
+      { p: req.projectId, c: req.contentId },
+    );
+    return { ok: true, changed: true };
+  } catch (err) {
+    return { ok: false, changed: false, message: err instanceof Error ? err.message : String(err) };
+  }
+}
diff --git a/apps/desktop/src/main/github/prs.ts b/apps/desktop/src/main/github/prs.ts
new file mode 100644
index 0000000..d39a700
--- /dev/null
+++ b/apps/desktop/src/main/github/prs.ts
@@ -0,0 +1,651 @@
+// GitHub pull-request write actions + the Create-PR support reads.
+//
+// These standalone functions are the section's GitHub logic. They are invoked
+// from main.ts through the bridge's `withRepo` helper, which supplies an
+// authenticated `GitHubClient` plus the resolved owner/repo. Following the
+// section convention:
+//   • READ helpers (branches / reviewers) THROW on a real API error so the
+//     renderer can surface an errorState; they only degrade to a benign empty
+//     result for the "no collaborator access" sub-case, where a free-text
+//     fallback is the right UX.
+//   • MUTATIONS never throw: they return a CommitActionResult-shaped object
+//     ({ ok, changed, message }). `changed` is ALWAYS false here — none of these
+//     PR API writes touch the local working tree (unlike pr:checkout), so the
+//     graph/sync widgets must NOT refresh off them.
+//
+// We reuse the client's PUBLIC primitives (request / requestBody / graphql) and
+// the shared `enc` / `mapUser` helpers; the Raw* shapes + mappers we need that
+// the client keeps private (RawPull/mapPull) are redefined locally so this
+// module is self-contained.
+
+import { GitHubClient, enc, mapUser, RawUser } from "../githubClient";
+import type {
+  BranchRef,
+  CommitActionResult,
+  CreatePrRequest,
+  FileDiff,
+  PrPrefill,
+  PrReviewComment,
+  PrReviewRequest,
+  PrReviewThread,
+  PullRequest,
+  RepoCollaborator,
+  RepoLabel,
+} from "../../shared/ipc";
+
+// ── 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;
+}
+interface RawRepoMeta {
+  default_branch?: string;
+}
+interface RawLabel {
+  name: string;
+  color: string;
+  description: string | null;
+}
+/** The base64 file-contents payload from GET /contents/{path}. */
+interface RawContents {
+  content?: string;
+  encoding?: string;
+  /** Bytes; present for blobs. GitHub stops inlining content for very large files. */
+  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);
+}
+
+/** Resolve a PR's base + head commit SHAs (anchors for diffs + review comments). */
+async function prRefs(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  n: number,
+): Promise<{ baseSha: string; headSha: string }> {
+  const p = await client.request("GET", `/repos/${enc(owner)}/${enc(repo)}/pulls/${n}`);
+  return { baseSha: p.base.sha, headSha: p.head.sha };
+}
+
+/**
+ * The text of a file at a given ref via the Contents API, or "" when the path
+ * doesn't exist on that side (404 = added/removed) or can't be read as text.
+ *
+ *  • 404 → "" (the file was added on head / deleted on base — that side is empty).
+ *  • Over GitHub's ~1MB inline cap (no `content` returned) → a short placeholder
+ *    so the diff degrades gracefully instead of throwing.
+ *  • A real network / auth error still throws (the caller is a READ → surfaces an
+ *    errorState).
+ */
+async function fileTextAt(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  path: string,
+  ref: string,
+): Promise {
+  // Hard cap: never decode more than ~2MB of base64 into the renderer.
+  const MAX_BYTES = 2 * 1024 * 1024;
+  let raw: RawContents;
+  try {
+    raw = await client.request(
+      "GET",
+      `/repos/${enc(owner)}/${enc(repo)}/contents/${path.split("/").map(enc).join("/")}?ref=${enc(ref)}`,
+    );
+  } catch (err) {
+    // The Contents API 404s when the path is absent at this ref — that's the
+    // "added on one side / removed on the other" case, so the side is empty.
+    if (/not found/i.test(errMessage(err))) return "";
+    throw err;
+  }
+  if (typeof raw.size === "number" && raw.size > MAX_BYTES) {
+    return `// File too large to display (${Math.round(raw.size / 1024)} KB).`;
+  }
+  if (raw.encoding !== "base64" || !raw.content) {
+    // No inlined content (oversized blob or a non-file entry) — degrade cleanly.
+    return raw.content ? raw.content : "// Diff not available for this file.";
+  }
+  try {
+    const text = Buffer.from(raw.content, "base64").toString("utf8");
+    // A NUL byte means binary — Monaco would render mojibake, so blank it.
+    if (text.includes(String.fromCharCode(0))) return "// Binary file not shown.";
+    return text;
+  } catch {
+    return "// Diff not available for this file.";
+  }
+}
+
+// ── Mutations (never throw; return CommitActionResult) ────────────────────────
+
+/**
+ * Create a pull request. On success the message carries the new PR number
+ * (e.g. "#42") so the renderer can name it in the success toast.
+ */
+export async function prCreate(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: CreatePrRequest,
+): Promise {
+  try {
+    const raw = await client.request("POST", `/repos/${enc(owner)}/${enc(repo)}/pulls`, {
+      title: req.title,
+      head: req.head,
+      base: req.base,
+      body: req.body ?? "",
+      draft: req.draft ?? false,
+    });
+    return { ok: true, changed: false, message: `#${mapPull(raw).number}` };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/** Add an issue comment to the PR's conversation (PRs share the issues endpoint). */
+export async function prComment(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { number: number; body: string },
+): Promise {
+  try {
+    await client.requestBody("POST", `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}/comments`, {
+      body: req.body,
+    });
+    return { ok: true, changed: false };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/**
+ * Submit a review: REQUEST_CHANGES | COMMENT (APPROVE keeps flowing through the
+ * existing pr:approve path). GitHub requires a non-empty body for both events
+ * here — the renderer enforces that before calling; a 422 surfaces verbatim.
+ */
+export async function prReview(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: PrReviewRequest,
+): Promise {
+  try {
+    await client.requestBody("POST", `/repos/${enc(owner)}/${enc(repo)}/pulls/${req.number}/reviews`, {
+      event: req.event,
+      ...(req.body ? { body: req.body } : {}),
+    });
+    return { ok: true, changed: false };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/** Close or reopen a PR (PATCH state on the pulls resource). */
+export async function prSetState(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { number: number; state: "open" | "closed" },
+): Promise {
+  try {
+    await client.requestBody("PATCH", `/repos/${enc(owner)}/${enc(repo)}/pulls/${req.number}`, {
+      state: req.state,
+    });
+    return { ok: true, changed: false };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/** Request reviewers for a PR. 422s on non-collaborators / the PR author. */
+export async function prRequestReviewers(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { number: number; reviewers: string[] },
+): Promise {
+  try {
+    await client.requestBody(
+      "POST",
+      `/repos/${enc(owner)}/${enc(repo)}/pulls/${req.number}/requested_reviewers`,
+      { reviewers: req.reviewers },
+    );
+    return { ok: true, changed: false };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/**
+ * Convert a draft PR to ready-for-review. GitHub exposes this ONLY via GraphQL,
+ * so we first resolve the PR node id, then run the mutation.
+ */
+export async function prMarkReady(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  n: number,
+): Promise {
+  try {
+    const data = await client.graphql<{ repository?: { pullRequest?: { id: string } } }>(
+      `query($owner:String!,$repo:String!,$n:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$n){id}}}`,
+      { owner, repo, n },
+    );
+    const id = data?.repository?.pullRequest?.id;
+    if (!id) {
+      return { ok: false, changed: false, message: "Couldn't resolve the pull request to mark ready." };
+    }
+    await client.graphql(
+      `mutation($id:ID!){markPullRequestReadyForReview(input:{pullRequestId:$id}){pullRequest{number}}}`,
+      { id },
+    );
+    return { ok: true, changed: false };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+// ── Create-PR support reads (throw on API error) ──────────────────────────────
+
+/**
+ * All branches in the repo (head/base selectors), flagged with the default.
+ * Single page of 100, matching the rest of the client's pagination convention.
+ */
+export async function prBranches(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+): Promise {
+  const [branches, def] = await Promise.all([
+    client.request("GET", `/repos/${enc(owner)}/${enc(repo)}/branches?per_page=100`),
+    client
+      .request("GET", `/repos/${enc(owner)}/${enc(repo)}`)
+      .then((m) => m.default_branch ?? "main")
+      .catch(() => "main"),
+  ]);
+  return branches.map((b) => ({ name: b.name, isDefault: b.name === def }));
+}
+
+/**
+ * Collaborators with push access — the candidate set for "Request reviewers".
+ * This GET 403s for non-admins on some repos; we degrade to [] so the renderer
+ * can fall back to a free-text login list rather than erroring the whole flow.
+ */
+export async function prReviewers(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+): Promise {
+  try {
+    const raw = await client.request(
+      "GET",
+      `/repos/${enc(owner)}/${enc(repo)}/collaborators?per_page=100`,
+    );
+    return raw.map((u) => ({ login: u.login, avatarUrl: u.avatar_url ?? null }));
+  } catch {
+    return [];
+  }
+}
+
+// ── PR review depth: per-file diffs + inline threads + metadata reads ──────────
+
+/**
+ * A single file's two sides for the shared 2-pane DiffView: the file's content
+ * at the PR base vs. at the PR head. We anchor on the PR's commit SHAs (stable
+ * even when the head branch isn't a local ref) and read each side through the
+ * Contents API. A side that 404s (added on head / removed on base) comes back as
+ * "" so the diff still renders one-sided. Throws on a real API failure so the
+ * renderer can show an errorState with Retry.
+ */
+export async function fileDiff(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { number: number; path: string },
+): Promise {
+  const { baseSha, headSha } = await prRefs(client, owner, repo, req.number);
+  const [leftText, rightText] = await Promise.all([
+    fileTextAt(client, owner, repo, req.path, baseSha),
+    fileTextAt(client, owner, repo, req.path, headSha),
+  ]);
+  return {
+    path: req.path,
+    leftLabel: "base",
+    rightLabel: "head",
+    leftText,
+    rightText,
+    conflicted: false,
+  };
+}
+
+// ── GraphQL shapes for review threads ──
+interface RawThreadsData {
+  repository?: {
+    pullRequest?: {
+      reviewThreads?: {
+        nodes?: {
+          id: string;
+          path: string | null;
+          line: number | null;
+          isResolved: boolean;
+          isOutdated: boolean;
+          comments?: {
+            nodes?: {
+              id: string;
+              author?: { login?: string; avatarUrl?: string; url?: string } | null;
+              body: string;
+              createdAt: string;
+            }[];
+          };
+        }[];
+      };
+    };
+  };
+}
+
+/**
+ * The PR's inline review threads (each anchored to a file + line), with their
+ * comments. GitHub exposes review threads + their resolution state ONLY via
+ * GraphQL, so we query there. Throws on a real API error (READ → errorState).
+ */
+export async function reviewThreads(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  number: number,
+): Promise {
+  const data = await client.graphql(
+    `query($owner:String!,$repo:String!,$n:Int!){
+      repository(owner:$owner,name:$repo){
+        pullRequest(number:$n){
+          reviewThreads(first:100){
+            nodes{
+              id path line isResolved isOutdated
+              comments(first:50){nodes{id author{login avatarUrl url} body createdAt}}
+            }
+          }
+        }
+      }
+    }`,
+    { owner, repo, n: number },
+  );
+  const nodes = data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
+  return nodes.map((t) => {
+    const comments: PrReviewComment[] = (t.comments?.nodes ?? []).map((c) => ({
+      id: c.id,
+      author: { login: c.author?.login ?? "ghost", avatarUrl: c.author?.avatarUrl ?? null },
+      body: c.body ?? "",
+      createdAt: c.createdAt ?? "",
+    }));
+    return {
+      id: t.id,
+      path: t.path ?? "",
+      line: t.line ?? null,
+      isResolved: t.isResolved,
+      isOutdated: t.isOutdated,
+      comments,
+    };
+  });
+}
+
+/**
+ * The repo's labels — the option set for the PR's "Labels" picker. Single page
+ * of 100, matching the client's pagination convention. Throws on API error.
+ */
+export async function labels(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+): Promise {
+  const raw = await client.request(
+    "GET",
+    `/repos/${enc(owner)}/${enc(repo)}/labels?per_page=100`,
+  );
+  return raw.map((l) => ({ name: l.name, color: l.color, description: l.description ?? null }));
+}
+
+/**
+ * Prefill for the "Create PR from current branch" flow: the repo's default
+ * branch, used as the base. The head (the current local branch) is filled in by
+ * the renderer, so we leave `headRef` undefined. Kept deliberately simple — a
+ * single cheap repo-meta read; degrades to "main" if it can't be resolved.
+ */
+export async function prefill(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+): Promise {
+  const baseRef = await client
+    .request("GET", `/repos/${enc(owner)}/${enc(repo)}`)
+    .then((m) => m.default_branch ?? "main")
+    .catch(() => "main");
+  return { baseRef };
+}
+
+// ── PR review depth: inline review + metadata mutations (never throw) ──────────
+
+/**
+ * Add a single inline review comment on the PR's head commit at path+line. Side
+ * defaults to RIGHT (the head/new side), which is what "comment on this line of
+ * the diff" means. Returns a CommitActionResult — a 422 (e.g. line not part of
+ * the diff) surfaces verbatim.
+ */
+export async function addReviewComment(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { number: number; path: string; line: number; side?: "LEFT" | "RIGHT"; body: string },
+): Promise {
+  try {
+    const { headSha } = await prRefs(client, owner, repo, req.number);
+    await client.requestBody(
+      "POST",
+      `/repos/${enc(owner)}/${enc(repo)}/pulls/${req.number}/comments`,
+      {
+        body: req.body,
+        commit_id: headSha,
+        path: req.path,
+        line: req.line,
+        side: req.side ?? "RIGHT",
+      },
+    );
+    return { ok: true, changed: false };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/**
+ * Reply to an existing review thread. GitHub's REST reply endpoint needs the
+ * root comment's numeric id, which we don't carry; the thread's node id is what
+ * the renderer has, so we use the GraphQL reply mutation (anchored by thread id).
+ */
+export async function replyThread(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { number: number; threadId: string; body: string },
+): Promise {
+  try {
+    await client.graphql(
+      `mutation($threadId:ID!,$body:String!){
+        addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){
+          comment{id}
+        }
+      }`,
+      { threadId: req.threadId, body: req.body },
+    );
+    return { ok: true, changed: false };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/** Resolve or unresolve a review thread (GraphQL — no REST equivalent). */
+export async function resolveThread(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { threadId: string; resolved: boolean },
+): Promise {
+  void owner;
+  void repo;
+  const field = req.resolved ? "resolveReviewThread" : "unresolveReviewThread";
+  try {
+    await client.graphql(
+      `mutation($threadId:ID!){
+        ${field}(input:{threadId:$threadId}){thread{id isResolved}}
+      }`,
+      { threadId: req.threadId },
+    );
+    return { ok: true, changed: false };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/** Edit a PR's title and/or body (PATCH on the pulls resource). */
+export async function edit(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { number: number; title?: string; body?: string },
+): Promise {
+  try {
+    const patch: Record = {};
+    if (req.title !== undefined) patch.title = req.title;
+    if (req.body !== undefined) patch.body = req.body;
+    await client.requestBody("PATCH", `/repos/${enc(owner)}/${enc(repo)}/pulls/${req.number}`, patch);
+    return { ok: true, changed: false };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/**
+ * Set the PR's labels (PRs share the issues endpoint). PUT replaces the full
+ * set, so the renderer passes the desired final list.
+ */
+export async function setLabels(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { number: number; labels: string[] },
+): Promise {
+  try {
+    await client.requestBody(
+      "PUT",
+      `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}/labels`,
+      { labels: req.labels },
+    );
+    return { ok: true, changed: false };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/**
+ * Reconcile the PR's assignees to exactly `req.assignees`. GitHub's assignees
+ * API is additive/subtractive (no "set"), so we read the current assignees,
+ * compute the add/remove deltas, and issue only the calls that are needed. PRs
+ * share the issues endpoint here.
+ */
+export async function setAssignees(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  req: { number: number; assignees: string[] },
+): Promise {
+  try {
+    const issue = await client.request<{ assignees?: { login: string }[] }>(
+      "GET",
+      `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}`,
+    );
+    const current = new Set((issue.assignees ?? []).map((a) => a.login));
+    const wanted = new Set(req.assignees);
+    const toAdd = [...wanted].filter((l) => !current.has(l));
+    const toRemove = [...current].filter((l) => !wanted.has(l));
+    if (toAdd.length) {
+      await client.requestBody(
+        "POST",
+        `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}/assignees`,
+        { assignees: toAdd },
+      );
+    }
+    if (toRemove.length) {
+      // DELETE with a body — the client's request() sends the JSON body for any verb.
+      await client.request(
+        "DELETE",
+        `/repos/${enc(owner)}/${enc(repo)}/issues/${req.number}/assignees`,
+        { assignees: toRemove },
+      );
+    }
+    return { ok: true, changed: false };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
+
+/**
+ * Update the PR's branch by merging the latest base into it (the "Update branch"
+ * button). 422s when the branch is already up to date or can't be updated — the
+ * message surfaces so the renderer can toast it.
+ */
+export async function updateBranch(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  number: number,
+): Promise {
+  try {
+    await client.requestBody("PUT", `/repos/${enc(owner)}/${enc(repo)}/pulls/${number}/update-branch`, {});
+    return { ok: true, changed: false };
+  } catch (err) {
+    return { ok: false, changed: false, message: errMessage(err) };
+  }
+}
diff --git a/apps/desktop/src/main/github/releases.ts b/apps/desktop/src/main/github/releases.ts
new file mode 100644
index 0000000..6233be7
--- /dev/null
+++ b/apps/desktop/src/main/github/releases.ts
@@ -0,0 +1,218 @@
+// GitHub Releases — the section's main-process logic. These are standalone
+// async functions called from main.ts via `github.withRepo((c, o, r) => …)`,
+// so each repo-scoped function takes (client, owner, repo, …args). Reads THROW
+// on API failure (the renderer catches → errorState + Retry); mutations return a
+// CommitActionResult-shaped object ({ ok, changed, message? }) so the renderer
+// can toast success/error without unwrapping exceptions.
+//
+// All-REST: the Releases REST API is complete (list/get/create/update/delete +
+// assets inline in the payload), so no GraphQL is needed here. Read+write live
+// under the `repo` scope the OAuth token already holds; a read-only token
+// surfaces a 403 from the mutation calls as a normal error message.
+
+import { GitHubClient, enc, mapUser, type RawUser } from "../githubClient";
+import type {
+  CommitActionResult,
+  ReleaseInfo,
+  ReleaseInput,
+  TagInfo,
+} from "../../shared/ipc";
+
+// ── Raw GitHub payload shapes (snake_case) → mapped to the public camelCase ──
+
+interface RawReleaseAsset {
+  id: number;
+  name: string;
+  label: string | null;
+  content_type: string;
+  size: number;
+  download_count: number;
+  browser_download_url: string;
+  created_at: string;
+  updated_at: string;
+}
+
+interface RawRelease {
+  id: number;
+  tag_name: string;
+  target_commitish: string;
+  name: string | null;
+  body: string | null;
+  draft: boolean;
+  prerelease: boolean;
+  html_url: string;
+  author: RawUser | null;
+  created_at: string;
+  published_at: string | null;
+  assets?: RawReleaseAsset[];
+}
+
+interface RawTag {
+  name: string;
+  commit?: { sha?: string };
+}
+
+function mapRelease(r: RawRelease): ReleaseInfo {
+  return {
+    id: r.id,
+    tagName: r.tag_name,
+    targetCommitish: r.target_commitish ?? "",
+    // Keep the RAW name ("" for tag-only releases); the view applies the
+    // tag fallback only for DISPLAY, so editing never overwrites an empty title.
+    name: r.name ?? "",
+    body: r.body,
+    draft: r.draft,
+    prerelease: r.prerelease,
+    htmlUrl: r.html_url,
+    author: mapUser(r.author),
+    createdAt: r.created_at,
+    publishedAt: r.published_at,
+    assets: (r.assets ?? []).map((a) => ({
+      id: a.id,
+      name: a.name,
+      label: a.label,
+      contentType: a.content_type,
+      size: a.size,
+      downloadCount: a.download_count,
+      downloadUrl: a.browser_download_url,
+      createdAt: a.created_at,
+      updatedAt: a.updated_at,
+    })),
+  };
+}
+
+// ── Reads (THROW on error) ──
+
+/** List the latest 50 releases (newest-first, the GitHub default order). */
+export async function listReleases(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+): Promise {
+  const raw = await client.request(
+    "GET",
+    `/repos/${enc(owner)}/${enc(repo)}/releases?per_page=50`,
+  );
+  return raw.map(mapRelease);
+}
+
+/** Fetch a single release fresh, so its body + assets are complete. */
+export async function getRelease(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  id: number,
+): Promise {
+  const raw = await client.request(
+    "GET",
+    `/repos/${enc(owner)}/${enc(repo)}/releases/${id}`,
+  );
+  return mapRelease(raw);
+}
+
+/** List every git tag in the repo (raw tags, distinct from releases). */
+export async function listTags(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+): Promise {
+  const raw = await client.request(
+    "GET",
+    `/repos/${enc(owner)}/${enc(repo)}/tags?per_page=100`,
+  );
+  return raw.map((t) => ({ name: t.name, sha: t.commit?.sha ?? "" }));
+}
+
+// ── Mutations (return CommitActionResult) ──
+
+/**
+ * Draft or publish a release. An empty `targetCommitish` is sent as `undefined`
+ * so GitHub uses the repo's default branch rather than erroring on "". If the
+ * tag doesn't exist yet, GitHub auto-creates it at the target commitish.
+ */
+export async function createRelease(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  input: ReleaseInput,
+): Promise {
+  try {
+    await client.requestBody("POST", `/repos/${enc(owner)}/${enc(repo)}/releases`, {
+      tag_name: input.tagName,
+      target_commitish: input.targetCommitish || undefined,
+      // A new release with no title sensibly defaults to the tag.
+      name: input.name || input.tagName,
+      body: input.body ?? "",
+      draft: input.draft ?? false,
+      prerelease: input.prerelease ?? false,
+    });
+    return { ok: true, changed: true };
+  } catch (err) {
+    return {
+      ok: false,
+      changed: false,
+      message: err instanceof Error ? err.message : String(err),
+    };
+  }
+}
+
+/** Edit a release (also how a draft is published: send draft:false). */
+export async function updateRelease(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  input: ReleaseInput,
+): Promise {
+  if (input.id === undefined) {
+    return { ok: false, changed: false, message: "Missing release id." };
+  }
+  try {
+    await client.requestBody(
+      "PATCH",
+      `/repos/${enc(owner)}/${enc(repo)}/releases/${input.id}`,
+      {
+        tag_name: input.tagName,
+        target_commitish: input.targetCommitish || undefined,
+        // Send the raw name (incl. "") so an emptied title clears it rather than
+        // being silently overwritten with the tag.
+        name: input.name ?? "",
+        body: input.body ?? "",
+        draft: input.draft ?? false,
+        prerelease: input.prerelease ?? false,
+      },
+    );
+    return { ok: true, changed: true };
+  } catch (err) {
+    return {
+      ok: false,
+      changed: false,
+      message: err instanceof Error ? err.message : String(err),
+    };
+  }
+}
+
+/**
+ * Delete a release (does NOT delete the underlying git tag — GitHub has no REST
+ * endpoint for that here; it's a `git push --delete` operation, out of scope).
+ * Uses `request` (no body) since DELETE /releases/{id} returns 204 with no body.
+ */
+export async function deleteRelease(
+  client: GitHubClient,
+  owner: string,
+  repo: string,
+  id: number,
+): Promise {
+  try {
+    await client.request(
+      "DELETE",
+      `/repos/${enc(owner)}/${enc(repo)}/releases/${id}`,
+    );
+    return { ok: true, changed: true };
+  } catch (err) {
+    return {
+      ok: false,
+      changed: false,
+      message: err instanceof Error ? err.message : String(err),
+    };
+  }
+}
diff --git a/apps/desktop/src/main/githubAuth.ts b/apps/desktop/src/main/githubAuth.ts
new file mode 100644
index 0000000..6a0307f
--- /dev/null
+++ b/apps/desktop/src/main/githubAuth.ts
@@ -0,0 +1,104 @@
+// GitHub OAuth Device Authorization Flow (RFC 8628) for the desktop app.
+//
+// This is the same model GitKraken / GitHub Desktop / the `gh` CLI use — an
+// OAuth App — but in the secret-less "device flow" variant, which is the only
+// option safe for distributed, no-backend desktop code: it sends ONLY the
+// public Client ID, never a client secret. The user authorizes a short code at
+// github.com/login/device and we poll until GitHub hands back a user token.
+//
+// These two endpoints live on github.com (NOT api.github.com) and must be asked
+// for JSON explicitly. All calls run in the MAIN process (no CORS, no secret in
+// the renderer).
+
+/** Public OAuth App Client ID — safe to embed (the secret is intentionally unused). */
+export const GITHUB_CLIENT_ID = "Ov23lizWuHbYyvQhkmwu";
+
+/**
+ * Scopes requested at sign-in. `repo` unlocks code/PRs/issues/statuses/releases;
+ * `workflow` unlocks GitHub Actions control; `read:org` exposes org repos/teams;
+ * `gist` + `notifications` power those surfaces. Widen/narrow here as features land.
+ */
+export const GITHUB_SCOPES = "repo workflow read:org gist notifications project";
+
+const DEVICE_CODE_URL = "https://github.com/login/device/code";
+const TOKEN_URL = "https://github.com/login/oauth/access_token";
+
+export interface DeviceCode {
+  deviceCode: string;
+  userCode: string;
+  verificationUri: string;
+  verificationUriComplete?: string;
+  expiresIn: number;
+  /** Minimum seconds between polls (GitHub default 5). */
+  interval: number;
+}
+
+export type PollResult =
+  | { state: "authorized"; accessToken: string; scope: string }
+  | { state: "pending" | "slow_down" | "denied" | "expired" | "error"; message?: string };
+
+/** Step 1: ask GitHub for a device + user code. */
+export async function requestDeviceCode(): Promise {
+  const res = await fetch(DEVICE_CODE_URL, {
+    method: "POST",
+    headers: {
+      Accept: "application/json",
+      "Content-Type": "application/json",
+      "User-Agent": "GitStudio",
+    },
+    body: JSON.stringify({ client_id: GITHUB_CLIENT_ID, scope: GITHUB_SCOPES }),
+  });
+  const j = (await res.json()) as Record;
+  if (!res.ok || j.error) {
+    throw new Error(String(j.error_description || j.error || `GitHub returned ${res.status}.`));
+  }
+  return {
+    deviceCode: String(j.device_code),
+    userCode: String(j.user_code),
+    verificationUri: String(j.verification_uri),
+    verificationUriComplete: j.verification_uri_complete
+      ? String(j.verification_uri_complete)
+      : undefined,
+    expiresIn: Number(j.expires_in) || 900,
+    interval: Number(j.interval) || 5,
+  };
+}
+
+/**
+ * Step 2: poll once for the access token. The renderer drives the cadence and
+ * widens it on `slow_down`. Returns a discriminated result the bridge maps to IPC.
+ */
+export async function pollForToken(deviceCode: string): Promise {
+  const res = await fetch(TOKEN_URL, {
+    method: "POST",
+    headers: {
+      Accept: "application/json",
+      "Content-Type": "application/json",
+      "User-Agent": "GitStudio",
+    },
+    body: JSON.stringify({
+      client_id: GITHUB_CLIENT_ID,
+      device_code: deviceCode,
+      grant_type: "urn:ietf:params:oauth:grant-type:device_code",
+    }),
+  });
+  const j = (await res.json()) as Record;
+  if (j.access_token) {
+    return { state: "authorized", accessToken: String(j.access_token), scope: String(j.scope || "") };
+  }
+  switch (j.error) {
+    case "authorization_pending":
+      return { state: "pending" };
+    case "slow_down":
+      return { state: "slow_down" };
+    case "expired_token":
+      return { state: "expired", message: "The code expired before you authorized. Start again." };
+    case "access_denied":
+      return { state: "denied", message: "Sign-in was cancelled." };
+    default:
+      return {
+        state: "error",
+        message: String(j.error_description || j.error || "Sign-in failed."),
+      };
+  }
+}
diff --git a/apps/desktop/src/main/githubBridge.ts b/apps/desktop/src/main/githubBridge.ts
new file mode 100644
index 0000000..f8be2c0
--- /dev/null
+++ b/apps/desktop/src/main/githubBridge.ts
@@ -0,0 +1,357 @@
+// The desktop's GitHub layer: PAT-based auth (encrypted at rest via Electron
+// safeStorage), owner/repo resolution from the active repo's `origin` remote,
+// and thin wrappers over GitHubClient for the PRs / Issues / Projects views.
+// OAuth device flow can be layered on later behind the same `status/connect`
+// surface; the renderer only knows about connect/disconnect + the data calls.
+
+import { app, safeStorage } from "electron";
+import { readFile, writeFile, unlink } from "node:fs/promises";
+import { join } from "node:path";
+import { GitHubClient } from "./githubClient";
+import { requestDeviceCode, pollForToken } from "./githubAuth";
+import type { RepoStore } from "./repoStore";
+import type {
+  CheckRun,
+  CommitActionResult,
+  DeviceCodeInfo,
+  DevicePollResult,
+  ExternalItemDetail,
+  GitHubStatus,
+  IssueInfo,
+  MergeMethod,
+  PrComment,
+  PrCommitInfo,
+  PrDetail,
+  ProjectInfo,
+  PullRequest,
+  WorkflowRun,
+} from "../shared/ipc";
+
+export class GitHubBridge {
+  private token: string | undefined;
+  private login: string | undefined;
+  private loaded = false;
+  private readonly client = new GitHubClient(() => this.token);
+  private ownerRepoRoot: string | undefined;
+  private cachedOwnerRepo: { owner: string; repo: string } | undefined;
+
+  constructor(private readonly repos: RepoStore) {}
+
+  private tokenPath(): string {
+    return join(app.getPath("userData"), "github-token.bin");
+  }
+
+  private async ensureLoaded(): Promise {
+    if (this.loaded) {
+      return;
+    }
+    this.loaded = true;
+    // Only ever read a token back when the OS can decrypt it. We never persist
+    // a plaintext token (see persistToken), so a non-decryptable file is junk.
+    if (!safeStorage.isEncryptionAvailable()) {
+      return;
+    }
+    try {
+      const buf = await readFile(this.tokenPath());
+      this.token = safeStorage.decryptString(buf);
+    } catch {
+      // no stored token, or it can't be decrypted on this machine
+    }
+  }
+
+  /** Resolve owner/repo from `git remote get-url origin` (cached per repo root). */
+  private async resolveOwnerRepo(): Promise<{ owner: string; repo: string } | undefined> {
+    const ctx = this.repos.getContext();
+    if (!ctx) {
+      return undefined;
+    }
+    if (this.ownerRepoRoot === ctx.root) {
+      return this.cachedOwnerRepo;
+    }
+    let url = "";
+    try {
+      const r = await ctx.process.run(["remote", "get-url", "origin"]);
+      url = r.stdout.trim();
+    } catch {
+      url = "";
+    }
+    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;
+  }
+
+  async status(): Promise {
+    await this.ensureLoaded();
+    const repo = await this.resolveOwnerRepo();
+    if (!this.token) {
+      return { connected: false, repo };
+    }
+    if (!this.login) {
+      this.login = await this.client.currentLogin();
+    }
+    return { connected: !!this.login, login: this.login, repo };
+  }
+
+  async connect(pat: string): Promise<{ ok: boolean; login?: string; message?: string }> {
+    this.token = pat.trim();
+    this.login = await this.client.currentLogin();
+    if (!this.login) {
+      this.token = undefined;
+      return { ok: false, message: "That token didn't work — make sure it has 'repo' scope." };
+    }
+    await this.persistToken(this.token);
+    this.loaded = true;
+    return { ok: true, login: this.login };
+  }
+
+  /** Encrypt + persist the user token at rest (best-effort). */
+  private async persistToken(token: string): Promise {
+    // No OS-level encryption (e.g. a headless Linux box with no keyring)? Never
+    // write the token in cleartext — keep it in memory for this session only.
+    if (!safeStorage.isEncryptionAvailable()) {
+      return;
+    }
+    try {
+      const data = safeStorage.encryptString(token);
+      // Owner-only perms on the (encrypted) blob as a second layer.
+      await writeFile(this.tokenPath(), data, { mode: 0o600 });
+    } catch {
+      // best-effort persistence; the in-memory token still works this session
+    }
+  }
+
+  /** Device Flow step 1: request a user code to show in the sign-in panel. */
+  async deviceStart(): Promise {
+    try {
+      const dc = await requestDeviceCode();
+      return {
+        ok: true,
+        userCode: dc.userCode,
+        verificationUri: dc.verificationUri,
+        verificationUriComplete: dc.verificationUriComplete,
+        deviceCode: dc.deviceCode,
+        interval: dc.interval,
+        expiresIn: dc.expiresIn,
+      };
+    } catch (err) {
+      return { ok: false, message: err instanceof Error ? err.message : String(err) };
+    }
+  }
+
+  /** Device Flow step 2: poll once; on authorization, store the token + login. */
+  async devicePoll(req: { deviceCode: string }): Promise {
+    let r;
+    try {
+      r = await pollForToken(req.deviceCode);
+    } catch (err) {
+      return { state: "error", message: err instanceof Error ? err.message : String(err) };
+    }
+    if (r.state !== "authorized") {
+      return { state: r.state, message: r.message };
+    }
+    this.token = r.accessToken;
+    this.login = await this.client.currentLogin();
+    if (!this.login) {
+      this.token = undefined;
+      return { state: "error", message: "Signed in, but GitHub didn't return a user." };
+    }
+    await this.persistToken(r.accessToken);
+    this.loaded = true;
+    return { state: "authorized", login: this.login };
+  }
+
+  async disconnect(): Promise {
+    this.token = undefined;
+    this.login = undefined;
+    try {
+      await unlink(this.tokenPath());
+    } catch {
+      // already gone
+    }
+  }
+
+  async prList(): Promise {
+    const r = await this.resolveOwnerRepo();
+    if (!r || !this.token) {
+      return [];
+    }
+    // Let API errors (rate limit / auth / network) propagate so the renderer can
+    // show a real error state instead of a misleading "no pull requests".
+    return this.client.listOpenPulls(r.owner, r.repo);
+  }
+
+  /**
+   * Run `fn` with the resolved owner/repo + the client, for the per-section
+   * modules under ./github. Throws a clean error (→ renderer errorState) when
+   * not connected or the repo isn't on github.com.
+   */
+  async withRepo(
+    fn: (client: GitHubClient, owner: string, repo: string) => Promise,
+  ): Promise {
+    if (!this.token) {
+      throw new Error("Not connected to GitHub.");
+    }
+    const r = await this.resolveOwnerRepo();
+    if (!r) {
+      throw new Error("This repository isn't on github.com.");
+    }
+    return fn(this.client, r.owner, r.repo);
+  }
+
+  /** Run `fn` with just the client (user-level endpoints: orgs, gists, notifications). */
+  async withClient(fn: (client: GitHubClient) => Promise): Promise {
+    if (!this.token) {
+      throw new Error("Not connected to GitHub.");
+    }
+    return fn(this.client);
+  }
+
+  async prDetail(n: number): Promise {
+    const r = await this.resolveOwnerRepo();
+    if (!r || !this.token) {
+      return undefined;
+    }
+    try {
+      const pr = await this.client.getPull(r.owner, r.repo, n);
+      const [files, status] = await Promise.all([
+        this.client.getPullFiles(r.owner, r.repo, n).catch(() => []),
+        this.client.getCombinedStatus(r.owner, r.repo, pr.head.sha).catch(() => ({ state: "", totalCount: 0 })),
+      ]);
+      return { pr, files, checks: status.state };
+    } catch {
+      return undefined;
+    }
+  }
+
+  /** Read-only fetch of an issue/PR from ANY repo (cross-repo notifications open
+   *  in-app instead of github.com). Uses the client directly — not the current
+   *  repo's owner/repo — so any subject the user is notified about can be read here. */
+  async externalItem(req: {
+    owner: string;
+    repo: string;
+    number: number;
+    kind: "issue" | "pull";
+  }): Promise {
+    if (!this.token) return undefined;
+    const { owner, repo, number, kind } = req;
+    try {
+      const comments = (await this.client.listConversation(owner, repo, number).catch(() => []))
+        .filter((c) => c.kind === "comment")
+        .map((c) => ({ author: c.author || null, body: c.body, createdAt: c.createdAt }));
+      if (kind === "pull") {
+        const pr = await this.client.getPull(owner, repo, number);
+        return {
+          kind: "pull", number, repo: `${owner}/${repo}`, title: pr.title,
+          state: pr.draft ? "draft" : pr.state, body: pr.body, htmlUrl: pr.htmlUrl,
+          author: pr.user?.login ?? null, createdAt: pr.createdAt, comments,
+        };
+      }
+      const issue = await this.client.getIssue(owner, repo, number);
+      return {
+        kind: "issue", number, repo: `${owner}/${repo}`, title: issue.title,
+        state: issue.state, body: issue.body, htmlUrl: issue.htmlUrl,
+        author: issue.user?.login ?? null, createdAt: issue.createdAt, comments,
+      };
+    } catch {
+      return undefined;
+    }
+  }
+
+  /** Fetch the PR's head into a local `pr/` branch and check it out. */
+  async prCheckout(n: number): Promise {
+    const ctx = this.repos.getContext();
+    if (!ctx) {
+      return { ok: false, changed: false, message: "No repository open." };
+    }
+    try {
+      const f = await ctx.process.run(["fetch", "origin", `pull/${n}/head:pr/${n}`]);
+      if (f.code !== 0) {
+        return { ok: false, changed: false, message: f.stderr.trim() };
+      }
+      const c = await ctx.process.run(["checkout", `pr/${n}`]);
+      return c.code === 0
+        ? { ok: true, changed: true }
+        : { ok: false, changed: false, message: c.stderr.trim() };
+    } catch (err) {
+      return { ok: false, changed: false, message: String(err) };
+    }
+  }
+
+  async prMerge(req: { number: number; method: MergeMethod }): Promise {
+    const r = await this.resolveOwnerRepo();
+    if (!r || !this.token) {
+      return { ok: false, changed: false, message: "Not connected to GitHub." };
+    }
+    try {
+      await this.client.mergePull(r.owner, r.repo, req.number, req.method);
+      return { ok: true, changed: true };
+    } catch (err) {
+      return { ok: false, changed: false, message: err instanceof Error ? err.message : String(err) };
+    }
+  }
+
+  async prCommits(n: number): Promise {
+    const r = await this.resolveOwnerRepo();
+    if (!r || !this.token) return [];
+    return this.client.listPrCommits(r.owner, r.repo, n).catch(() => []);
+  }
+  async prConversation(n: number): Promise {
+    const r = await this.resolveOwnerRepo();
+    if (!r || !this.token) return [];
+    return this.client.listConversation(r.owner, r.repo, n).catch(() => []);
+  }
+  async prChecks(n: number): Promise {
+    const r = await this.resolveOwnerRepo();
+    if (!r || !this.token) return [];
+    try {
+      const pr = await this.client.getPull(r.owner, r.repo, n);
+      return await this.client.listCheckRuns(r.owner, r.repo, pr.head.sha);
+    } catch {
+      return [];
+    }
+  }
+  async prApprove(n: number): Promise {
+    const r = await this.resolveOwnerRepo();
+    if (!r || !this.token) return { ok: false, changed: false, message: "Not connected to GitHub." };
+    try {
+      await this.client.approvePull(r.owner, r.repo, n);
+      return { ok: true, changed: false };
+    } catch (err) {
+      return { ok: false, changed: false, message: err instanceof Error ? err.message : String(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) {
+      return [];
+    }
+    return this.client.listOpenIssues(r.owner, r.repo);
+  }
+
+  async issueDetail(n: number): Promise {
+    const r = await this.resolveOwnerRepo();
+    if (!r || !this.token) {
+      return undefined;
+    }
+    try {
+      return await this.client.getIssue(r.owner, r.repo, n);
+    } catch {
+      return undefined;
+    }
+  }
+
+  async projectList(): Promise {
+    const r = await this.resolveOwnerRepo();
+    if (!r || !this.token) {
+      return [];
+    }
+    return this.client.listProjects(r.owner, r.repo);
+  }
+}
diff --git a/apps/desktop/src/main/githubClient.ts b/apps/desktop/src/main/githubClient.ts
new file mode 100644
index 0000000..6329b3b
--- /dev/null
+++ b/apps/desktop/src/main/githubClient.ts
@@ -0,0 +1,422 @@
+// A thin GitHub REST + GraphQL client for the desktop app's PRs / Issues /
+// Projects views. It runs in the Electron MAIN process over Node's global
+// `fetch`, talks only to api.github.com, and returns typed results. Mirrors the
+// extension's githubApi.ts (the proven PR client) and adds Issues + Projects.
+// The token is supplied by the caller (GitHubBridge reads it from safeStorage).
+
+import type {
+  CheckRun,
+  GitHubUser,
+  IssueInfo,
+  ProjectInfo,
+  PrComment,
+  PrCommitInfo,
+  PrFile,
+  PullRequest,
+  WorkflowRun,
+} from "../shared/ipc";
+
+const API_BASE = "https://api.github.com";
+const GRAPHQL = "https://api.github.com/graphql";
+
+interface CombinedStatus {
+  state: string;
+  totalCount: number;
+}
+
+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 {
+    const token = this.getToken();
+    if (!token) {
+      throw new Error("Not connected to GitHub.");
+    }
+    const headers: Record = {
+      Authorization: `Bearer ${token}`,
+      Accept: "application/vnd.github+json",
+      "X-GitHub-Api-Version": "2022-11-28",
+      "User-Agent": "GitStudio",
+    };
+    if (body !== undefined) {
+      headers["Content-Type"] = "application/json";
+    }
+    let res: Response;
+    try {
+      res = await fetch(`${API_BASE}${path}`, {
+        method,
+        headers,
+        body: body !== undefined ? JSON.stringify(body) : undefined,
+      });
+    } catch {
+      throw new Error("Couldn't reach GitHub. Check your network connection.");
+    }
+    if (res.ok) {
+      if (res.status === 204) return undefined as T;
+      const text = await res.text();
+      return (text.length > 0 ? JSON.parse(text) : undefined) as T;
+    }
+    throw await this.toError(res);
+  }
+
+  /** REST call that ignores the response body (fire-and-forget mutations). */
+  async requestBody(method: string, path: string, body: unknown): Promise {
+    const token = this.getToken();
+    if (!token) {
+      throw new Error("Not connected to GitHub.");
+    }
+    const res = await fetch(`${API_BASE}${path}`, {
+      method,
+      headers: {
+        Authorization: `Bearer ${token}`,
+        Accept: "application/vnd.github+json",
+        "X-GitHub-Api-Version": "2022-11-28",
+        "User-Agent": "GitStudio",
+        "Content-Type": "application/json",
+      },
+      body: JSON.stringify(body),
+    });
+    if (!res.ok) {
+      throw await this.toError(res);
+    }
+  }
+
+  /** GraphQL call (Projects v2 etc.). Public for the per-section modules. */
+  async graphql(query: string, variables: unknown): Promise {
+    const token = this.getToken();
+    if (!token) {
+      throw new Error("Not connected to GitHub.");
+    }
+    const res = await fetch(GRAPHQL, {
+      method: "POST",
+      headers: {
+        Authorization: `Bearer ${token}`,
+        "User-Agent": "GitStudio",
+        "Content-Type": "application/json",
+      },
+      body: JSON.stringify({ query, variables }),
+    });
+    if (!res.ok) {
+      throw await this.toError(res);
+    }
+    const json = (await res.json()) as { data?: T; errors?: { message: string }[] };
+    if (json.errors && json.errors.length) {
+      throw new Error(json.errors[0].message);
+    }
+    return json.data as T;
+  }
+
+  private async toError(res: Response): Promise {
+    let detail = "";
+    try {
+      const data = (await res.json()) as { message?: string };
+      detail = data?.message ?? "";
+    } catch {
+      /* non-JSON body */
+    }
+    if (res.status === 401) return new Error("Your GitHub token is invalid or expired.");
+    if (res.status === 403) return new Error(detail || "GitHub denied the request (permissions or rate limit).");
+    if (res.status === 404) return new Error(detail || "Not found on GitHub.");
+    return new Error(detail || `GitHub request failed (HTTP ${res.status}).`);
+  }
+
+  // ── User ──
+  async currentLogin(): Promise {
+    try {
+      const u = await this.request<{ login: string }>("GET", "/user");
+      return u.login;
+    } catch {
+      return undefined;
+    }
+  }
+
+  // ── 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`,
+    );
+    return raw.map(mapPull);
+  }
+  async getPull(owner: string, repo: string, n: number): Promise {
+    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",
+      `/repos/${enc(owner)}/${enc(repo)}/pulls/${n}/files?per_page=100`,
+    );
+    return raw.map((f) => ({
+      filename: f.filename,
+      status: f.status,
+      additions: f.additions,
+      deletions: f.deletions,
+    }));
+  }
+  async mergePull(owner: string, repo: string, n: number, method: "merge" | "squash" | "rebase"): Promise {
+    await this.requestBody("PUT", `/repos/${enc(owner)}/${enc(repo)}/pulls/${n}/merge`, { merge_method: method });
+  }
+  async approvePull(owner: string, repo: string, n: number): Promise {
+    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",
+      `/repos/${enc(owner)}/${enc(repo)}/pulls/${n}/commits?per_page=100`,
+    );
+    return raw.map((c) => ({
+      sha: c.sha,
+      shortSha: c.sha.slice(0, 7),
+      message: (c.commit?.message ?? "").split("\n", 1)[0],
+      author: c.commit?.author?.name ?? c.author?.login ?? "unknown",
+      date: c.commit?.author?.date ?? "",
+    }));
+  }
+  /** 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(() => []),
+    ]);
+    const out: PrComment[] = [];
+    for (const c of comments) {
+      out.push({ author: c.user?.login ?? "unknown", body: c.body ?? "", createdAt: c.created_at, kind: "comment" });
+    }
+    for (const r of reviews) {
+      if (r.state === "PENDING") continue;
+      out.push({ author: r.user?.login ?? "unknown", body: r.body ?? "", createdAt: r.submitted_at ?? "", kind: "review", state: r.state });
+    }
+    out.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
+    return out;
+  }
+  async listCheckRuns(owner: string, repo: string, ref: string): Promise {
+    try {
+      const raw = await this.request<{ check_runs?: RawCheck[] }>(
+        "GET",
+        `/repos/${enc(owner)}/${enc(repo)}/commits/${enc(ref)}/check-runs?per_page=100`,
+      );
+      return (raw.check_runs ?? []).map((c) => ({
+        name: c.name,
+        status: c.status ?? "",
+        conclusion: c.conclusion ?? "",
+        detailsUrl: c.details_url ?? undefined,
+      }));
+    } catch {
+      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 }>(
+        "GET",
+        `/repos/${enc(owner)}/${enc(repo)}/commits/${enc(ref)}/status`,
+      );
+      return { state: raw.state ?? "", totalCount: raw.total_count ?? 0 };
+    } catch {
+      return { state: "", totalCount: 0 };
+    }
+  }
+
+  // ── 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`,
+    );
+    return raw.filter((i) => !i.pull_request).map(mapIssue);
+  }
+  async getIssue(owner: string, repo: string, n: number): Promise {
+    return mapIssue(await this.request("GET", `/repos/${enc(owner)}/${enc(repo)}/issues/${n}`));
+  }
+
+  // ── Projects (v2, via GraphQL) ──
+  async listProjects(owner: string, repo: string): Promise {
+    try {
+      const data = await this.graphql(
+        `query($owner:String!,$repo:String!){repository(owner:$owner,name:$repo){projectsV2(first:20,orderBy:{field:UPDATED_AT,direction:DESC}){nodes{id number title shortDescription url closed updatedAt items{totalCount}}}}}`,
+        { owner, repo },
+      );
+      const nodes = data?.repository?.projectsV2?.nodes ?? [];
+      return nodes.map((p) => ({
+        id: p.id ?? "",
+        number: p.number,
+        title: p.title,
+        shortDescription: p.shortDescription ?? "",
+        url: p.url,
+        itemCount: p.items?.totalCount ?? 0,
+        closed: p.closed,
+        updatedAt: p.updatedAt ?? "",
+      }));
+    } catch {
+      return [];
+    }
+  }
+}
+
+export function enc(part: string): string {
+  return encodeURIComponent(part);
+}
+
+export interface RawUser {
+  login: string;
+  avatar_url?: string;
+}
+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 } };
+  author?: { login?: string } | null;
+}
+interface RawComment {
+  user?: RawUser | null;
+  body?: string;
+  created_at: string;
+}
+interface RawReview {
+  user?: RawUser | null;
+  body?: string;
+  state?: string;
+  submitted_at?: string;
+}
+interface RawCheck {
+  name: string;
+  status?: string;
+  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?: {
+      nodes?: {
+        id?: string;
+        number: number;
+        title: string;
+        shortDescription?: string;
+        url: string;
+        closed: boolean;
+        updatedAt?: string;
+        items?: { totalCount: number };
+      }[];
+    };
+  };
+}
+
+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/main.ts b/apps/desktop/src/main/main.ts
new file mode 100644
index 0000000..f7cf5ad
--- /dev/null
+++ b/apps/desktop/src/main/main.ts
@@ -0,0 +1,772 @@
+// GitStudio desktop — Electron main process.
+//
+// Creates the app window (contextIsolation on, nodeIntegration off, sandbox off
+// so the preload can `require` the contextBridge), wires the application menu,
+// and registers the DesktopHostBridge: a set of `ipcMain.handle` endpoints that
+// wrap @gitstudio/git-service + @gitstudio/engine. No git logic lives here — it
+// all delegates to GitBridge, which reuses the shared core verbatim.
+
+import {
+  app,
+  BrowserWindow,
+  dialog,
+  ipcMain,
+  Menu,
+  nativeTheme,
+  shell,
+} from "electron";
+import type { IpcMainInvokeEvent, MenuItemConstructorOptions, WebContents } from "electron";
+import { AsyncLocalStorage } from "node:async_hooks";
+import { join } from "node:path";
+import { readFile, writeFile, mkdir } from "node:fs/promises";
+import { RepoStore } from "./repoStore";
+import { GitBridge } from "./gitBridge";
+import { GitHubBridge } from "./githubBridge";
+import { AiBridge } from "./aiBridge";
+import { TerminalBridge } from "./terminalBridge";
+import { pickCloneDir, startClone, listGhRepos, killActiveClones } from "./cloneBridge";
+import { initAutoUpdate } from "./autoUpdate";
+import * as issuesApi from "./github/issues";
+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 projectsApi from "./github/projects";
+import * as gistsApi from "./github/gists";
+import type {
+  CommitActionResult,
+  IpcChannel,
+  IpcEvents,
+  IpcRequest,
+  IpcResponse,
+  RepoInfo,
+} from "../shared/ipc";
+
+// Set the product name BEFORE the app is ready so the macOS app menu, the dock
+// label, and userData path all read "GitStudio" instead of "Electron" (which is
+// the default for an unpackaged dev build).
+app.setName("GitStudio");
+
+let mainWindow: BrowserWindow | undefined;
+let repos: RepoStore;
+let bridge: GitBridge;
+let github: GitHubBridge;
+let ai: AiBridge;
+let terminal: TerminalBridge;
+
+/** Where the recent-repos list is persisted between sessions. */
+function statePath(): string {
+  return join(app.getPath("userData"), "gitstudio-state.json");
+}
+
+async function loadState(): Promise<{ recent: string[]; current?: string }> {
+  try {
+    const raw = await readFile(statePath(), "utf8");
+    const parsed = JSON.parse(raw) as { recent?: string[]; current?: string };
+    return { recent: parsed.recent ?? [], current: parsed.current };
+  } catch {
+    return { recent: [] };
+  }
+}
+
+async function saveState(): Promise {
+  try {
+    await mkdir(app.getPath("userData"), { recursive: true });
+    await writeFile(statePath(), JSON.stringify(repos.serialize(), null, 2));
+  } catch {
+    // Persistence is best-effort; never block on it.
+  }
+}
+
+function send(event: E, data: IpcEvents[E]): void {
+  if (mainWindow && !mainWindow.isDestroyed()) {
+    mainWindow.webContents.send(event, data);
+  }
+}
+
+/**
+ * Only ever hand http(s)/mailto URLs to the OS. The renderer routes every
+ * `window.open` through here, and many of those URLs come straight from the
+ * GitHub API (PR/check `details_url`, release asset `download_url`, …) — i.e.
+ * attacker-influenced. `shell.openExternal` will otherwise happily launch
+ * `file://`, `smb://`, and registered custom-protocol handlers.
+ */
+function openExternalSafely(rawUrl: string): void {
+  try {
+    const u = new URL(rawUrl);
+    if (u.protocol === "http:" || u.protocol === "https:" || u.protocol === "mailto:") {
+      void shell.openExternal(rawUrl);
+    }
+  } catch {
+    // Not a parseable URL — ignore.
+  }
+}
+
+/**
+ * Lock a webContents down: external links open in the OS browser (allowlisted),
+ * top-level navigation away from the bundled app is blocked (an XSS or a stray
+ * `location =` must never be able to load a remote origin into a window whose
+ * preload exposes the full IPC surface), child webviews are forbidden, and all
+ * device-permission requests are denied (the app needs none).
+ */
+function hardenWebContents(contents: WebContents): void {
+  contents.setWindowOpenHandler(({ url }) => {
+    openExternalSafely(url);
+    return { action: "deny" };
+  });
+  contents.on("will-navigate", (event, url) => {
+    if (url !== contents.getURL()) {
+      event.preventDefault();
+      openExternalSafely(url);
+    }
+  });
+  contents.on("will-attach-webview", (event) => event.preventDefault());
+  contents.session.setPermissionRequestHandler((_wc, _permission, callback) =>
+    callback(false),
+  );
+}
+
+async function createWindow(): Promise {
+  mainWindow = new BrowserWindow({
+    width: 1280,
+    height: 820,
+    minWidth: 880,
+    minHeight: 560,
+    show: false,
+    // Match the renderer's --app-bg for the chosen theme so the window frame
+    // doesn't flash the wrong shade before the page paints.
+    backgroundColor: nativeTheme.shouldUseDarkColors ? "#0d1016" : "#eef1f5",
+    titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
+    // Vertically center the traffic lights in the slim 40px topbar (macOS).
+    ...(process.platform === "darwin"
+      ? { trafficLightPosition: { x: 18, y: 13 } }
+      : {}),
+    title: "GitStudio",
+    icon: appIcon(),
+    webPreferences: {
+      preload: join(__dirname, "../preload/preload.js"),
+      contextIsolation: true,
+      nodeIntegration: false,
+      // The preload only touches contextBridge + ipcRenderer, both available in
+      // a sandboxed preload, so we keep the renderer fully sandboxed.
+      sandbox: true,
+      spellcheck: false,
+    },
+  });
+
+  // The integrated terminal's PTY manager streams output to this window.
+  terminal = new TerminalBridge((channel, payload) =>
+    mainWindow?.webContents.send(channel, payload),
+  );
+
+  mainWindow.once("ready-to-show", () => mainWindow?.show());
+  mainWindow.on("closed", () => {
+    terminal?.killAll();
+    killActiveClones();
+    mainWindow = undefined;
+  });
+
+  // External links / navigation lockdown is applied to every webContents via the
+  // app-level "web-contents-created" handler registered in boot().
+
+  await mainWindow.loadFile(join(__dirname, "../renderer/index.html"));
+}
+
+/** The dock/window brand mark for a theme variant (dev/window icon; electron-builder
+ *  embeds the packaged icon separately). Light theme gets the light-tile mark. */
+function iconPath(variant: "dark" | "light"): string {
+  return join(__dirname, variant === "light" ? "../renderer/icon-light.png" : "../renderer/icon.png");
+}
+
+/** Brand icon for the window `icon:`; electron-builder embeds the platform icon,
+ *  this is the dev/window one. Tracks the OS scheme so it isn't visibly wrong. */
+function appIcon(): string {
+  return iconPath(nativeTheme.shouldUseDarkColors ? "dark" : "light");
+}
+
+/** Swap the macOS dock icon to the given brand variant (best-effort). */
+function setDockIcon(variant: "dark" | "light"): void {
+  try {
+    app.dock?.setIcon(iconPath(variant));
+  } catch {
+    /* non-macOS or missing — harmless */
+  }
+}
+
+// ── Menu ─────────────────────────────────────────────────────────────────────
+
+function buildMenu(): void {
+  const isMac = process.platform === "darwin";
+
+  const recentSubmenu: MenuItemConstructorOptions[] = repos
+    .recentRepos()
+    .map((r) => ({
+      label: r.name,
+      sublabel: r.root,
+      click: () => void openRepoPath(r.root),
+    }));
+  if (recentSubmenu.length === 0) {
+    recentSubmenu.push({ label: "No Recent Repositories", enabled: false });
+  }
+
+  const template: MenuItemConstructorOptions[] = [
+    ...(isMac
+      ? [
+          {
+            label: app.name,
+            submenu: [
+              { role: "about" as const },
+              { type: "separator" as const },
+              { role: "hide" as const },
+              { role: "hideOthers" as const },
+              { role: "unhide" as const },
+              { type: "separator" as const },
+              { role: "quit" as const },
+            ],
+          },
+        ]
+      : []),
+    {
+      label: "Repo",
+      submenu: [
+        {
+          label: "Open Repository…",
+          accelerator: "CmdOrCtrl+O",
+          click: () => void openRepoDialog(),
+        },
+        { label: "Open Recent", submenu: recentSubmenu },
+        { type: "separator" },
+        {
+          label: "Refresh",
+          accelerator: "CmdOrCtrl+R",
+          click: () => send("menu:command", { command: "refresh" }),
+        },
+        {
+          label: "Close Repository",
+          accelerator: "CmdOrCtrl+W",
+          click: () => closeRepo(),
+        },
+        ...(isMac
+          ? []
+          : [
+              { type: "separator" as const },
+              { role: "quit" as const },
+            ]),
+      ],
+    },
+    {
+      label: "Edit",
+      submenu: [
+        { role: "undo" },
+        { role: "redo" },
+        { type: "separator" },
+        { role: "cut" },
+        { role: "copy" },
+        { role: "paste" },
+        { role: "selectAll" },
+      ],
+    },
+    {
+      label: "View",
+      submenu: [
+        { role: "reload" },
+        { role: "forceReload" },
+        { role: "toggleDevTools" },
+        { type: "separator" },
+        { role: "resetZoom" },
+        { role: "zoomIn" },
+        { role: "zoomOut" },
+        { type: "separator" },
+        { role: "togglefullscreen" },
+      ],
+    },
+    {
+      label: "Window",
+      submenu: [
+        { role: "minimize" },
+        { role: "zoom" },
+        ...(isMac
+          ? [
+              { type: "separator" as const },
+              { role: "front" as const },
+            ]
+          : [{ role: "close" as const }]),
+      ],
+    },
+    {
+      role: "help",
+      submenu: [
+        {
+          label: "GitStudio Website",
+          click: () => openExternalSafely("https://gitstudio.dev"),
+        },
+        {
+          label: "Report an Issue",
+          click: () =>
+            openExternalSafely("https://github.com/GitStudioHQ/gitstudio/issues"),
+        },
+      ],
+    },
+  ];
+
+  Menu.setApplicationMenu(Menu.buildFromTemplate(template));
+}
+
+// ── Repo lifecycle ───────────────────────────────────────────────────────────
+
+async function openRepoDialog(): Promise {
+  if (!mainWindow) {
+    return undefined;
+  }
+  const result = await dialog.showOpenDialog(mainWindow, {
+    title: "Open Git Repository",
+    properties: ["openDirectory"],
+  });
+  if (result.canceled || result.filePaths.length === 0) {
+    return undefined;
+  }
+  return openRepoPath(result.filePaths[0]);
+}
+
+async function openRepoPath(path: string): Promise {
+  const info = await repos.open(path);
+  if (!info && mainWindow) {
+    await dialog.showMessageBox(mainWindow, {
+      type: "warning",
+      message: "Not a Git repository",
+      detail: `${path} is not inside a Git repository.`,
+    });
+  }
+  buildMenu();
+  void saveState();
+  return info;
+}
+
+function closeRepo(): void {
+  repos.close();
+  buildMenu();
+  void saveState();
+}
+
+// ── IPC registration ─────────────────────────────────────────────────────────
+
+// Every IPC invocation runs inside an "action" async context; the git-command
+// observer reads it so the Output tab can group the commands a single user
+// action executed under a human label (AsyncLocalStorage follows the awaits,
+// so concurrent actions never cross-tag each other's commands).
+const actionCtx = new AsyncLocalStorage<{ id: number; label: string }>();
+let actionSeq = 0;
+
+/** Human label for the action behind an IPC channel (Output-tab group title). */
+function actionLabel(channel: string): string {
+  const NAMES: Record = {
+    "graph:load": "Load history",
+    "refs:list": "Refresh refs",
+    "head:get": "Read HEAD",
+    status: "Refresh status",
+    "commit:details": "Inspect commit",
+    "commit:rowStats": "Commit stats",
+    "diff:files": "List changes",
+    "file:diff": "Open diff",
+    "conflict:model": "Open conflict",
+    "blame:file": "Blame file",
+    "commit:action": "Commit action",
+    stage: "Stage",
+    unstage: "Unstage",
+    discard: "Discard",
+    stageAll: "Stage all",
+    unstageAll: "Unstage all",
+    commit: "Commit",
+    "stash:list": "List stashes",
+    "stash:apply": "Apply stash",
+    "stash:pop": "Pop stash",
+    "stash:drop": "Drop stash",
+    "stash:save": "Stash",
+    "worktree:list": "List worktrees",
+    "worktree:add": "Add worktree",
+    "worktree:remove": "Remove worktree",
+    "sync:status": "Check sync",
+    "sync:fetch": "Fetch",
+    "sync:pull": "Pull",
+    "sync:push": "Push",
+    "branches:list": "List branches",
+    "branch:create": "Create branch",
+    "branch:delete": "Delete branch",
+    "branch:pullFf": "Pull branch",
+    "compare:refs": "Compare",
+    "compare:fileDiff": "Compare file",
+    "repo:tree": "Read tree",
+    "repo:file": "Read file",
+    "repo:open": "Open repository",
+    "repo:openPath": "Open repository",
+    "clone:start": "Clone",
+    "pr:checkout": "Checkout PR",
+    "git:identity": "Read identity",
+    "git:setIdentity": "Set identity",
+  };
+  if (NAMES[channel]) return NAMES[channel];
+  // "branch:rename" → "Branch rename" — readable even for unmapped channels.
+  return channel.replace(/[:.]/g, " ").replace(/^./, (c) => c.toUpperCase());
+}
+
+/** Registers a typed `ipcMain.handle` endpoint. */
+function handle(
+  channel: C,
+  fn: (payload: IpcRequest, event: IpcMainInvokeEvent) => Promise>,
+): void {
+  ipcMain.handle(channel, (event, payload) =>
+    actionCtx.run({ id: ++actionSeq, label: actionLabel(channel) }, () =>
+      fn(payload as IpcRequest, event),
+    ),
+  );
+}
+
+function registerIpc(): void {
+  handle("repo:open", () => openRepoDialog());
+  handle("repo:openPath", (path) => openRepoPath(path));
+  handle("repo:recent", async () => repos.recentRepos());
+  handle("repo:current", async () => repos.current());
+  handle("repo:close", async () => {
+    closeRepo();
+  });
+
+  handle("graph:load", (opts) => bridge.graphLoad(opts));
+  handle("refs:list", () => bridge.refsList());
+  handle("head:get", () => bridge.head());
+  handle("status", () => bridge.status());
+  handle("commit:details", (sha) => bridge.commitDetails(sha));
+  handle("commit:rowStats", (shas) => bridge.rowStats(shas));
+  handle("diff:files", () => bridge.diffFiles());
+  handle("file:diff", (req) => bridge.fileDiff(req));
+  handle("conflict:model", (path) => bridge.conflictModel(path));
+  handle("blame:file", (path) => bridge.blameFile(path));
+  handle("commit:action", (req) => bridge.commitAction(req));
+
+  // Working-tree staging + commit (Changes view).
+  handle("stage", (path) => bridge.stage(path));
+  handle("unstage", (path) => bridge.unstage(path));
+  handle("discard", (path) => bridge.discard(path));
+  handle("stageAll", () => bridge.stageAll());
+  handle("unstageAll", () => bridge.unstageAll());
+  handle("commit", (req) => bridge.commit(req));
+
+  // Stashes.
+  handle("stash:list", () => bridge.stashList());
+  handle("stash:apply", (ref) => bridge.stashApply(ref));
+  handle("stash:pop", (ref) => bridge.stashPop(ref));
+  handle("stash:drop", (ref) => bridge.stashDrop(ref));
+  handle("stash:save", (opts) => bridge.stashSave(opts));
+
+  // Worktrees.
+  handle("worktree:list", () => bridge.worktreeList());
+  handle("worktree:add", (req) => worktreeAddDialog(req));
+  handle("worktree:remove", (req) => bridge.worktreeRemove(req));
+  handle("worktree:open", (path) => openRepoPath(path));
+
+  // Sync (control remote changes).
+  handle("sync:status", () => bridge.syncStatus());
+  handle("sync:fetch", () => bridge.syncFetch());
+  handle("sync:pull", () => bridge.syncPull());
+  handle("sync:push", (opts) => bridge.syncPush(opts || undefined));
+
+  // Branch management.
+  handle("branches:list", () => bridge.branchesList());
+  handle("branch:create", (req) => bridge.branchCreate(req));
+  handle("branch:delete", (req) => bridge.branchDelete(req));
+  handle("branch:pullFf", (req) => bridge.branchPullFf(req.name));
+
+  // Compare (base…head).
+  handle("compare:refs", (req) => bridge.compareRefs(req));
+  handle("compare:fileDiff", (req) => bridge.compareFileDiff(req));
+
+  // Code browser (GitHub-style file tree at HEAD).
+  handle("repo:tree", (req) => bridge.treeList(req));
+  handle("repo:file", (req) => bridge.fileText(req));
+  handle("repo:headCommit", () => bridge.headCommit());
+
+  // Integrated terminal (PTY) — launches in the active repo's directory.
+  handle("terminal:create", async (opts) =>
+    terminal.create(opts, repos.current()?.root),
+  );
+  handle("terminal:write", async (req) => terminal.write(req.id, req.data));
+  handle("terminal:resize", async (req) => terminal.resize(req.id, req.cols, req.rows));
+  handle("terminal:kill", async (req) => terminal.kill(req.id));
+
+  // Clone / browse repos.
+  handle("clone:pickDir", () => pickCloneDir());
+  handle("clone:start", (req) => startClone(req, (p) => send("clone:progress", p)));
+  handle("github:repos", (req) =>
+    github.withClient((c) => listGhRepos(c, req?.search)),
+  );
+
+  // GitHub (PRs / Issues / Projects).
+  handle("github:status", () => github.status());
+  handle("github:connect", (pat) => github.connect(pat));
+  handle("github:disconnect", () => github.disconnect());
+  handle("github:deviceStart", () => github.deviceStart());
+  handle("github:devicePoll", (req) => github.devicePoll(req));
+
+  // Settings: git identity + local SSH keys.
+  handle("git:identity", () => bridge.gitIdentity());
+  handle("git:setIdentity", (req) => bridge.setGitIdentity(req));
+  handle("ssh:keys", () => bridge.sshKeys());
+  handle("pr:list", () => github.prList());
+  handle("pr:detail", (n) => github.prDetail(n));
+  handle("pr:checkout", (n) => github.prCheckout(n));
+  handle("pr:merge", (req) => github.prMerge(req));
+  handle("pr:commits", (n) => github.prCommits(n));
+  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("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 /
+  //    notifications / orgs / projects / gists (each in src/main/github/*). ──
+  // Issues.
+  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("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)));
+  handle("issue:edit", (req) => github.withRepo((c, o, r) => issuesApi.editIssue(c, o, r, req)));
+  handle("issue:labels", () => github.withRepo((c, o, r) => issuesApi.listLabels(c, o, r)));
+  handle("issue:setLabels", (req) => github.withRepo((c, o, r) => issuesApi.setIssueLabels(c, o, r, req)));
+  handle("issue:setAssignees", (req) => github.withRepo((c, o, r) => issuesApi.setIssueAssignees(c, o, r, req)));
+  // Pull request write actions (reads/approve/checkout/merge stay on the bridge).
+  handle("pr:create", (req) => github.withRepo((c, o, r) => prsApi.prCreate(c, o, r, req)));
+  handle("pr:comment", (req) => github.withRepo((c, o, r) => prsApi.prComment(c, o, r, req)));
+  handle("pr:review", (req) => github.withRepo((c, o, r) => prsApi.prReview(c, o, r, req)));
+  handle("pr:setState", (req) => github.withRepo((c, o, r) => prsApi.prSetState(c, o, r, req)));
+  handle("pr:requestReviewers", (req) => github.withRepo((c, o, r) => prsApi.prRequestReviewers(c, o, r, req)));
+  handle("pr:markReady", (n) => github.withRepo((c, o, r) => prsApi.prMarkReady(c, o, r, n)));
+  handle("pr:branches", () => github.withRepo((c, o, r) => prsApi.prBranches(c, o, r)));
+  handle("pr:reviewers", () => github.withRepo((c, o, r) => prsApi.prReviewers(c, o, r)));
+  // Actions control.
+  handle("actions:runDetail", (id) => github.withRepo((c, o, r) => actionsApi.getRunDetail(c, o, r, id)));
+  handle("actions:workflows", () => github.withRepo((c, o, r) => actionsApi.listWorkflows(c, o, r)));
+  handle("actions:dispatchInputs", (id) => github.withRepo((c, o, r) => actionsApi.getDispatchInputs(c, o, r, id)));
+  handle("actions:rerun", (id) => github.withRepo((c, o, r) => actionsApi.rerunRun(c, o, r, id)));
+  handle("actions:rerunFailed", (id) => github.withRepo((c, o, r) => actionsApi.rerunFailedJobs(c, o, r, id)));
+  handle("actions:cancel", (id) => github.withRepo((c, o, r) => actionsApi.cancelRun(c, o, r, id)));
+  handle("actions:dispatch", (req) => github.withRepo((c, o, r) => actionsApi.dispatchWorkflow(c, o, r, req)));
+  // Releases.
+  handle("release:list", () => github.withRepo((c, o, r) => releasesApi.listReleases(c, o, r)));
+  handle("release:detail", (id) => github.withRepo((c, o, r) => releasesApi.getRelease(c, o, r, id)));
+  handle("release:tags", () => github.withRepo((c, o, r) => releasesApi.listTags(c, o, r)));
+  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)));
+  // Notifications (user-level).
+  handle("notifications:list", (opts) => github.withClient((c) => notificationsApi.listNotifications(c, opts)));
+  handle("notification:markRead", (req) => github.withClient((c) => notificationsApi.markNotificationRead(c, req.id)));
+  handle("notifications:markAllRead", () => github.withClient((c) => notificationsApi.markAllNotificationsRead(c)));
+  // Organizations (user-level).
+  handle("orgs:list", () => github.withClient((c) => orgsApi.listOrgs(c)));
+  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)));
+  // 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)));
+  handle("project:moveItem", (req) => github.withRepo((c, o, r) => projectsApi.moveProjectItem(c, o, r, req)));
+  handle("project:addItem", (req) => github.withRepo((c, o, r) => projectsApi.addProjectItem(c, o, r, req)));
+  // Gists (user-level).
+  handle("gist:list", () => github.withClient((c) => gistsApi.listGists(c)));
+  handle("gist:detail", (id) => github.withClient((c) => gistsApi.getGist(c, id)));
+  handle("gist:create", (req) => github.withClient((c) => gistsApi.createGist(c, req)));
+  handle("gist:update", (req) => github.withClient((c) => gistsApi.updateGist(c, req)));
+  handle("gist:delete", (id) => github.withClient((c) => gistsApi.deleteGist(c, id)));
+
+  // ── AI / Agent / MCP (optional; degrades to "no connection" when unset) ──
+  handle("ai:settings", () => ai.getSettings());
+  handle("ai:catalog", async () => ai.catalog());
+  handle("ai:addConnection", (req) => ai.addConnection(req.preset));
+  handle("ai:updateConnection", (patch) => ai.updateConnection(patch));
+  handle("ai:removeConnection", (req) => ai.removeConnection(req.id));
+  handle("ai:setDefault", (req) => ai.setDefault(req.id));
+  handle("ai:setKey", (req) => ai.setKey(req.id, req.key));
+  handle("ai:setAgentConfig", (patch) => ai.setAgentConfig(patch));
+  handle("ai:models", (req) => ai.listModels(req ? req.connectionId : undefined));
+  handle("ai:test", (req) => ai.test(req.id));
+  handle("ai:task", (req) => ai.runTask(req.requestId, req.task, req.input));
+  handle("ai:agentRun", (req) => ai.runAgentTask(req));
+  handle("ai:agentConfirm", async (ans) => {
+    ai.confirmAnswer(ans);
+  });
+  handle("ai:cancel", async (req) => {
+    ai.cancel(req.requestId);
+  });
+  handle("ai:mcpInfo", async () => ai.mcpInfo());
+  handle("ai:mcpInstall", async (req) => ai.mcpInstall(req));
+  // Assistant chats (persisted sessions; warm CLI processes live in main).
+  handle("ai:chatList", () => ai.chatList());
+  handle("ai:chatCurrent", () => ai.chatCurrent());
+  handle("ai:chatGet", (req) => ai.chatGet(req.id));
+  handle("ai:chatNew", (req) => ai.chatNew(req?.setCurrent !== false));
+  handle("ai:chatSetCurrent", async (req) => {
+    await ai.chatSetCurrent(req.id);
+  });
+  handle("ai:chatSend", (req) => ai.chatSend(req));
+  handle("ai:chatDelete", async (req) => {
+    await ai.chatDelete(req.id);
+  });
+
+  // ── Local-git depth (engine-backed via GitBridge) ──
+  handle("conflict:resolve", (req) => bridge.conflictResolve(req));
+  handle("conflict:takeSide", (req) => bridge.conflictTakeSide(req));
+  handle("conflict:list", () => bridge.conflictList());
+  handle("stage:lines", (req) => bridge.stageLines(req));
+  handle("branch:merge", (req) => bridge.branchMerge(req));
+  handle("branch:rebase", (req) => bridge.branchRebase(req));
+  handle("branch:rename", (req) => bridge.branchRename(req));
+  handle("branch:setUpstream", (req) => bridge.branchSetUpstream(req));
+  handle("branch:deleteRemote", (req) => bridge.branchDeleteRemote(req));
+  handle("git:opState", () => bridge.opState());
+  handle("merge:abort", () => bridge.mergeAbort());
+  handle("merge:continue", () => bridge.mergeContinue());
+  handle("rebase:abort", () => bridge.rebaseAbort());
+  handle("rebase:continue", () => bridge.rebaseContinue());
+  handle("rebase:skip", () => bridge.rebaseSkip());
+  handle("tag:create", (req) => bridge.tagCreate(req));
+
+  // ── GitHub depth (PR review / issues / actions / search / repo admin) ──
+  handle("pr:fileDiff", (req) => github.withRepo((c, o, r) => prsApi.fileDiff(c, o, r, req)));
+  handle("pr:reviewThreads", (n) => github.withRepo((c, o, r) => prsApi.reviewThreads(c, o, r, n)));
+  handle("pr:addReviewComment", (req) => github.withRepo((c, o, r) => prsApi.addReviewComment(c, o, r, req)));
+  handle("pr:replyThread", (req) => github.withRepo((c, o, r) => prsApi.replyThread(c, o, r, req)));
+  handle("pr:resolveThread", (req) => github.withRepo((c, o, r) => prsApi.resolveThread(c, o, r, req)));
+  handle("pr:edit", (req) => github.withRepo((c, o, r) => prsApi.edit(c, o, r, req)));
+  handle("pr:setLabels", (req) => github.withRepo((c, o, r) => prsApi.setLabels(c, o, r, req)));
+  handle("pr:setAssignees", (req) => github.withRepo((c, o, r) => prsApi.setAssignees(c, o, r, req)));
+  handle("pr:updateBranch", (n) => github.withRepo((c, o, r) => prsApi.updateBranch(c, o, r, n)));
+  handle("pr:labels", () => github.withRepo((c, o, r) => prsApi.labels(c, o, r)));
+  handle("pr:prefill", () => github.withRepo((c, o, r) => prsApi.prefill(c, o, r)));
+  handle("issue:milestones", () => github.withRepo((c, o, r) => issuesApi.milestones(c, o, r)));
+  handle("issue:setMilestone", (req) => github.withRepo((c, o, r) => issuesApi.setMilestone(c, o, r, req)));
+  handle("labels:list", () => github.withRepo((c, o, r) => issuesApi.listLabels(c, o, r)));
+  handle("label:create", (req) => github.withRepo((c, o, r) => issuesApi.createLabel(c, o, r, req)));
+  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: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)));
+  handle("actions:setSecret", (req) => github.withRepo((c, o, r) => actionsApi.setSecret(c, o, r, req)));
+  handle("actions:deleteSecret", (name) => github.withRepo((c, o, r) => actionsApi.deleteSecret(c, o, r, name)));
+  handle("actions:variables", () => github.withRepo((c, o, r) => actionsApi.variables(c, o, r)));
+  handle("actions:setVariable", (req) => github.withRepo((c, o, r) => actionsApi.setVariable(c, o, r, req)));
+  handle("actions:deleteVariable", (name) => github.withRepo((c, o, r) => actionsApi.deleteVariable(c, o, r, name)));
+
+  // Appearance: the renderer owns the in-app theme override, so it tells us
+  // which brand variant the dock should wear.
+  handle("appearance:dockIcon", async (payload) => {
+    setDockIcon(payload.variant);
+  });
+}
+
+/** Picks (or creates) a folder, then adds a worktree there for `ref`. */
+async function worktreeAddDialog(req: {
+  ref: string;
+  newBranch?: boolean;
+}): Promise {
+  if (!mainWindow) {
+    return { ok: false, changed: false, message: "No window." };
+  }
+  const result = await dialog.showOpenDialog(mainWindow, {
+    title: `New worktree for ${req.ref}`,
+    properties: ["openDirectory", "createDirectory"],
+    buttonLabel: "Create Worktree Here",
+  });
+  if (result.canceled || result.filePaths.length === 0) {
+    return { ok: false, changed: false };
+  }
+  return bridge.worktreeAdd(result.filePaths[0], req.ref, req.newBranch);
+}
+
+// ── Boot ─────────────────────────────────────────────────────────────────────
+
+async function boot(): Promise {
+  // Belt-and-suspenders navigation lockdown: any webContents that ever gets
+  // created (not just the main window) inherits the same hardening.
+  app.on("web-contents-created", (_e, contents) => hardenWebContents(contents));
+
+  const state = await loadState();
+  repos = new RepoStore(state.recent);
+  bridge = new GitBridge(repos);
+  github = new GitHubBridge(repos);
+  ai = new AiBridge(repos, send);
+  repos.onChange((info) => {
+    send("repo:changed", info);
+    buildMenu();
+  });
+  // Stream every git command the open repo runs to the renderer's Output tab.
+  let gitLogId = 0;
+  repos.onGitRun = (e) => {
+    const action = actionCtx.getStore();
+    send("git:log", {
+      id: ++gitLogId,
+      args: e.args,
+      command: `git ${e.args.join(" ")}`,
+      durationMs: e.durationMs,
+      exitCode: e.exitCode,
+      failed: e.failed,
+      ...(e.stderr ? { stderr: e.stderr } : {}),
+      ...(action ? { actionId: action.id, action: action.label } : {}),
+      at: Date.now(),
+    });
+  };
+
+  registerIpc();
+  buildMenu();
+  // Dev builds show Electron's dock icon; force the GitStudio brand mark. Pick a
+  // sensible initial variant from the OS scheme so it doesn't flash the wrong
+  // tile before the renderer reports its (possibly overridden) theme.
+  setDockIcon(nativeTheme.shouldUseDarkColors ? "dark" : "light");
+  await createWindow();
+  initAutoUpdate({ isDev: !app.isPackaged });
+
+  // Re-open the last repo, if any, so the window lands on real history.
+  if (state.current) {
+    await repos.open(state.current).catch(() => undefined);
+    buildMenu();
+  }
+}
+
+// A single git call or GitHub request must never take the whole app down. Log
+// and keep running — the renderer surfaces user-facing failures itself.
+process.on("uncaughtException", (err) => {
+  // eslint-disable-next-line no-console
+  console.error("GitStudio main: uncaught exception:", err);
+});
+process.on("unhandledRejection", (reason) => {
+  // eslint-disable-next-line no-console
+  console.error("GitStudio main: unhandled rejection:", reason);
+});
+
+app.whenReady().then(boot).catch((err) => {
+  // eslint-disable-next-line no-console
+  console.error("GitStudio failed to start:", err);
+  app.quit();
+});
+
+app.on("window-all-closed", () => {
+  if (process.platform !== "darwin") {
+    app.quit();
+  }
+});
+
+app.on("activate", () => {
+  if (BrowserWindow.getAllWindows().length === 0) {
+    void createWindow();
+  }
+});
+
+app.on("before-quit", () => {
+  void saveState();
+  ai?.dispose();
+  repos?.dispose();
+});
diff --git a/apps/desktop/src/main/mcpConfig.ts b/apps/desktop/src/main/mcpConfig.ts
new file mode 100644
index 0000000..50546b0
--- /dev/null
+++ b/apps/desktop/src/main/mcpConfig.ts
@@ -0,0 +1,165 @@
+// "Agent Access": everything the Settings ▸ Agent Access card needs to point an
+// external agent (Claude Desktop, Cursor, VS Code/Copilot, Windsurf) at the
+// bundled GitStudio MCP server. We resolve the server's entry script, build a
+// ready-to-paste config snippet, detect which clients already have it, and can
+// one-click merge it into a client's config — scoped to the open repo, with the
+// write/destructive permission flags the user chose.
+
+import { app } from "electron";
+import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
+import { homedir } from "node:os";
+import { dirname, join } from "node:path";
+import type { McpClientInfo, McpInfo, McpInstallRequest } from "../shared/ipc";
+
+/** Resolve the bundled gitstudio-mcp entry across dev + packaged layouts. */
+export function resolveMcpBin(): string {
+  const env = process.env.GITSTUDIO_MCP_BIN;
+  const candidates = [
+    env,
+    // Dev: apps/desktop/dist/main/main.js → apps/mcp/dist/index.js
+    join(__dirname, "..", "..", "..", "mcp", "dist", "index.js"),
+    // Packaged (asar-unpacked or resources): resources/mcp/dist/index.js
+    join(process.resourcesPath ?? "", "mcp", "dist", "index.js"),
+    join(app.getAppPath(), "..", "mcp", "dist", "index.js"),
+  ].filter((p): p is string => typeof p === "string" && p.length > 0);
+  for (const c of candidates) {
+    if (existsSync(c)) {
+      return c;
+    }
+  }
+  // Fall back to the dev path even if missing, so the UI can say "build it".
+  return candidates[1] ?? "";
+}
+
+interface ClientConfig {
+  id: string;
+  label: string;
+  /** Config file path (mac/linux/win as available). */
+  path: string;
+  /** The JSON key the client uses for its server map. */
+  serversKey: "mcpServers" | "servers";
+}
+
+/** Per-OS client config locations. mac is fully supported; others best-effort. */
+function clientConfigs(): ClientConfig[] {
+  const home = homedir();
+  const mac = process.platform === "darwin";
+  const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
+  const list: ClientConfig[] = [
+    {
+      id: "claude",
+      label: "Claude Desktop",
+      path: mac
+        ? join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")
+        : join(appData, "Claude", "claude_desktop_config.json"),
+      serversKey: "mcpServers",
+    },
+    {
+      id: "cursor",
+      label: "Cursor",
+      path: join(home, ".cursor", "mcp.json"),
+      serversKey: "mcpServers",
+    },
+    {
+      id: "windsurf",
+      label: "Windsurf",
+      path: join(home, ".codeium", "windsurf", "mcp_config.json"),
+      serversKey: "mcpServers",
+    },
+    {
+      id: "vscode",
+      label: "VS Code (Copilot)",
+      path: mac
+        ? join(home, "Library", "Application Support", "Code", "User", "mcp.json")
+        : join(appData, "Code", "User", "mcp.json"),
+      serversKey: "servers",
+    },
+  ];
+  return list;
+}
+
+/** Build the args a client should launch the server with. */
+function serverArgs(binPath: string, repoRoot: string | undefined, req: { write: boolean; destructive: boolean }): string[] {
+  const args = [binPath];
+  if (repoRoot) {
+    args.push("--repo", repoRoot);
+  }
+  if (req.destructive) {
+    args.push("--allow-destructive");
+  } else if (req.write) {
+    args.push("--write");
+  }
+  return args;
+}
+
+function readJson(path: string): Record | undefined {
+  try {
+    return JSON.parse(readFileSync(path, "utf8")) as Record;
+  } catch {
+    return undefined;
+  }
+}
+
+/** Is GitStudio's server already present in a client's config? */
+function isInstalled(cfg: ClientConfig): boolean {
+  const json = readJson(cfg.path);
+  if (!json) {
+    return false;
+  }
+  const servers = json[cfg.serversKey];
+  return !!servers && typeof servers === "object" && "gitstudio" in (servers as Record);
+}
+
+export function mcpInfo(repoRoot: string | undefined): McpInfo {
+  const binPath = resolveMcpBin();
+  const args = serverArgs(binPath, repoRoot, { write: false, destructive: false });
+  const snippet = JSON.stringify(
+    { mcpServers: { gitstudio: { command: "node", args } } },
+    null,
+    2,
+  );
+  const clients: McpClientInfo[] = clientConfigs().map((c) => ({
+    id: c.id,
+    label: c.label,
+    installed: isInstalled(c),
+    configPath: c.path,
+  }));
+  return {
+    binPath,
+    command: "node",
+    args,
+    configSnippet: snippet,
+    clients,
+    repoRoot,
+    available: !!binPath && existsSync(binPath),
+  };
+}
+
+export function installMcp(
+  repoRoot: string | undefined,
+  req: McpInstallRequest,
+): { ok: boolean; message: string } {
+  const cfg = clientConfigs().find((c) => c.id === req.client);
+  if (!cfg) {
+    return { ok: false, message: `Unknown client: ${req.client}.` };
+  }
+  const binPath = resolveMcpBin();
+  if (!binPath || !existsSync(binPath)) {
+    return { ok: false, message: "The MCP server isn't built yet (apps/mcp/dist/index.js)." };
+  }
+  const entry = { command: "node", args: serverArgs(binPath, repoRoot, req) };
+  try {
+    mkdirSync(dirname(cfg.path), { recursive: true });
+    const json = readJson(cfg.path) ?? {};
+    const servers = (json[cfg.serversKey] && typeof json[cfg.serversKey] === "object"
+      ? json[cfg.serversKey]
+      : {}) as Record;
+    servers.gitstudio = entry;
+    json[cfg.serversKey] = servers;
+    writeFileSync(cfg.path, JSON.stringify(json, null, 2));
+    const mode = req.destructive ? "read + write + destructive" : req.write ? "read + write" : "read-only";
+    return { ok: true, message: `Added GitStudio (${mode}) to ${cfg.label}. Restart ${cfg.label} to pick it up.` };
+  } catch (err) {
+    return { ok: false, message: err instanceof Error ? err.message : String(err) };
+  }
+}
diff --git a/apps/desktop/src/main/repoStore.ts b/apps/desktop/src/main/repoStore.ts
new file mode 100644
index 0000000..cdffe38
--- /dev/null
+++ b/apps/desktop/src/main/repoStore.ts
@@ -0,0 +1,121 @@
+// Owns the open repository (a cached GitContext) plus the recent-repos list.
+// Repo discovery goes through NodeGitAdapter — the portable HostGitAdapter the
+// git-service ships — exactly as the brief specifies. Nothing here is
+// Electron-specific beyond the persistence path, so the data layer stays the
+// same one the extension uses.
+
+import { basename } 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";
+
+const MAX_RECENT = 12;
+
+export class RepoStore {
+  private readonly adapter = new NodeGitAdapter();
+  private context: GitContext | undefined;
+  private currentRoot: string | undefined;
+  /** Monotonic token so an out-of-order `open()` can't clobber a newer one. */
+  private openSeq = 0;
+  private recent: string[] = [];
+  /** Observer wired by main.ts: fires for every git command the open repo runs,
+   *  so the renderer's Output tab can show a live git-command log. */
+  onGitRun?: GitRunHook;
+
+  /** Listeners fired when the active repo changes (the main process re-emits). */
+  private readonly listeners = new Set<(info: RepoInfo | undefined) => void>();
+
+  constructor(recent: string[] = []) {
+    this.recent = recent.slice(0, MAX_RECENT);
+  }
+
+  onChange(fn: (info: RepoInfo | undefined) => void): void {
+    this.listeners.add(fn);
+  }
+
+  /** The cached GitContext for the open repo, or undefined when none is open. */
+  getContext(): GitContext | undefined {
+    return this.context;
+  }
+
+  current(): RepoInfo | undefined {
+    return this.currentRoot ? toInfo(this.currentRoot) : undefined;
+  }
+
+  recentRepos(): RepoInfo[] {
+    return this.recent.map(toInfo);
+  }
+
+  /** Serializable state to persist between sessions. */
+  serialize(): { recent: string[]; current?: string } {
+    return { recent: this.recent, current: this.currentRoot };
+  }
+
+  /**
+   * Discover the repo root for `cwd` (a folder the user picked or a recent
+   * entry), create + cache its GitContext, and make it the active repo. Returns
+   * the opened RepoInfo, or undefined when `cwd` is not inside a git repo.
+   */
+  async open(cwd: string): Promise {
+    const seq = ++this.openSeq;
+    const root = await this.adapter.discoverRepoRoot(cwd);
+    // A newer open() began while we were discovering the root — let it win, and
+    // touch no shared state here (otherwise we'd leave the UI on one repo and the
+    // active context on another).
+    if (seq !== this.openSeq) {
+      return root ? toInfo(root) : undefined;
+    }
+    if (!root) {
+      return undefined;
+    }
+    if (root === this.currentRoot && this.context) {
+      this.promoteRecent(root);
+      return toInfo(root);
+    }
+    this.context?.dispose();
+    this.context = new GitContext({
+      root,
+      gitPath: this.adapter.gitPath(),
+      onRun: (e) => this.onGitRun?.(e),
+    });
+    this.currentRoot = root;
+    this.promoteRecent(root);
+    const info = toInfo(root);
+    this.emit(info);
+    return info;
+  }
+
+  close(): void {
+    this.openSeq++; // supersede any in-flight open() so it can't re-open after close
+    if (!this.context && !this.currentRoot) {
+      return;
+    }
+    this.context?.dispose();
+    this.context = undefined;
+    this.currentRoot = undefined;
+    this.emit(undefined);
+  }
+
+  dispose(): void {
+    this.context?.dispose();
+    this.context = undefined;
+    this.listeners.clear();
+  }
+
+  private promoteRecent(root: string): void {
+    this.recent = [root, ...this.recent.filter((r) => r !== root)].slice(
+      0,
+      MAX_RECENT,
+    );
+  }
+
+  private emit(info: RepoInfo | undefined): void {
+    for (const fn of this.listeners) {
+      fn(info);
+    }
+  }
+}
+
+function toInfo(root: string): RepoInfo {
+  return { root, name: basename(root) || root };
+}
diff --git a/apps/desktop/src/main/terminalBridge.ts b/apps/desktop/src/main/terminalBridge.ts
new file mode 100644
index 0000000..3698ffe
--- /dev/null
+++ b/apps/desktop/src/main/terminalBridge.ts
@@ -0,0 +1,118 @@
+// Integrated-terminal backend — a thin manager over node-pty PTY sessions.
+//
+// CONTRACT (do not change these signatures — main.ts + the renderer depend on
+// them): construct with a `send` that forwards events to the renderer's
+// webContents; `create` spawns an OS-appropriate login shell in `cwd` and
+// streams its output back via the `terminal:data` event, emitting `terminal:exit`
+// when it ends. node-pty is required LAZILY (it's a native module marked external
+// in esbuild) so a missing/unbuilt binary degrades to `create` returning
+// undefined rather than crashing the main process at import time.
+
+import type { IPty } from "node-pty";
+import type { TerminalSession } from "../shared/ipc";
+
+/** Forwards a host→renderer IPC event (bound to the window's webContents). */
+export type SendEvent = (channel: string, payload: unknown) => void;
+
+/** The slice of node-pty we use — kept minimal so the lazy require stays typed. */
+interface PtyModule {
+  spawn(
+    file: string,
+    args: string[] | string,
+    options: {
+      name?: string;
+      cols?: number;
+      rows?: number;
+      cwd?: string;
+      env?: NodeJS.ProcessEnv;
+    },
+  ): IPty;
+}
+
+// Lazy native require: node-pty ships N-API prebuilds, but if the binary is
+// missing/unbuilt we want graceful degradation (no terminal) rather than a crash
+// at import time. `require` is used directly so esbuild keeps it external.
+let pty: PtyModule | undefined;
+try {
+  // eslint-disable-next-line @typescript-eslint/no-var-requires
+  pty = require("node-pty") as PtyModule;
+} catch {
+  pty = undefined;
+}
+
+let counter = 0;
+
+export class TerminalBridge {
+  private readonly sessions = new Map();
+
+  constructor(private readonly send: SendEvent) {}
+
+  /** Spawn a PTY login shell in `cwd`; returns the session, or undefined when
+   *  node-pty is unavailable. Streams output via `terminal:data`. */
+  create(opts: { cols: number; rows: number }, cwd: string | undefined): TerminalSession | undefined {
+    if (!pty) return undefined;
+
+    const shell =
+      process.platform === "win32"
+        ? process.env.COMSPEC || "powershell.exe"
+        : process.env.SHELL || "/bin/bash";
+
+    const id = `t${++counter}`;
+
+    let p: IPty;
+    try {
+      p = pty.spawn(shell, [], {
+        name: "xterm-color",
+        cols: opts.cols,
+        rows: opts.rows,
+        cwd: cwd || process.env.HOME || process.cwd(),
+        env: process.env,
+      });
+    } catch {
+      return undefined;
+    }
+
+    p.onData((data) => this.send("terminal:data", { id, data }));
+    p.onExit(({ exitCode }) => {
+      this.send("terminal:exit", { id, exitCode });
+      this.sessions.delete(id);
+    });
+
+    this.sessions.set(id, p);
+    return { id, shell };
+  }
+
+  /** Write user input to a session's PTY. */
+  write(id: string, data: string): void {
+    this.sessions.get(id)?.write(data);
+  }
+
+  /** Resize a session's PTY to the renderer's measured grid. */
+  resize(id: string, cols: number, rows: number): void {
+    if (cols < 1 || rows < 1) return;
+    const session = this.sessions.get(id);
+    if (!session) return;
+    try {
+      session.resize(cols, rows);
+    } catch {
+      // The PTY may have exited between measure and resize — ignore.
+    }
+  }
+
+  /** Kill one session. */
+  kill(id: string): void {
+    const session = this.sessions.get(id);
+    if (!session) return;
+    try {
+      session.kill();
+    } catch {
+      // Already gone.
+    }
+    this.sessions.delete(id);
+  }
+
+  /** Kill every live session (on window close / repo switch). */
+  killAll(): void {
+    for (const id of [...this.sessions.keys()]) this.kill(id);
+  }
+}
diff --git a/apps/desktop/src/main/warmCliSession.ts b/apps/desktop/src/main/warmCliSession.ts
new file mode 100644
index 0000000..d0ce4c5
--- /dev/null
+++ b/apps/desktop/src/main/warmCliSession.ts
@@ -0,0 +1,234 @@
+// A warm, long-lived Claude Code session: one `claude` process kept alive for a
+// chat, fed messages over stdin (stream-json input) and streaming responses back
+// over stdout. The first message pays the cold start; every later message in the
+// same chat is fast because the process — and Claude Code's session — is still
+// resident. It lives in the MAIN process, so a renderer refresh reconnects to it
+// rather than killing it.
+//
+// On idle it disposes itself; the next message respawns and resumes the prior
+// conversation via `--resume `, so context survives even a full app
+// restart (the session id is persisted by the caller).
+
+import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
+
+/** Idle a warm session for this long before disposing the process. */
+const IDLE_MS = 5 * 60 * 1000;
+
+export interface WarmSessionOptions {
+  cwd: string | undefined;
+  model?: string;
+  /** Resume an existing Claude Code session (after an app restart). */
+  resumeId?: string;
+  /** Fired when the process exits (so the owner can drop its reference). */
+  onExit?: () => void;
+}
+
+export interface SendHandlers {
+  onDelta: (text: string) => void;
+  signal?: AbortSignal;
+}
+
+interface ClaudeLine {
+  type?: string;
+  session_id?: string;
+  result?: string;
+  is_error?: boolean;
+  event?: { type?: string; delta?: { type?: string; text?: string } };
+  message?: { content?: Array<{ type?: string; text?: string }> };
+}
+
+const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g;
+
+export class WarmCliSession {
+  private proc?: ChildProcessWithoutNullStreams;
+  private sessionId?: string;
+  private buffer = "";
+  private busy = false;
+  private idleTimer?: NodeJS.Timeout;
+  /** Resolver for the in-flight turn (resolved on the `result` event). */
+  private active?: {
+    onDelta: (t: string) => void;
+    resolve: (text: string) => void;
+    reject: (e: Error) => void;
+    text: string;
+    streamed: boolean;
+    final: string;
+  };
+
+  constructor(private readonly opts: WarmSessionOptions) {}
+
+  /** Claude Code's session id for this chat (persist it to resume later). */
+  get id(): string | undefined {
+    return this.sessionId;
+  }
+
+  /** Whether the process is alive (a message will be warm rather than cold). */
+  get warm(): boolean {
+    return !!this.proc && !this.proc.killed;
+  }
+
+  /** Send one user message; streams assistant text via onDelta; resolves on completion. */
+  send(text: string, handlers: SendHandlers): Promise {
+    if (this.busy) {
+      return Promise.reject(new Error("The session is still answering the previous message."));
+    }
+    this.clearIdle();
+    if (!this.warm) {
+      this.spawn();
+    }
+    const proc = this.proc;
+    if (!proc) {
+      return Promise.reject(new Error("Couldn't start the Claude Code session."));
+    }
+    this.busy = true;
+    return new Promise((resolve, reject) => {
+      this.active = { onDelta: handlers.onDelta, resolve, reject, text: "", streamed: false, final: "" };
+      const onAbort = () => {
+        // Cancelling a turn means killing the process (Claude Code has no
+        // mid-turn cancel over stdin); the next message respawns + resumes.
+        this.dispose();
+      };
+      if (handlers.signal) {
+        if (handlers.signal.aborted) onAbort();
+        else handlers.signal.addEventListener("abort", onAbort, { once: true });
+      }
+      try {
+        proc.stdin.write(JSON.stringify({ type: "user", message: { role: "user", content: text } }) + "\n");
+      } catch (err) {
+        this.settle(reject, err instanceof Error ? err : new Error(String(err)));
+      }
+    });
+  }
+
+  dispose(): void {
+    this.clearIdle();
+    const p = this.proc;
+    this.proc = undefined;
+    if (p && !p.killed) {
+      try {
+        p.stdin.end();
+      } catch {
+        /* ignore */
+      }
+      p.kill("SIGTERM");
+    }
+    // Fail any in-flight turn.
+    if (this.active && this.busy) {
+      const { reject } = this.active;
+      this.active = undefined;
+      this.busy = false;
+      reject(new Error("Session cancelled."));
+    }
+  }
+
+  // ── internals ──
+
+  private spawn(): void {
+    const args = [
+      "-p",
+      "--strict-mcp-config",
+      ...(this.opts.model ? ["--model", this.opts.model] : []),
+      ...(this.sessionId || this.opts.resumeId ? ["--resume", (this.sessionId ?? this.opts.resumeId) as string] : []),
+      "--input-format",
+      "stream-json",
+      "--output-format",
+      "stream-json",
+      "--verbose",
+      "--include-partial-messages",
+    ];
+    const proc = spawn("claude", args, {
+      cwd: this.opts.cwd,
+      env: process.env,
+      stdio: ["pipe", "pipe", "pipe"],
+    });
+    this.proc = proc;
+    this.buffer = "";
+    proc.stdout.setEncoding("utf8");
+    proc.stdout.on("data", (d: string) => this.onStdout(d));
+    proc.stderr.on("data", () => {
+      /* swallow logs; failures surface via close code */
+    });
+    proc.on("error", (err: NodeJS.ErrnoException) => {
+      const e = err.code === "ENOENT" ? new Error("The `claude` CLI isn't installed or not on PATH.") : err;
+      if (this.active && this.busy) this.settle(this.active.reject, e);
+    });
+    proc.on("close", () => {
+      this.proc = undefined;
+      if (this.active && this.busy) {
+        // Process died mid-turn: resolve with whatever streamed (or fail).
+        const a = this.active;
+        this.active = undefined;
+        this.busy = false;
+        if (a.streamed || a.final) a.resolve((a.text || a.final).trim());
+        else a.reject(new Error("The Claude Code session ended unexpectedly."));
+      }
+      this.opts.onExit?.();
+    });
+  }
+
+  private onStdout(chunk: string): void {
+    this.buffer += chunk;
+    let nl: number;
+    while ((nl = this.buffer.indexOf("\n")) !== -1) {
+      const line = this.buffer.slice(0, nl).trim();
+      this.buffer = this.buffer.slice(nl + 1);
+      if (line) this.onLine(line);
+    }
+  }
+
+  private onLine(line: string): void {
+    let o: ClaudeLine;
+    try {
+      o = JSON.parse(line) as ClaudeLine;
+    } catch {
+      return;
+    }
+    if (o.session_id && !this.sessionId) {
+      this.sessionId = o.session_id;
+    }
+    const a = this.active;
+    if (!a) return;
+
+    if (o.type === "stream_event" && o.event?.type === "content_block_delta") {
+      const d = o.event.delta;
+      if (d?.type === "text_delta" && typeof d.text === "string") {
+        const t = d.text.replace(ANSI, "");
+        a.text += t;
+        a.streamed = true;
+        a.onDelta(t);
+      }
+      return;
+    }
+    if (o.type === "assistant" && Array.isArray(o.message?.content)) {
+      const t = o.message!.content
+        .filter((b) => b.type === "text" && typeof b.text === "string")
+        .map((b) => b.text as string)
+        .join("");
+      if (t) a.final = t;
+      return;
+    }
+    if (o.type === "result") {
+      if (!a.streamed && a.final) a.onDelta(a.final.replace(ANSI, ""));
+      const out = (a.streamed ? a.text : a.final).trim();
+      this.settle(a.resolve, out);
+    }
+  }
+
+  private settle(fn: (v: never) => void, value: unknown): void {
+    this.active = undefined;
+    this.busy = false;
+    this.scheduleIdle();
+    (fn as (v: unknown) => void)(value);
+  }
+
+  private scheduleIdle(): void {
+    this.clearIdle();
+    this.idleTimer = setTimeout(() => this.dispose(), IDLE_MS);
+  }
+  private clearIdle(): void {
+    if (this.idleTimer) {
+      clearTimeout(this.idleTimer);
+      this.idleTimer = undefined;
+    }
+  }
+}
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
new file mode 100644
index 0000000..5101727
--- /dev/null
+++ b/apps/desktop/src/preload/preload.ts
@@ -0,0 +1,36 @@
+// Preload — the only code with one foot in Node and one in the page. It exposes
+// a minimal, typed `window.gitstudio` surface over the contextBridge: an
+// `invoke` that forwards to the main process's `ipcMain.handle` endpoints, and
+// an `on` that subscribes to host-pushed events. No Node primitive (fs, child
+// process, the GitContext) ever leaks to the renderer — the page only ever sees
+// these two functions. This IS the renderer-facing HostBridge.
+
+import { contextBridge, ipcRenderer } from "electron";
+import type { IpcRendererEvent } from "electron";
+import type {
+  GitStudioBridge,
+  IpcChannel,
+  IpcEvent,
+  IpcEvents,
+  IpcRequest,
+  IpcResponse,
+} from "../shared/ipc";
+
+const bridge: GitStudioBridge = {
+  invoke(
+    channel: C,
+    payload: IpcRequest,
+  ): Promise> {
+    return ipcRenderer.invoke(channel, payload) as Promise>;
+  },
+  on(
+    event: E,
+    listener: (data: IpcEvents[E]) => void,
+  ): () => void {
+    const handler = (_e: IpcRendererEvent, data: IpcEvents[E]) => listener(data);
+    ipcRenderer.on(event, handler);
+    return () => ipcRenderer.removeListener(event, handler);
+  },
+};
+
+contextBridge.exposeInMainWorld("gitstudio", bridge);
diff --git a/apps/desktop/src/renderer/aiAssist.ts b/apps/desktop/src/renderer/aiAssist.ts
new file mode 100644
index 0000000..f9ae206
--- /dev/null
+++ b/apps/desktop/src/renderer/aiAssist.ts
@@ -0,0 +1,123 @@
+// Reusable inline-AI affordances surfaced across the app (Changes, Compare, PRs,
+// Issues): a ✨ chip button, a helper that opens a named, conversational AI chat
+// tab in the footer dock (Explain / Review / Analyze / Draft a comment), and a
+// "stream straight into a textarea" helper (commit messages / comment drafts).
+// The chat tabs run on the persistent agent backend (ai:chat*); the textarea
+// helper runs the one-shot ai:task. Both reuse the Assistant's model + keys.
+
+import { host } from "./bridge";
+import { el, span, glyph, cleanErr } from "./ui";
+import { toast } from "./dialogs";
+import type { AssistantTabRequest } from "./terminalDock";
+import type { AiTaskInput, AiTaskName } from "../shared/ipc";
+
+let enabledCache: boolean | undefined;
+
+/** Whether any AI model is connected (gates the ✨ affordances). Cached. */
+export async function aiEnabled(): Promise {
+  if (enabledCache !== undefined) return enabledCache;
+  try {
+    enabledCache = (await host.invoke("ai:settings", undefined)).enabled;
+  } catch {
+    enabledCache = false;
+  }
+  return enabledCache;
+}
+
+/** Drop the cached enabled-state so the next aiEnabled() re-checks. Call after any
+ *  change to model connections (connect / remove / set key) so the ✨ affordances
+ *  appear or disappear without a full reload. */
+export function invalidateAiEnabled(): void {
+  enabledCache = undefined;
+}
+
+/** A small ✨ action chip. */
+export function aiChip(label: string, onClick: () => void, icon = "sparkle"): HTMLElement {
+  const b = el("button", "ai-chip");
+  b.append(glyph(icon), span(label));
+  b.addEventListener("click", onClick);
+  return b;
+}
+
+/** Run a task, streaming text deltas through onDelta; resolves with the outcome. */
+export async function streamTask(
+  task: AiTaskName,
+  input: AiTaskInput,
+  onDelta: (text: string) => void,
+  signal?: AbortSignal,
+): Promise<{ ok: boolean; text?: string; message?: string }> {
+  const requestId = crypto.randomUUID();
+  const off = host.on("ai:delta", (e) => {
+    if (e.requestId === requestId) onDelta(e.delta);
+  });
+  if (signal) {
+    signal.addEventListener("abort", () => void host.invoke("ai:cancel", { requestId }), { once: true });
+  }
+  try {
+    return await host.invoke("ai:task", { requestId, task, input });
+  } catch (e) {
+    return { ok: false, message: cleanErr(e) };
+  } finally {
+    off();
+  }
+}
+
+// ── Footer AI chat tabs ──────────────────────────────────────────────────────
+// The ✨ Explain / Review / Analyze / Draft actions open a named, conversational
+// tab in the footer dock instead of a dead-end modal. aiAssist stays decoupled
+// from the dock: the shell registers the opener once the dock is mounted.
+
+let tabOpener: ((req: AssistantTabRequest) => void) | undefined;
+
+/** Register the footer-dock opener. The shell calls this once after creating the
+ *  TerminalDock, so aiAssist need not import it (avoids a layering cycle). */
+export function registerAssistantTab(open: (req: AssistantTabRequest) => void): void {
+  tabOpener = open;
+}
+
+/** Open a named, seeded AI chat tab in the footer dock. Falls back to a toast if
+ *  no dock is mounted yet (e.g. before a repository is open). */
+export function openAssistantTab(req: AssistantTabRequest): void {
+  if (tabOpener) tabOpener(req);
+  else toast("Open a repository to use the Assistant.", "info");
+}
+
+/**
+ * Stream a task's result directly into a textarea (commit messages, comment
+ * drafts). Disables the trigger button while running; replaces the field's text.
+ */
+export async function streamInto(
+  task: AiTaskName,
+  input: AiTaskInput,
+  textarea: HTMLTextAreaElement,
+  btn?: HTMLButtonElement,
+): Promise {
+  const original = btn?.innerHTML;
+  if (btn) {
+    btn.disabled = true;
+    btn.replaceChildren(glyph("loading"), span("Writing…"));
+  }
+  const prev = textarea.value;
+  textarea.value = "";
+  let got = false;
+  const res = await streamTask(task, input, (d) => {
+    got = true;
+    textarea.value += d;
+    textarea.dispatchEvent(new Event("input", { bubbles: true }));
+  });
+  if (btn) {
+    btn.disabled = false;
+    if (original) btn.innerHTML = original;
+  }
+  if (!res.ok || (!got && !res.text)) {
+    textarea.value = prev; // restore on failure
+    toast(res.message ?? "Couldn't generate that.", "error");
+    return;
+  }
+  if (!got && res.text) {
+    textarea.value = res.text;
+    textarea.dispatchEvent(new Event("input", { bubbles: true }));
+  }
+  textarea.value = textarea.value.trim();
+  textarea.focus();
+}
diff --git a/apps/desktop/src/renderer/aiSettings.ts b/apps/desktop/src/renderer/aiSettings.ts
new file mode 100644
index 0000000..7851972
--- /dev/null
+++ b/apps/desktop/src/renderer/aiSettings.ts
@@ -0,0 +1,477 @@
+// Settings cards for the two AI features:
+//   • AI Models      — connect any model platform or a local server (BYO-key /
+//                      keyless local), manage connections, set the default.
+//   • Agent Access   — point an external agent (Claude Desktop, Cursor, Copilot,
+//                      Windsurf) at GitStudio's MCP server for this repo, with a
+//                      least-privilege permission choice.
+//
+// The renderer never holds an API key: it sends one to the main process via
+// ai:setKey and only ever reads back redacted views (hasKey/usable booleans).
+
+import { host } from "./bridge";
+import { el, span, glyph, settingsCard, settingsField, copyText, pill, cleanErr, runBusy } from "./ui";
+import { providerLogo } from "./providerLogos";
+import { invalidateAiEnabled } from "./aiAssist";
+import { toast, confirmDialog, promptInline } from "./dialogs";
+import { trapTab } from "./views/common";
+import type { AiConnectionView, AiPresetView, AiSettingsView, McpInfo } from "../shared/ipc";
+
+/** The "AI Models" card: manage model connections. */
+export function aiModelsCard(): HTMLElement {
+  const { card, body } = settingsCard("AI Models", "sparkle");
+
+  const render = async (): Promise => {
+    // Every connect / remove / set-key / set-default re-renders this card, so this
+    // is the one chokepoint that busts the inline-AI gate's cached enabled-state.
+    invalidateAiEnabled();
+    body.replaceChildren();
+    const sub = el("div", "settings-sub");
+    sub.textContent =
+      "Connect any model to power the ✨ helpers and the Assistant — bring your own key, or run a local model (Ollama / LM Studio) that never leaves your machine. AI is optional and never blocks Git.";
+    body.append(sub);
+
+    let settings: AiSettingsView;
+    try {
+      settings = await host.invoke("ai:settings", undefined);
+    } catch (e) {
+      body.append(errorLine(cleanErr(e)));
+      return;
+    }
+
+    if (settings.connections.length === 0) {
+      const empty = el("div", "settings-empty");
+      empty.textContent = "No models connected yet.";
+      body.append(empty);
+    } else {
+      const list = el("div", "ai-conn-list");
+      for (const c of settings.connections) {
+        list.append(connectionRow(c, settings.defaultId === c.id, render));
+      }
+      body.append(list);
+    }
+
+    const add = el("button", "btn btn-primary ai-add-btn");
+    add.append(glyph("add"), span("Connect a model"));
+    add.addEventListener("click", () => openGallery(body, render));
+    body.append(add);
+  };
+
+  void render();
+  return card;
+}
+
+/** One connection row with inline expand-to-edit. */
+function connectionRow(c: AiConnectionView, isDefault: boolean, refresh: () => Promise): HTMLElement {
+  const row = el("div", "ai-conn");
+  const head = el("div", "ai-conn-head");
+  head.append(providerLogo(c.preset) ?? glyph(iconForWire(c)));
+
+  const meta = el("div", "ai-conn-meta");
+  const top = el("div", "ai-conn-name");
+  top.append(span(c.label));
+  if (isDefault) top.append(pill("Default", "is-default"));
+  if (c.local) top.append(pill("Local", "is-local"));
+  const bottom = el("div", "ai-conn-sub");
+  bottom.textContent = `${c.models.mid || c.models.fast || "no model set"} · ${hostLabel(c.baseUrl)}`;
+  meta.append(top, bottom);
+  head.append(meta);
+
+  const status = c.usable
+    ? pill("Ready", "is-ready")
+    : c.needsKey && !c.hasKey
+      ? pill("Needs key", "is-warn")
+      : pill("Incomplete", "is-warn");
+  head.append(status);
+
+  const actions = el("div", "ai-conn-actions");
+  if (!isDefault && c.usable) {
+    const star = iconBtn("star-empty", "Set as default");
+    star.addEventListener("click", () =>
+      void runBusy(star, async () => {
+        await host.invoke("ai:setDefault", { id: c.id });
+        void refresh();
+      }),
+    );
+    actions.append(star);
+  }
+  const edit = iconBtn("gear", "Configure");
+  const remove = iconBtn("trash", "Remove");
+  actions.append(edit, remove);
+  head.append(actions);
+  row.append(head);
+
+  // Inline editor (hidden until "Configure").
+  const editor = el("div", "ai-conn-editor");
+  editor.hidden = true;
+  edit.addEventListener("click", () => {
+    if (editor.hidden) {
+      buildEditor(editor, c, refresh);
+      editor.hidden = false;
+    } else {
+      editor.hidden = true;
+    }
+  });
+  remove.addEventListener("click", async () => {
+    const ok = await confirmDialog({
+      title: "Remove model",
+      message: `Remove “${c.label}”? Its stored API key will be deleted from this machine.`,
+      confirmLabel: "Remove",
+      danger: true,
+    });
+    if (!ok) return;
+    await host.invoke("ai:removeConnection", { id: c.id });
+    toast("Model removed.", "info");
+    void refresh();
+  });
+  row.append(editor);
+  return row;
+}
+
+function buildEditor(editor: HTMLElement, c: AiConnectionView, refresh: () => Promise): void {
+  editor.replaceChildren();
+  const isCli = c.wire === "cli";
+
+  const labelF = settingsField("Name", c.label, "My Claude");
+  // CLI connections have no base URL or API key — they use the local binary's own
+  // login. They just take optional model overrides (mapped to `--model`).
+  const urlF = settingsField("API base URL", c.baseUrl, "https://api.example.com/v1");
+  const fastF = settingsField(isCli ? "Quick model (optional)" : "Fast model", c.models.fast, "e.g. haiku");
+  const midF = settingsField(isCli ? "Default model (optional)" : "Standard model", c.models.mid, "e.g. sonnet");
+  const deepF = settingsField(isCli ? "Deep model (optional)" : "Deep model", c.models.deep, "e.g. opus");
+  editor.append(labelF.row);
+  if (isCli) {
+    const note = el("div", "settings-sub");
+    note.textContent = "Runs your local CLI with its own login — no API key. Model names map to the CLI's --model flag (leave blank to use its default).";
+    editor.append(note, fastF.row, midF.row, deepF.row);
+  } else {
+    editor.append(urlF.row, fastF.row, midF.row, deepF.row);
+  }
+
+  if (c.needsKey) {
+    const keyRow = el("div", "settings-field");
+    const kl = el("label", "settings-field-label");
+    kl.textContent = "API key";
+    const keyInput = document.createElement("input");
+    keyInput.type = "password";
+    keyInput.className = "settings-input";
+    keyInput.placeholder = c.hasKey ? "•••••••• (stored — leave blank to keep)" : "Paste your API key";
+    keyRow.append(kl, keyInput);
+    editor.append(keyRow);
+
+    const saveKey = el("button", "mini-btn");
+    saveKey.append(glyph("key"), span(c.hasKey ? "Update key" : "Save key"));
+    saveKey.addEventListener("click", () => {
+      if (!keyInput.value.trim()) {
+        toast("Enter a key first.", "info");
+        return;
+      }
+      void runBusy(saveKey, async () => {
+        await host.invoke("ai:setKey", { id: c.id, key: keyInput.value.trim() });
+        keyInput.value = "";
+        toast("Key stored securely.", "success");
+        void refresh();
+      });
+    });
+    editor.append(saveKey);
+  }
+
+  const actions = el("div", "settings-actions");
+  const save = el("button", "btn btn-primary");
+  save.append(glyph("check"), span("Save"));
+  save.addEventListener("click", () =>
+    void runBusy(save, async () => {
+      await host.invoke("ai:updateConnection", {
+        id: c.id,
+        label: labelF.input.value.trim() || c.label,
+        baseUrl: urlF.input.value.trim(),
+        models: { fast: fastF.input.value.trim(), mid: midF.input.value.trim(), deep: deepF.input.value.trim() },
+      });
+      toast("Saved.", "success");
+      void refresh();
+    }),
+  );
+
+  const test = el("button", "mini-btn");
+  test.append(glyph("debug-start"), span("Test"));
+  test.addEventListener("click", async () => {
+    (test as HTMLButtonElement).disabled = true;
+    test.replaceChildren(glyph("loading"), span("Testing…"));
+    try {
+      const r = await host.invoke("ai:test", { id: c.id });
+      toast(r.message, r.ok ? "success" : "error");
+    } catch (e) {
+      toast(cleanErr(e), "error");
+    } finally {
+      (test as HTMLButtonElement).disabled = false;
+      test.replaceChildren(glyph("debug-start"), span("Test"));
+    }
+  });
+
+  actions.append(save, test);
+  editor.append(actions);
+}
+
+/**
+ * The "connect a provider" gallery — grouped into clearly-separated sections so
+ * the no-key local options (your CLI login / a local model server) are distinct
+ * from the cloud providers that need an API key.
+ */
+async function openGallery(body: HTMLElement, refresh: () => Promise): Promise {
+  let presets: AiPresetView[] = [];
+  try {
+    presets = await host.invoke("ai:catalog", undefined);
+  } catch {
+    presets = [];
+  }
+  const overlay = el("div", "ai-gallery-pop");
+  overlay.setAttribute("role", "dialog");
+  overlay.setAttribute("aria-modal", "true");
+  overlay.setAttribute("aria-label", "Connect a model");
+  // Electron: an overlay over a -webkit-app-region:drag surface needs no-drag or
+  // its controls aren't clickable.
+  overlay.style.setProperty("-webkit-app-region", "no-drag");
+  const prevFocus = document.activeElement as HTMLElement | null;
+  const closeOverlay = (): void => {
+    overlay.remove();
+    document.removeEventListener("keydown", onKey, true);
+    prevFocus?.focus?.();
+  };
+  const onKey = (e: KeyboardEvent): void => {
+    if (e.key === "Escape") {
+      e.preventDefault();
+      closeOverlay();
+      return;
+    }
+    trapTab(e, panel);
+  };
+
+  const choose = async (p: AiPresetView): Promise => {
+    closeOverlay();
+    await host.invoke("ai:addConnection", { preset: p.id });
+    const ready = !p.needsKey;
+    toast(`Added ${p.label}. ${ready ? "Ready to use." : "Add your API key to finish."}`, "success");
+    await refresh();
+    // Auto-open the new connection's editor (to paste a key / pick a model).
+    const editors = body.querySelectorAll(".ai-conn");
+    const gear = editors[editors.length - 1]?.querySelector(
+      '.ai-conn-actions [title="Configure"]',
+    );
+    gear?.click();
+  };
+
+  const tile = (p: AiPresetView): HTMLElement => {
+    const card = el("button", "ai-prov-card");
+    card.append(providerLogo(p.id) ?? glyph(p.icon));
+    const t = el("div", "ai-prov-meta");
+    const name = el("div", "ai-prov-name");
+    name.append(span(p.label.replace(/\s*\(local\)$/i, "")));
+    if (p.wire === "cli") name.append(pill("Your login", "is-local"));
+    else if (p.local) name.append(pill("On-device", "is-local"));
+    else if (!p.needsKey) name.append(pill("No key", "is-ready"));
+    const blurb = el("div", "ai-prov-blurb");
+    blurb.textContent = p.blurb;
+    t.append(name, blurb);
+    card.append(t);
+    card.addEventListener("click", () => void choose(p));
+    return card;
+  };
+
+  const section = (title: string, sub: string, items: AiPresetView[]): HTMLElement | null => {
+    if (items.length === 0) return null;
+    const wrap = el("div", "ai-gallery-section");
+    const head = el("div", "ai-gallery-section-head");
+    const h = el("div", "ai-gallery-section-title");
+    h.textContent = title;
+    const s = el("div", "ai-gallery-section-sub");
+    s.textContent = sub;
+    head.append(h, s);
+    const grid = el("div", "ai-gallery");
+    for (const p of items) grid.append(tile(p));
+    wrap.append(head, grid);
+    return wrap;
+  };
+
+  const agents = presets.filter((p) => p.wire === "cli");
+  const localModels = presets.filter((p) => p.local && p.wire !== "cli");
+  const cloud = presets.filter((p) => !p.local && p.wire !== "cli");
+
+  const panel = el("div", "ai-gallery-panel");
+  const ph = el("div", "ai-gallery-head");
+  ph.append(span("Connect a model"));
+  const close = iconBtn("close", "Close");
+  close.addEventListener("click", () => closeOverlay());
+  ph.append(close);
+  panel.append(ph);
+
+  const sections = [
+    section("Use a local agent", "Drive a CLI you've already signed in to — no API key, your own subscription.", agents),
+    section("Run a model locally", "Open models on your own machine — fully private, no key.", localModels),
+    section("Connect with an API key", "Bring your own key from a cloud provider.", cloud),
+  ].filter((x): x is HTMLElement => x !== null);
+  for (const s of sections) panel.append(s);
+
+  overlay.addEventListener("click", (e) => {
+    if (e.target === overlay) closeOverlay();
+  });
+  overlay.append(panel);
+  document.body.append(overlay);
+  document.addEventListener("keydown", onKey, true);
+  // Focus the first card (or the close button) so keyboard users land inside.
+  (panel.querySelector(".ai-prov-card") ?? close).focus();
+}
+
+// ── Agent Access (MCP) card ────────────────────────────────────────────────────
+
+export function agentAccessCard(): HTMLElement {
+  const { card, body } = settingsCard("Agent Access · MCP", "plug");
+  let permission: "read" | "write" | "destructive" = "read";
+
+  const render = async (): Promise => {
+    body.replaceChildren();
+    const sub = el("div", "settings-sub");
+    sub.textContent =
+      "Expose this repository's Git tools to any MCP agent — Claude Desktop, Cursor, Copilot, Windsurf — so it can inspect history, diffs and branches (and, if you allow, commit) grounded in real state. Your repo, your rules.";
+    body.append(sub);
+
+    let info: McpInfo;
+    try {
+      info = await host.invoke("ai:mcpInfo", undefined);
+    } catch (e) {
+      body.append(errorLine(cleanErr(e)));
+      return;
+    }
+
+    if (!info.available) {
+      const warn = el("div", "settings-empty");
+      warn.textContent = "The MCP server isn't built yet. Run `npm run build` in apps/mcp.";
+      body.append(warn);
+    }
+    if (!info.repoRoot) {
+      const warn = el("div", "settings-empty");
+      warn.textContent = "Open a repository to scope the agent's access to it.";
+      body.append(warn);
+    }
+
+    // Permission selector.
+    const permWrap = el("div", "mcp-perm");
+    const permLabel = el("div", "settings-field-label");
+    permLabel.textContent = "What the agent may do";
+    const seg = el("div", "settings-seg");
+    const perms: Array<{ id: typeof permission; label: string }> = [
+      { id: "read", label: "Read-only" },
+      { id: "write", label: "+ Commit & branch" },
+      { id: "destructive", label: "+ Discard & reset" },
+    ];
+    for (const p of perms) {
+      const b = el("button", "settings-seg-btn" + (permission === p.id ? " active" : ""));
+      b.append(span(p.label));
+      b.addEventListener("click", () => {
+        permission = p.id;
+        void render();
+      });
+      seg.append(b);
+    }
+    // Explain what the CURRENT level grants, so the choice is never a guess.
+    const permDesc = el("div", "mcp-perm-desc");
+    const permDescs: Record = {
+      read: "Inspect only — history, diffs, branches and file contents. The agent cannot change your repository.",
+      write: "Everything in Read-only, plus stage, commit and create or switch branches. It can't discard or rewrite existing work.",
+      destructive: "Everything above, plus discard, reset and force operations that can lose uncommitted work or rewrite history.",
+    };
+    permDesc.textContent = permDescs[permission];
+    permWrap.append(permLabel, seg, permDesc);
+    body.append(permWrap);
+    if (permission === "destructive") {
+      const note = el("div", "mcp-danger-note");
+      note.append(glyph("warning"), span("Only enable for an agent you trust — these tools can permanently lose work."));
+      body.append(note);
+    }
+
+    // One-click client install rows.
+    const clients = el("div", "mcp-clients");
+    for (const cl of info.clients) {
+      const r = el("div", "mcp-client");
+      r.append(glyph("plug"));
+      const m = el("div", "mcp-client-meta");
+      const n = el("div", "mcp-client-name");
+      n.append(span(cl.label));
+      if (cl.installed) n.append(pill("Connected", "is-ready"));
+      m.append(n);
+      r.append(m);
+      const btn = el("button", "mini-btn");
+      btn.append(glyph(cl.installed ? "sync" : "add"), span(cl.installed ? "Update" : "Add"));
+      btn.addEventListener("click", () => void runBusy(btn, async () => {
+        try {
+          const res = await host.invoke("ai:mcpInstall", {
+            client: cl.id,
+            write: permission !== "read",
+            destructive: permission === "destructive",
+          });
+          toast(res.message, res.ok ? "success" : "error");
+          if (res.ok) void render();
+        } catch (e) {
+          toast(cleanErr(e), "error");
+        }
+      }));
+      r.append(btn);
+      clients.append(r);
+    }
+    body.append(clients);
+
+    // Manual config snippet (copy).
+    const snippet = buildSnippet(info, permission);
+    const codeWrap = el("div", "mcp-snippet");
+    const codeHead = el("div", "mcp-snippet-head");
+    codeHead.append(span("Or paste this into any MCP client"));
+    const copyBtn = el("button", "icon-btn");
+    copyBtn.title = "Copy config";
+    copyBtn.append(glyph("copy"));
+    copyBtn.addEventListener("click", () => void copyText(snippet, "MCP config copied."));
+    codeHead.append(copyBtn);
+    const pre = el("pre", "mcp-snippet-code");
+    pre.textContent = snippet;
+    codeWrap.append(codeHead, pre);
+    body.append(codeWrap);
+  };
+
+  void render();
+  return card;
+}
+
+function buildSnippet(info: McpInfo, permission: "read" | "write" | "destructive"): string {
+  const args = [info.binPath];
+  if (info.repoRoot) args.push("--repo", info.repoRoot);
+  if (permission === "destructive") args.push("--allow-destructive");
+  else if (permission === "write") args.push("--write");
+  return JSON.stringify({ mcpServers: { gitstudio: { command: "node", args } } }, null, 2);
+}
+
+// ── small helpers ──────────────────────────────────────────────────────────────
+
+function iconBtn(icon: string, title: string): HTMLElement {
+  const b = el("button", "icon-btn");
+  b.title = title;
+  b.append(glyph(icon));
+  return b;
+}
+
+function iconForWire(c: AiConnectionView): string {
+  if (c.local) return "vm";
+  if (c.preset === "openrouter" || c.preset === "together") return "globe";
+  if (c.preset === "groq") return "zap";
+  return "sparkle";
+}
+
+function hostLabel(url: string): string {
+  try {
+    return new URL(url).host || url;
+  } catch {
+    return url || "no endpoint";
+  }
+}
+
+function errorLine(msg: string): HTMLElement {
+  const e = el("div", "settings-empty");
+  e.textContent = msg || "Something went wrong.";
+  return e;
+}
diff --git a/apps/desktop/src/renderer/assistant.ts b/apps/desktop/src/renderer/assistant.ts
new file mode 100644
index 0000000..ab210bc
--- /dev/null
+++ b/apps/desktop/src/renderer/assistant.ts
@@ -0,0 +1,298 @@
+// The Assistant view — an agent that automates Git/dev workflow tasks in the
+// open repository using the user's OWN connected model. It streams the agent's
+// reasoning and every tool call/result live, and asks for explicit approval
+// before any write or destructive action (the human-in-the-loop gate).
+//
+// Rendered as a SectionRender so it slots into the shell's view router with no
+// renderer.ts surgery beyond a nav entry. All listeners are scoped to a single
+// run and torn down when it ends, so navigating away never leaks.
+
+import { host } from "./bridge";
+import { el, span, glyph, openMenu, relTimeISO } from "./ui";
+import type { MenuItem } from "./ui";
+import { runAgentTurn, addBubble, markdownBlock, errorBlock, connectPrompt, elText, setBusy, scrollDown } from "./chatRender";
+import type { SectionRender } from "./views/common";
+import type { AiModelOption, AiSettingsView, ChatView } from "../shared/ipc";
+
+/** 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). */
+let selectedModelId: string | undefined;
+/** Reasoning depth for the Assistant — seeded from the saved agent config. */
+let thinkLevel: "off" | "auto" | "extended" = "auto";
+
+const THINK_OPTS: Array<{ id: "off" | "auto" | "extended"; label: string }> = [
+  { id: "off", label: "No thinking" },
+  { id: "auto", label: "Auto thinking" },
+  { id: "extended", label: "Extended thinking" },
+];
+const ACCESS_OPTS: Array<{ id: "read" | "write" | "destructive"; label: string }> = [
+  { id: "read", label: "Read-only" },
+  { id: "write", label: "Allow commits" },
+  { id: "destructive", label: "Allow everything" },
+];
+const thinkText = (id: string): string => THINK_OPTS.find((o) => o.id === id)?.label ?? "Thinking";
+const accessText = (id: string): string => ACCESS_OPTS.find((o) => o.id === id)?.label ?? "Access";
+/** Trim a long model id for the chip ("anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6"). */
+const shortModel = (id: string): string => id.split("/").pop() ?? id;
+
+const QUICK_ACTIONS: Array<{ icon: string; label: string; goal: string }> = [
+  { icon: "git-commit", label: "Draft a commit", goal: "Draft a commit message for my staged changes and show it to me. Don't commit unless I confirm." },
+  { icon: "list-unordered", label: "Summarize my changes", goal: "Summarize my current working-tree changes in a few bullet points." },
+  { icon: "git-compare", label: "What does this branch add?", goal: "Compare the current branch against main and explain, concisely, what it changes." },
+  { icon: "tag", label: "Draft release notes", goal: "Draft release notes from the commits since the last tag." },
+];
+
+export const renderAssistant: SectionRender = (wrap, nav) => {
+  wrap.classList.add("assistant-view");
+
+  let currentChatId: string | undefined;
+
+  const header = el("div", "assistant-head");
+  const title = el("div", "assistant-title");
+  title.append(glyph("sparkle"), span("Assistant"));
+  const connTag = el("span", "assistant-model");
+  // New-chat + chat-history controls — sessions persist across refresh/restart.
+  const newBtn = el("button", "assistant-iconbtn");
+  newBtn.title = "New chat";
+  newBtn.append(glyph("add"));
+  newBtn.addEventListener("click", () => void newChat());
+  const histBtn = el("button", "assistant-iconbtn");
+  histBtn.title = "Chat history";
+  histBtn.append(glyph("history"));
+  histBtn.addEventListener("click", () => void openHistory());
+  header.append(title, connTag, newBtn, histBtn);
+
+  // Three compact dropdown "chips" — the agent's options shown directly here and
+  // propagated from the connected provider (no Settings setup needed). Each pick
+  // is remembered (persisted to the agent config).
+  const controls = el("div", "assistant-controls");
+
+  /** Build a chip whose menu items are produced fresh each open. */
+  const makeChip = (icon: string, initial: string, items: () => MenuItem[]): { el: HTMLElement; set: (t: string) => void } => {
+    const b = el("button", "assistant-chip-ctl");
+    const ic = glyph(icon);
+    const lab = span(initial, "assistant-chip-label");
+    const car = glyph("chevron-down");
+    car.classList.add("assistant-chip-caret");
+    b.append(ic, lab, car);
+    b.addEventListener("click", () => openMenu(b, items()));
+    return { el: b, set: (t: string) => (lab.textContent = t) };
+  };
+
+  let modelOptions: AiModelOption[] = [];
+  const modelChip = makeChip("sparkle", "Model", () => {
+    if (modelOptions.length === 0) return [{ label: "No models available", disabled: true }];
+    return modelOptions.map((m) => ({
+      label: m.label ?? shortModel(m.id),
+      current: m.id === selectedModelId,
+      onClick: () => {
+        selectedModelId = m.id;
+        modelChip.set(shortModel(m.id));
+        void host.invoke("ai:setAgentConfig", { modelId: m.id });
+      },
+    }));
+  });
+  const thinkChip = makeChip("lightbulb", thinkText(thinkLevel), () =>
+    THINK_OPTS.map((o) => ({
+      label: o.label,
+      current: o.id === thinkLevel,
+      onClick: () => {
+        thinkLevel = o.id;
+        thinkChip.set(o.label);
+        void host.invoke("ai:setAgentConfig", { thinking: o.id });
+      },
+    })),
+  );
+  const accessChip = makeChip("shield", accessText(permission), () =>
+    ACCESS_OPTS.map((o) => ({
+      label: o.label,
+      current: o.id === permission,
+      onClick: () => {
+        permission = o.id;
+        accessChip.set(o.label);
+        void host.invoke("ai:setAgentConfig", { permission: o.id });
+      },
+    })),
+  );
+  controls.append(modelChip.el, thinkChip.el, accessChip.el);
+  header.append(controls);
+
+  const transcript = el("div", "assistant-transcript");
+  const composer = el("div", "assistant-composer");
+  const quick = el("div", "assistant-quick");
+  for (const qa of QUICK_ACTIONS) {
+    const chip = el("button", "assistant-chip");
+    chip.append(glyph(qa.icon), span(qa.label));
+    chip.addEventListener("click", () => void runGoal(qa.goal));
+    quick.append(chip);
+  }
+  const inputRow = el("div", "assistant-input-row");
+  const input = document.createElement("textarea");
+  input.className = "assistant-input";
+  input.rows = 2;
+  input.placeholder = "Ask the agent to do something in this repo…";
+  const send = el("button", "btn btn-primary assistant-send");
+  send.append(glyph("send"));
+  send.title = "Send";
+  inputRow.append(input, send);
+  composer.append(quick, inputRow);
+
+  wrap.append(header, transcript, composer);
+
+  let running = false;
+
+  const empty = el("div", "assistant-empty");
+  empty.append(
+    glyph("sparkle"),
+    elText("div", "assistant-empty-title", "Your repo's AI agent"),
+    elText(
+      "div",
+      "assistant-empty-sub",
+      "It reads real status, diffs and history before acting — and asks before it writes. Try a quick action, or describe a task.",
+    ),
+  );
+  transcript.append(empty);
+
+  // Gate on a usable connection.
+  void (async () => {
+    let settings: AiSettingsView | undefined;
+    try {
+      settings = await host.invoke("ai:settings", undefined);
+    } catch {
+      settings = undefined;
+    }
+    if (!settings || !settings.enabled) {
+      transcript.replaceChildren(connectPrompt(nav));
+      input.disabled = true;
+      (send as HTMLButtonElement).disabled = true;
+      controls.classList.add("is-disabled");
+    } else {
+      const def = settings.connections.find((c) => c.id === settings!.defaultId) ?? settings.connections.find((c) => c.usable);
+      connTag.textContent = def ? `· ${def.label}` : "";
+      // Seed the controls from the saved agent config.
+      permission = settings.agent.permission;
+      thinkLevel = settings.agent.thinking;
+      selectedModelId = settings.agent.modelId;
+      thinkChip.set(thinkText(thinkLevel));
+      accessChip.set(accessText(permission));
+      // Propagate the provider's models into the picker.
+      try {
+        modelOptions = await host.invoke("ai:models", undefined);
+      } catch {
+        modelOptions = [];
+      }
+      if (!selectedModelId && modelOptions[0]) {
+        selectedModelId = modelOptions[0].id;
+      }
+      modelChip.set(selectedModelId ? shortModel(selectedModelId) : "Model");
+      // Restore the chat the user last had open in this repo (survives refresh).
+      try {
+        const cur = await host.invoke("ai:chatCurrent", undefined);
+        if (cur) {
+          currentChatId = cur.id;
+          if (cur.turns.length > 0) restoreChat(cur);
+        }
+      } catch {
+        /* no prior chat */
+      }
+    }
+  })();
+
+  function restoreChat(chat: ChatView): void {
+    empty.remove();
+    transcript.replaceChildren();
+    for (const t of chat.turns) {
+      if (t.role === "user") addBubble(transcript, "user", t.text);
+      else transcript.append(markdownBlock(t.text));
+    }
+    scrollDown(transcript);
+  }
+
+  async function newChat(): Promise {
+    try {
+      const chat = await host.invoke("ai:chatNew", undefined);
+      currentChatId = chat?.id;
+    } catch {
+      currentChatId = undefined;
+    }
+    transcript.replaceChildren(empty);
+  }
+
+  async function openHistory(): Promise {
+    let chats: { id: string; title: string; updatedAt: number }[] = [];
+    try {
+      chats = await host.invoke("ai:chatList", undefined);
+    } catch {
+      chats = [];
+    }
+    const items: MenuItem[] = [{ label: "New chat", icon: "add", onClick: () => void newChat() }];
+    if (chats.length) items.push({ separator: true });
+    for (const c of chats) {
+      items.push({
+        label: c.title || "Untitled chat",
+        sub: relTimeISO(new Date(c.updatedAt).toISOString()),
+        current: c.id === currentChatId,
+        onClick: () => void switchChat(c.id),
+      });
+    }
+    openMenu(histBtn, items);
+  }
+
+  async function switchChat(id: string): Promise {
+    try {
+      const chat = await host.invoke("ai:chatGet", { id });
+      if (!chat) return;
+      await host.invoke("ai:chatSetCurrent", { id });
+      currentChatId = id;
+      if (chat.turns.length > 0) restoreChat(chat);
+      else transcript.replaceChildren(empty);
+    } catch {
+      /* ignore */
+    }
+  }
+
+  async function runGoal(goal: string): Promise {
+    if (running || !goal.trim()) return;
+    running = true;
+    input.value = "";
+    empty.remove();
+    setBusy(send, true);
+
+    // Ensure this conversation has a persisted chat (created lazily on first send).
+    if (!currentChatId) {
+      try {
+        const chat = await host.invoke("ai:chatNew", undefined);
+        currentChatId = chat?.id;
+      } catch {
+        currentChatId = undefined;
+      }
+    }
+    if (!currentChatId) {
+      addBubble(transcript, "user", goal);
+      transcript.append(errorBlock("Couldn't start a chat — open a repository and connect a model."));
+      running = false;
+      setBusy(send, false);
+      return;
+    }
+
+    try {
+      await runAgentTurn(transcript, send, currentChatId, goal, {
+        allowWrite: permission !== "read",
+        allowDestructive: permission === "destructive",
+        modelId: selectedModelId,
+        thinking: thinkLevel,
+      });
+    } finally {
+      running = false;
+    }
+  }
+
+  send.addEventListener("click", () => void runGoal(input.value));
+  input.addEventListener("keydown", (e) => {
+    if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+      e.preventDefault();
+      void runGoal(input.value);
+    }
+  });
+};
diff --git a/apps/desktop/src/renderer/bottomDock.ts b/apps/desktop/src/renderer/bottomDock.ts
new file mode 100644
index 0000000..3a90bab
--- /dev/null
+++ b/apps/desktop/src/renderer/bottomDock.ts
@@ -0,0 +1,173 @@
+// A reusable collapsible bottom panel, VS-Code-panel style. Collapsed, only a
+// tiny status-bar-like tab BAR sits at the very bottom. Expanded, the whole panel
+// floats UP and ON TOP of the view content (never reflowing it): the bar rises to
+// sit above the body (tabs-on-top), and the body fills beneath it down to the
+// window bottom. Closing drops the bar back down to the base.
+//
+// The terminal dock is the first consumer, but the component is deliberately
+// content-agnostic: a caller fills `tabsEl` (the footer's left side) + `actionsEl`
+// (the footer's right side, before the chevron) and mounts whatever it likes into
+// `bodyEl`. Any screen that wants a draggable, collapsible bottom split can reuse
+// it.
+//
+// Layout contract: the host must be a `display:flex; flex-direction:column`
+// container whose other child(ren) carry `flex: 1` and `min-height: 0`. Only the
+// footer sits in the flex flow (the dock-mount stays footer-height always); when
+// expanded, the resizer + body float in an absolutely-positioned overlay ABOVE
+// the footer (anchored to the position:relative dock-mount), ON TOP of the
+// content — so opening the dock never shrinks the view above it.
+
+import { el, glyph, wireResizerKeys } from "./ui";
+
+export interface BottomDockOptions {
+  /** Start collapsed (just the footer bar) vs expanded (footer + body). */
+  collapsed: boolean;
+  /** Body height in px when expanded. */
+  height: number;
+  /** Minimum body height while dragging (px). Defaults to 120. */
+  minHeight?: number;
+  /** Fires continuously while the body resizes and on collapse/expand, so the
+   *  consumer can relayout its content (e.g. re-fit an xterm). */
+  onResize?: () => void;
+  /** Fires when the user toggles collapse — persist the new state. */
+  onToggle?: (collapsed: boolean) => void;
+  /** Fires when a drag-resize settles — persist the new height. */
+  onHeightChange?: (height: number) => void;
+  /** Accessible label for the panel region. */
+  label?: string;
+}
+
+export class BottomDock {
+  /** The whole dock (resizer + popped-up body + footer) — appended to the host. */
+  readonly root: HTMLElement;
+  /** Footer's left region — the consumer fills this (e.g. with tabs). */
+  readonly tabsEl: HTMLElement;
+  /** Footer's right region (before the chevron) — consumer action buttons. */
+  readonly actionsEl: HTMLElement;
+  /** The panel body — hidden when collapsed. Consumer mounts content here. */
+  readonly bodyEl: HTMLElement;
+
+  private readonly panel: HTMLElement;
+  private readonly resizer: HTMLElement;
+  private readonly chevron: HTMLElement;
+  private readonly opts: BottomDockOptions;
+  private collapsed: boolean;
+  private heightPx: number;
+
+  constructor(host: HTMLElement, opts: BottomDockOptions) {
+    this.opts = opts;
+    this.collapsed = opts.collapsed;
+    this.heightPx = Math.max(opts.minHeight ?? 120, opts.height);
+
+    // The body pops UP above a permanently-pinned footer bar, so the resizer
+    // (drag the body's top edge) and the body sit ABOVE the footer; the footer
+    // bar is the bottom-most, always-visible element — like a status bar.
+    this.resizer = el("div", "dock-resizer");
+    this.resizer.append(el("div", "dock-resizer-grip"));
+    this.resizer.addEventListener("pointerdown", (e) => this.startResize(e));
+    wireResizerKeys(this.resizer, {
+      orientation: "horizontal",
+      label: opts.label ? `Resize ${opts.label}` : "Resize panel",
+      min: opts.minHeight ?? 120,
+      max: () => Math.max(opts.minHeight ?? 120, window.innerHeight - 220),
+      get: () => this.heightPx,
+      set: (h) => {
+        this.heightPx = h;
+        this.bodyEl.style.height = `${h}px`;
+        this.opts.onResize?.();
+      },
+      onCommit: () => this.opts.onHeightChange?.(this.heightPx),
+      disabled: () => this.collapsed,
+    });
+
+    this.bodyEl = el("div", "dock-body");
+    this.bodyEl.style.height = `${this.heightPx}px`;
+
+    // ── The footer bar (always visible): tabs · actions · collapse chevron. ──
+    const footer = el("div", "dock-footer");
+    if (opts.label) footer.setAttribute("aria-label", opts.label);
+    this.tabsEl = el("div", "dock-tabs");
+    this.actionsEl = el("div", "dock-actions");
+    this.chevron = el("button", "dock-chevron");
+    this.chevron.setAttribute("aria-label", "Toggle panel");
+    this.syncChevron();
+    this.chevron.addEventListener("click", () => this.toggle());
+    footer.append(this.tabsEl, el("div", "dock-spacer"), this.actionsEl, this.chevron);
+    // Clicking empty footer space expands a collapsed dock (collapse is the
+    // chevron's job — so a stray click on the bar never hides your terminal).
+    footer.addEventListener("pointerdown", (e) => {
+      const target = e.target as HTMLElement;
+      if (this.collapsed && !target.closest("button, .term-tab")) this.toggle();
+    });
+
+    // The whole panel floats in an overlay anchored to the window bottom, so
+    // opening it never reflows the content above (the dock-mount always reserves
+    // just the bar height). Order top→bottom: resizer · BAR · body. When open the
+    // bar sits ON TOP of the body (VS-Code-panel style); when collapsed only the
+    // bar remains, dropped to the base.
+    this.panel = el("div", "dock-overlay");
+    this.panel.append(this.resizer, footer, this.bodyEl);
+
+    this.root = el("div", "dock-mount" + (this.collapsed ? " collapsed" : ""));
+    this.root.append(this.panel);
+    host.appendChild(this.root);
+  }
+
+  /** Collapse to just the footer bar, or expand back to footer + body. */
+  toggle(): void {
+    this.setCollapsed(!this.collapsed);
+    this.opts.onToggle?.(this.collapsed);
+  }
+
+  setCollapsed(collapsed: boolean): void {
+    if (this.collapsed === collapsed) return;
+    this.collapsed = collapsed;
+    this.root.classList.toggle("collapsed", collapsed);
+    this.syncChevron();
+    this.opts.onResize?.();
+  }
+
+  isCollapsed(): boolean {
+    return this.collapsed;
+  }
+
+  get height(): number {
+    return this.heightPx;
+  }
+
+  /** Point the chevron the right way (up = expand, down = collapse). */
+  private syncChevron(): void {
+    this.chevron.replaceChildren(glyph(this.collapsed ? "chevron-up" : "chevron-down"));
+    this.chevron.title = this.collapsed ? "Expand panel" : "Collapse panel";
+  }
+
+  /** Drag the top edge to resize the body height (clamped), then relayout. */
+  private startResize(e: PointerEvent): void {
+    if (this.collapsed) return;
+    e.preventDefault();
+    document.body.classList.add("resizing-v");
+    const startY = e.clientY;
+    const startH = this.heightPx;
+    const min = this.opts.minHeight ?? 120;
+    const max = Math.max(min, window.innerHeight - 220);
+    const move = (ev: PointerEvent): void => {
+      const h = Math.max(min, Math.min(max, startH + (startY - ev.clientY)));
+      this.heightPx = h;
+      this.bodyEl.style.height = `${h}px`;
+      this.opts.onResize?.();
+    };
+    const up = (): void => {
+      document.body.classList.remove("resizing-v");
+      window.removeEventListener("pointermove", move);
+      window.removeEventListener("pointerup", up);
+      this.opts.onResize?.();
+      this.opts.onHeightChange?.(this.heightPx);
+    };
+    window.addEventListener("pointermove", move);
+    window.addEventListener("pointerup", up);
+  }
+
+  dispose(): void {
+    this.root.remove();
+  }
+}
diff --git a/apps/desktop/src/renderer/bridge.ts b/apps/desktop/src/renderer/bridge.ts
new file mode 100644
index 0000000..adbc093
--- /dev/null
+++ b/apps/desktop/src/renderer/bridge.ts
@@ -0,0 +1,75 @@
+// Renderer-side access to the host. `window.gitstudio` is the typed surface the
+// preload exposed over the contextBridge; this module gives the rest of the
+// renderer a single import for it plus the graph-protocol adapter that lets the
+// UNCHANGED `` element speak to the desktop host.
+//
+// The shared graph element was written for a VS Code webview: it expects to
+// receive `graphInit` / `graphAppend` messages and to post `selectCommit` /
+// `openCommit` / `contextMenu` / `loadMore` back. The desktop host instead
+// answers a single `graph:load` IPC call returning a page. `GraphHostAdapter`
+// bridges the two — it pages via IPC and feeds the element host messages — so
+// the component itself needs no desktop-specific code.
+
+import type { GitStudioBridge } from "../shared/ipc";
+import type { GraphInitMessage, GraphAppendMessage } from "@gitstudio/host-bridge/graphProtocol";
+import { nextGraphMessage } from "../shared/graphAdapterCore";
+
+declare global {
+  interface Window {
+    gitstudio: GitStudioBridge;
+  }
+}
+
+export const host: GitStudioBridge = window.gitstudio;
+
+/**
+ * Drives the `` element off the desktop `graph:load` IPC.
+ * Owns the paging cursor and translates each page into the host message the
+ * element expects; the pure page→message translation lives in graphAdapterCore
+ * so it can be unit-tested without a browser.
+ */
+export class GraphHostAdapter {
+  private skip = 0;
+  private loading = false;
+  private exhausted = false;
+
+  constructor(
+    private readonly onMessage: (msg: GraphInitMessage | GraphAppendMessage) => void,
+  ) {}
+
+  /** Resets to the first page (e.g. after the active repo changes). */
+  reset(): void {
+    this.skip = 0;
+    this.loading = false;
+    this.exhausted = false;
+  }
+
+  /** Loads the first page and feeds a `graphInit` to the element. */
+  async loadInitial(): Promise {
+    this.reset();
+    await this.page(true);
+  }
+
+  /** Loads the next page (called when the element nears its bottom). */
+  async loadMore(): Promise {
+    if (this.exhausted) {
+      return;
+    }
+    await this.page(false);
+  }
+
+  private async page(initial: boolean): Promise {
+    if (this.loading) {
+      return;
+    }
+    this.loading = true;
+    try {
+      const result = await host.invoke("graph:load", { skip: this.skip });
+      this.skip = result.nextSkip;
+      this.exhausted = !result.hasMore;
+      this.onMessage(nextGraphMessage(result, initial));
+    } finally {
+      this.loading = false;
+    }
+  }
+}
diff --git a/apps/desktop/src/renderer/cache.ts b/apps/desktop/src/renderer/cache.ts
new file mode 100644
index 0000000..b394bed
--- /dev/null
+++ b/apps/desktop/src/renderer/cache.ts
@@ -0,0 +1,121 @@
+// A tiny stale-while-revalidate cache over the host bridge. Read-heavy views
+// (graph, code tree, branches, status, GitHub lists) re-render constantly as you
+// switch tabs; without caching every switch re-hits git/GitHub and feels slow.
+//
+// Usage pattern in a view:
+//   const cached = peek("branches:list", undefined);   // sync — instant paint
+//   if (cached) renderRows(cached); else renderSkeleton();
+//   renderRows(await gget("branches:list", undefined)); // fresh (cheap if warm)
+//
+// After any mutation (commit/stage/checkout/sync/push/PR action) call
+// `bust()` (everything) or `bust("branches")` (a channel prefix) so the next
+// read refetches. `prime()` seeds a value fetched elsewhere.
+
+import type { IpcChannel, IpcRequest, IpcResponse } from "../shared/ipc";
+import { host } from "./bridge";
+
+interface Entry {
+  value: unknown;
+  /** epoch ms when stored. */
+  at: number;
+  /** in-flight fetch, so concurrent callers share one request. */
+  pending?: Promise;
+}
+
+const store = new Map();
+
+/** Default freshness window (ms) — within this, `gget` skips the network. */
+const DEFAULT_TTL = 8000;
+
+/** The active repo root. Every cache key is namespaced by it so a fast repo
+ *  switch can never resolve repo A's (cached or in-flight) data into repo B's
+ *  view — switching repos wipes the cache outright. */
+let scope = "";
+
+/**
+ * Point the cache at a repo. Changing the active repo clears all cached entries
+ * (a different repo's branches/status/graph must never bleed through). Call this
+ * on every `repo:changed` before re-rendering.
+ */
+export function setCacheScope(repoRoot: string | undefined): void {
+  const next = repoRoot ?? "";
+  if (next !== scope) {
+    scope = next;
+    store.clear();
+  }
+}
+
+function keyFor(channel: string, payload: unknown): string {
+  return scope + " " + channel + "|" + (payload === undefined ? "" : JSON.stringify(payload));
+}
+
+/** The cached value if present and (optionally) younger than `maxAgeMs`. */
+export function peek(
+  channel: C,
+  payload: IpcRequest,
+  maxAgeMs = Infinity,
+): IpcResponse | undefined {
+  const e = store.get(keyFor(channel, payload));
+  if (!e) return undefined;
+  if (Date.now() - e.at > maxAgeMs) return undefined;
+  return e.value as IpcResponse;
+}
+
+/**
+ * Cached get. Returns the cached value when it's younger than `ttl`; otherwise
+ * invokes the host, stores, and returns it. Concurrent calls for the same key
+ * dedupe onto a single in-flight request.
+ */
+export async function gget(
+  channel: C,
+  payload: IpcRequest,
+  ttl = DEFAULT_TTL,
+): Promise> {
+  const key = keyFor(channel, payload);
+  const e = store.get(key);
+  if (e) {
+    if (e.pending) return e.pending as Promise>;
+    if (Date.now() - e.at <= ttl) return e.value as IpcResponse;
+  }
+  const pending = host.invoke(channel, payload).then(
+    (value) => {
+      store.set(key, { value, at: Date.now() });
+      return value;
+    },
+    (err) => {
+      // Drop the failed in-flight marker so a retry can re-fetch; keep any prior
+      // good value in place (callers can still `peek` the last-known-good).
+      const prev = store.get(key);
+      if (prev && prev.pending) {
+        if (prev.value !== undefined) store.set(key, { value: prev.value, at: prev.at });
+        else store.delete(key);
+      }
+      throw err;
+    },
+  );
+  store.set(key, { value: e?.value, at: e?.at ?? 0, pending });
+  return pending as Promise>;
+}
+
+/** Force the next `gget`/`peek(maxAge)` for matching channels to refetch.
+ *  No prefix → clear everything; a prefix clears the current repo's channels
+ *  that start with it (keys are namespaced by repo scope, so match within it). */
+export function bust(prefix?: string): void {
+  if (!prefix) {
+    store.clear();
+    return;
+  }
+  const scoped = scope + " " + prefix;
+  for (const k of store.keys()) {
+    if (k.startsWith(scoped)) store.delete(k);
+  }
+}
+
+/** Seed the cache with a value obtained elsewhere (e.g. an event payload). */
+export function prime(
+  channel: C,
+  payload: IpcRequest,
+  value: IpcResponse,
+): void {
+  store.set(keyFor(channel, payload), { value, at: Date.now() });
+}
diff --git a/apps/desktop/src/renderer/chatPanel.ts b/apps/desktop/src/renderer/chatPanel.ts
new file mode 100644
index 0000000..c89d0c4
--- /dev/null
+++ b/apps/desktop/src/renderer/chatPanel.ts
@@ -0,0 +1,150 @@
+// A self-contained agent-chat panel for the footer dock's inline AI tabs. Each
+// ✨ action (Explain / Review / Analyze / Draft a comment) opens one of these as
+// a named, closable tab: the action's prompt is auto-sent as the opening turn,
+// the reply streams in, and the user can keep asking follow-ups — a real
+// conversation, not the dead-end modal it replaces.
+//
+// It reuses the exact streaming / tool-step / confirm plumbing the full
+// Assistant view uses (chatRender.runAgentTurn) and the persistent chat backend
+// (ai:chat*), so every footer chat is a first-class, persisted conversation.
+
+import { host } from "./bridge";
+import { el, glyph } from "./ui";
+import { runAgentTurn, addBubble, errorBlock, connectPrompt, setBusy, scrollDown } from "./chatRender";
+import type { AiSettingsView } from "../shared/ipc";
+
+export interface ChatPanelOptions {
+  /** Auto-sent as the opening turn — the AI action that spawned this tab. May
+   *  embed large context (an issue body, a PR description). */
+  seedGoal: string;
+  /** A short label shown as the opening user bubble in place of the full goal
+   *  (e.g. the tab title "Analyze #42"). Defaults to the goal itself. */
+  seedLabel?: string;
+  /** Navigate the shell (for the "connect a model" CTA when AI is off). */
+  nav?: (view: string) => void;
+}
+
+export class ChatPanel {
+  /** The panel root — the dock mounts this as the tab's surface. */
+  readonly el: HTMLElement;
+  private readonly transcript: HTMLElement;
+  private readonly input: HTMLTextAreaElement;
+  private readonly send: HTMLElement;
+
+  private chatId?: string;
+  private running = false;
+  private disposed = false;
+  private modelId?: string;
+  private thinking: "off" | "auto" | "extended" = "auto";
+  /** Aborts the in-flight turn (and tells the main process to cancel) on close. */
+  private readonly abort = new AbortController();
+
+  constructor(private readonly opts: ChatPanelOptions) {
+    this.el = el("div", "chat-panel");
+
+    this.transcript = el("div", "assistant-transcript chat-panel-transcript");
+
+    const composer = el("div", "assistant-composer chat-panel-composer");
+    const inputRow = el("div", "assistant-input-row");
+    this.input = document.createElement("textarea");
+    this.input.className = "assistant-input";
+    this.input.rows = 1;
+    this.input.placeholder = "Ask a follow-up…";
+    this.send = el("button", "btn btn-primary assistant-send");
+    this.send.append(glyph("send"));
+    this.send.title = "Send";
+    inputRow.append(this.input, this.send);
+    composer.append(inputRow);
+
+    this.el.append(this.transcript, composer);
+
+    this.send.addEventListener("click", () => void this.runGoal(this.input.value));
+    this.input.addEventListener("keydown", (e) => {
+      // Enter sends; Shift+Enter (and ⌘/Ctrl+Enter) insert a newline.
+      if (e.key === "Enter" && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
+        e.preventDefault();
+        void this.runGoal(this.input.value);
+      }
+    });
+
+    void this.start();
+  }
+
+  /** Gate on a usable model, then auto-send the seeding action. */
+  private async start(): Promise {
+    let settings: AiSettingsView | undefined;
+    try {
+      settings = await host.invoke("ai:settings", undefined);
+    } catch {
+      settings = undefined;
+    }
+    if (this.disposed) return;
+    if (!settings || !settings.enabled) {
+      this.transcript.replaceChildren(connectPrompt(this.opts.nav ?? (() => undefined)));
+      this.input.disabled = true;
+      (this.send as HTMLButtonElement).disabled = true;
+      return;
+    }
+    this.modelId = settings.agent.modelId;
+    this.thinking = settings.agent.thinking;
+    void this.runGoal(this.opts.seedGoal, true);
+  }
+
+  /** Send a turn. The seeding turn keeps the composer empty; follow-ups clear it. */
+  private async runGoal(goal: string, fromSeed = false): Promise {
+    if (this.disposed || this.running || !goal.trim()) return;
+    this.running = true;
+    if (!fromSeed) this.input.value = "";
+    setBusy(this.send, true);
+
+    // Lazily create the persisted chat backing this tab — but NOT as the repo's
+    // "current" chat, so opening a footer tab never disturbs the full Assistant.
+    if (!this.chatId) {
+      try {
+        const chat = await host.invoke("ai:chatNew", { setCurrent: false });
+        this.chatId = chat?.id;
+      } catch {
+        this.chatId = undefined;
+      }
+    }
+    if (this.disposed) return;
+    if (!this.chatId) {
+      addBubble(this.transcript, "user", goal);
+      this.transcript.append(errorBlock("Couldn't start a chat — open a repository and connect a model."));
+      this.running = false;
+      setBusy(this.send, false);
+      return;
+    }
+
+    try {
+      // Footer chats are read-only by design: explain / review / analyze / draft
+      // never mutate the repo, so there is no write-confirmation friction. The
+      // seeding turn shows a short label (the tab title) rather than the full
+      // context-laden prompt; follow-ups show what the user typed.
+      await runAgentTurn(
+        this.transcript,
+        this.send,
+        this.chatId,
+        goal,
+        { allowWrite: false, allowDestructive: false, modelId: this.modelId, thinking: this.thinking },
+        this.abort.signal,
+        fromSeed ? this.opts.seedLabel : undefined,
+      );
+    } finally {
+      this.running = false;
+      if (!this.disposed) this.input.focus();
+    }
+  }
+
+  /** Called by the dock when this tab becomes active — land focus in the input. */
+  reveal(): void {
+    scrollDown(this.transcript);
+    if (!this.input.disabled) this.input.focus();
+  }
+
+  dispose(): void {
+    this.disposed = true;
+    this.abort.abort(); // cancel any in-flight turn in the main process
+    this.el.remove();
+  }
+}
diff --git a/apps/desktop/src/renderer/chatRender.ts b/apps/desktop/src/renderer/chatRender.ts
new file mode 100644
index 0000000..bfe6e4c
--- /dev/null
+++ b/apps/desktop/src/renderer/chatRender.ts
@@ -0,0 +1,341 @@
+// Shared agent-chat rendering + the single turn runner used by BOTH the full
+// Assistant view (assistant.ts) and the inline AI tabs in the footer dock
+// (chatPanel.ts). It owns the intricate bits — live Markdown streaming, tool
+// steps, the write/destructive confirm gate, the thinking indicator and the
+// cancel swap — so there is ONE implementation, not two that drift apart.
+//
+// Everything here renders into a caller-owned transcript element; nothing holds
+// view state, so it is safe to instantiate many chats at once.
+
+import { host } from "./bridge";
+import { el, span, glyph } from "./ui";
+import { renderMarkdown } from "./markdown";
+import { confirmDialog, toast } from "./dialogs";
+import type { AgentConfirmRequest, AgentEventWire } from "../shared/ipc";
+
+/** Per-run rendering state for one in-flight agent turn. */
+export interface TurnState {
+  turn: HTMLElement;
+  thinking: HTMLElement;
+  /** The live streaming block for the current step (null between steps). */
+  stream: HTMLElement | null;
+  /** Accumulated raw text for the streaming block (rendered as Markdown live). */
+  raw: string;
+  /** Whether a Markdown re-render is already scheduled this frame. */
+  pending: boolean;
+  /** The label shown while waiting (e.g. "Loading the agent" on a cold start). */
+  status: string;
+}
+
+/** The agent options for a turn (mapped from the caller's permission/model/think). */
+export interface RunTurnConfig {
+  allowWrite: boolean;
+  allowDestructive: boolean;
+  modelId?: string;
+  thinking: "off" | "auto" | "extended";
+}
+
+/**
+ * Run one agent turn end-to-end: render the user bubble, show a live "thinking"
+ * indicator, stream the reply as Markdown, surface tool steps + confirmations,
+ * and settle. Listeners are scoped to this turn's requestId and torn down in
+ * `finally`, so nothing leaks. Pass a `signal` to cancel from the outside (e.g.
+ * when a footer chat tab is closed mid-stream).
+ */
+export async function runAgentTurn(
+  transcript: HTMLElement,
+  send: HTMLElement,
+  chatId: string,
+  goal: string,
+  cfg: RunTurnConfig,
+  signal?: AbortSignal,
+  /** What the user bubble shows, when it should differ from the sent goal — e.g.
+   *  "Analyze #42" instead of the full issue body embedded in the prompt. */
+  displayText?: string,
+): Promise {
+  addBubble(transcript, "user", displayText ?? goal);
+  const turn = el("div", "assistant-turn");
+  // An animated "thinking" indicator: three pulsing dots + a shimmering label +
+  // a live elapsed time, so a multi-second model start-up (a local CLI boots its
+  // whole agent before the first token) clearly reads as active thinking.
+  const thinking = el("div", "assistant-thinking");
+  const dots = el("span", "ai-think-dots");
+  dots.append(el("i"), el("i"), el("i"));
+  const thinkLabel = span("Thinking", "ai-think-label");
+  const thinkMeta = span("", "ai-think-meta");
+  thinking.append(dots, thinkLabel, thinkMeta);
+  turn.append(thinking);
+  transcript.append(turn);
+  scrollDown(transcript);
+
+  const state: TurnState = { turn, thinking, stream: null, raw: "", pending: false, status: "Thinking" };
+  const t0 = Date.now();
+  const ticker = window.setInterval(() => {
+    const s = Math.max(1, Math.round((Date.now() - t0) / 1000));
+    thinkLabel.textContent = state.stream ? "Responding" : state.status;
+    thinkMeta.textContent = `${s}s`;
+  }, 250);
+
+  const requestId = crypto.randomUUID();
+  const offDelta = host.on("ai:delta", (e) => {
+    if (e.requestId === requestId) onDelta(state, e.delta);
+  });
+  const offEvent = host.on("ai:agentEvent", (e) => {
+    if (e.requestId === requestId) onEvent(state, e);
+  });
+  const offConfirm = host.on("ai:confirmRequest", (c) => {
+    if (c.requestId === requestId) void onConfirm(requestId, c);
+  });
+  const onAbort = (): void => void host.invoke("ai:cancel", { requestId });
+  signal?.addEventListener("abort", onAbort, { once: true });
+
+  // A cancel affordance replaces the send button while running.
+  const cancel = swapToCancel(send, () => void host.invoke("ai:cancel", { requestId }));
+
+  try {
+    const done = await host.invoke("ai:chatSend", {
+      chatId,
+      requestId,
+      goal,
+      allowWrite: cfg.allowWrite,
+      allowDestructive: cfg.allowDestructive,
+      modelId: cfg.modelId,
+      thinking: cfg.thinking,
+    });
+    finalizeStream(state);
+    thinking.remove();
+    if (!done.ok && done.message) {
+      turn.append(errorBlock(done.message));
+    } else if (done.text && !turn.querySelector(".assistant-msg")) {
+      turn.append(markdownBlock(done.text));
+    }
+  } catch (e) {
+    thinking.remove();
+    turn.append(errorBlock(e instanceof Error ? e.message : String(e)));
+  } finally {
+    window.clearInterval(ticker);
+    offDelta();
+    offEvent();
+    offConfirm();
+    signal?.removeEventListener("abort", onAbort);
+    cancel.restore();
+    scrollDown(transcript);
+  }
+}
+
+// ── Streaming + event rendering ──────────────────────────────────────────────
+
+/** 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.turn.insertBefore(state.stream, state.thinking);
+    state.raw = "";
+  }
+  state.raw += delta;
+  scheduleStreamRender(state);
+  scrollDown(state.turn.parentElement as HTMLElement);
+}
+
+/** Re-render the live block as Markdown, at most once per animation frame. */
+function scheduleStreamRender(state: TurnState): void {
+  if (state.pending || !state.stream) return;
+  state.pending = true;
+  requestAnimationFrame(() => {
+    state.pending = false;
+    if (state.stream) state.stream.innerHTML = renderMarkdown(state.raw);
+  });
+}
+
+/** Settle the live streaming block when its step completes. */
+export function finalizeStream(state: TurnState): void {
+  if (state.stream) {
+    state.stream.classList.remove("is-streaming");
+    if (state.raw.trim()) state.stream.innerHTML = renderMarkdown(state.raw);
+    state.stream = null;
+    state.raw = "";
+  }
+}
+
+/** Apply one structured agent event to the active turn. */
+export function onEvent(state: TurnState, e: AgentEventWire): void {
+  const { turn, thinking } = state;
+  switch (e.kind) {
+    case "status":
+      // A pre-token status (e.g. "Loading the agent…" on a cold start).
+      if (e.text && e.text.trim()) state.status = e.text.trim();
+      break;
+    case "assistant":
+      // The step's text finished — render the final Markdown.
+      if (state.stream) {
+        const text = e.text && e.text.trim() ? e.text : state.raw;
+        state.stream.innerHTML = renderMarkdown(text);
+        state.stream.classList.remove("is-streaming");
+        state.stream = null;
+        state.raw = "";
+      } else if (e.text && e.text.trim()) {
+        turn.insertBefore(markdownBlock(e.text), thinking);
+      }
+      break;
+    case "tool_call":
+      finalizeStream(state); // close any open text block before the tool step
+      turn.insertBefore(toolStep(e), thinking);
+      break;
+    case "tool_result": {
+      const step = turn.querySelector(`.assistant-tool[data-call="${e.callId}"]`);
+      if (step) finishToolStep(step, e);
+      break;
+    }
+    case "tool_denied": {
+      const step = turn.querySelector(`.assistant-tool[data-call="${e.callId}"]`);
+      step?.classList.add("is-denied");
+      break;
+    }
+    case "error":
+      finalizeStream(state);
+      turn.insertBefore(errorBlock(e.text ?? "The agent hit an error."), thinking);
+      break;
+    default:
+      break;
+  }
+  scrollDown(turn.parentElement as HTMLElement);
+}
+
+/** Render the confirm dialog for a write/destructive tool and answer the agent. */
+export async function onConfirm(requestId: string, c: AgentConfirmRequest): Promise {
+  const approved = await confirmDialog({
+    title: c.mode === "destructive" ? "Approve destructive action" : "Approve action",
+    message: c.summary,
+    confirmLabel: c.mode === "destructive" ? "Yes, do it" : "Approve",
+    danger: c.mode === "destructive",
+  });
+  await host.invoke("ai:agentConfirm", { requestId, callId: c.callId, approved });
+  if (!approved) toast("Action declined.", "info");
+}
+
+// ── DOM helpers ──────────────────────────────────────────────────────────────
+
+export function addBubble(transcript: HTMLElement, who: "user", text: string): void {
+  const b = el("div", `assistant-bubble is-${who}`);
+  b.textContent = text;
+  transcript.append(b);
+}
+
+export function markdownBlock(md: string): HTMLElement {
+  const block = el("div", "assistant-msg");
+  block.innerHTML = renderMarkdown(md);
+  return block;
+}
+
+function toolStep(e: AgentEventWire): HTMLElement {
+  const step = el("div", "assistant-tool");
+  step.dataset.call = e.callId ?? "";
+  const head = el("div", "assistant-tool-head");
+  head.append(glyph("tools"));
+  const name = el("span", "assistant-tool-name");
+  name.textContent = (e.tool ?? "tool").replace(/^git_/, "").replace(/_/g, " ");
+  head.append(name);
+  const argPreview = argSummary(e.args);
+  if (argPreview) {
+    const a = el("span", "assistant-tool-arg");
+    a.textContent = argPreview;
+    head.append(a);
+  }
+  const spin = glyph("loading");
+  spin.classList.add("assistant-tool-spin");
+  head.append(spin);
+  step.append(head);
+  return step;
+}
+
+function finishToolStep(step: HTMLElement, e: AgentEventWire): void {
+  step.querySelector(".assistant-tool-spin")?.remove();
+  step.classList.toggle("is-error", e.isError === true);
+  const status = glyph(e.isError ? "error" : "check");
+  status.classList.add("assistant-tool-status");
+  step.querySelector(".assistant-tool-head")?.append(status);
+  if (e.text && e.text.trim()) {
+    const out = el("pre", "assistant-tool-out");
+    const txt = e.text.length > 1200 ? e.text.slice(0, 1200) + "\n…" : e.text;
+    out.textContent = txt;
+    // Collapsed by default; the head toggles it.
+    out.hidden = true;
+    step.append(out);
+    step.querySelector(".assistant-tool-head")?.addEventListener("click", () => (out.hidden = !out.hidden));
+    step.classList.add("is-expandable");
+  }
+}
+
+function argSummary(args?: Record): string {
+  if (!args) return "";
+  if (typeof args.message === "string") return `“${args.message.split("\n")[0]}”`;
+  if (typeof args.name === "string") return args.name;
+  if (typeof args.ref === "string") return args.ref;
+  if (typeof args.path === "string") return args.path;
+  if (Array.isArray(args.paths)) return (args.paths as string[]).join(", ");
+  if (typeof args.base === "string") return `${args.base}…${(args.head as string) ?? "HEAD"}`;
+  if (typeof args.query === "string") return `“${args.query}”`;
+  if (args.all === true) return "all";
+  return "";
+}
+
+export function errorBlock(msg: string): HTMLElement {
+  const b = el("div", "assistant-error");
+  b.append(glyph("error"), span(msg));
+  return b;
+}
+
+export function connectPrompt(nav: (view: string) => void): HTMLElement {
+  const wrap = el("div", "assistant-empty");
+  wrap.append(
+    glyph("sparkle"),
+    elText("div", "assistant-empty-title", "Connect a model to use the Assistant"),
+    elText(
+      "div",
+      "assistant-empty-sub",
+      "Bring your own key — Claude, OpenAI, Gemini and more — or run a local model. Your subscription, your data.",
+    ),
+  );
+  const btn = el("button", "btn btn-primary");
+  btn.append(glyph("gear"), span("Open AI settings"));
+  btn.addEventListener("click", () => nav("settings"));
+  wrap.append(btn);
+  return wrap;
+}
+
+export function elText(tag: string, cls: string, text: string): HTMLElement {
+  const e = el(tag, cls);
+  e.textContent = text;
+  return e;
+}
+
+export function setBusy(btn: HTMLElement, busy: boolean): void {
+  (btn as HTMLButtonElement).disabled = busy;
+}
+
+/** Swap the send button into a Cancel button for the duration of a run. */
+export function swapToCancel(send: HTMLElement, onCancel: () => void): { restore: () => void } {
+  const original = send.innerHTML;
+  (send as HTMLButtonElement).disabled = false;
+  send.classList.add("is-cancel");
+  send.replaceChildren(glyph("stop-circle"));
+  send.title = "Stop";
+  const handler = (ev: Event): void => {
+    ev.stopImmediatePropagation();
+    onCancel();
+  };
+  send.addEventListener("click", handler, true);
+  return {
+    restore() {
+      send.removeEventListener("click", handler, true);
+      send.classList.remove("is-cancel");
+      send.innerHTML = original;
+      send.title = "Send";
+      (send as HTMLButtonElement).disabled = false;
+    },
+  };
+}
+
+export function scrollDown(container: HTMLElement | null): void {
+  if (container) container.scrollTop = container.scrollHeight;
+}
diff --git a/apps/desktop/src/renderer/cloneDialog.ts b/apps/desktop/src/renderer/cloneDialog.ts
new file mode 100644
index 0000000..dc0188e
--- /dev/null
+++ b/apps/desktop/src/renderer/cloneDialog.ts
@@ -0,0 +1,461 @@
+// Clone / browse-GitHub-repos dialog — a focus-trapping modal with two paths:
+//  1. "URL" tab: paste an HTTPS or SSH git URL.
+//  2. "GitHub" tab: search + pick from the signed-in user's repositories
+//     (github:repos), with HTTPS/SSH toggle.
+// In both, "Choose…" picks the parent directory (clone:pickDir) and "Clone"
+// runs clone:start, showing live progress (clone:progress). On success it calls
+// `onCloned(root)` so the shell can open the freshly-cloned repo.
+//
+// CONTRACT (keep this signature — renderer.ts + the welcome screen call it):
+//   openCloneDialog(onCloned: (root: string) => void): void
+//
+// The focus-trap / Escape / backdrop-click / focus-restore scaffold mirrors
+// `modal()` in ./dialogs (which isn't exported), so this self-contained module
+// matches that a11y behaviour exactly.
+
+import { toast } from "./dialogs";
+import { host } from "./bridge";
+import {
+  el,
+  span,
+  glyph,
+  loadingState,
+  emptyState,
+  relTimeISO,
+  cleanErr,
+} from "./ui";
+import type { GhRepoBrief } from "../shared/ipc";
+
+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");
+
+  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();
+    }
+  };
+
+  // ── state ────────────────────────────────────────────────────────────────
+  let tab: Tab = "url";
+  let scheme: Scheme = "https";
+  let parentDir = "";
+  let busy = false;
+  let offProgress: (() => void) | null = null;
+  /** The repo selected on the GitHub tab (drives the SSH/HTTPS toggle). */
+  let selectedRepo: GhRepoBrief | null = null;
+  let searchSeq = 0;
+
+  // ── header: title + segmented tab switch ───────────────────────────────────
+  const h = el("div", "modal-title");
+  h.textContent = "Clone a repository";
+
+  const tabs = el("div", "gh-seg clone-tabs");
+  tabs.setAttribute("role", "tablist");
+  const urlTabBtn = el("button", "gh-seg-btn");
+  urlTabBtn.setAttribute("role", "tab");
+  urlTabBtn.append(glyph("link"), span("URL"));
+  const ghTabBtn = el("button", "gh-seg-btn");
+  ghTabBtn.setAttribute("role", "tab");
+  ghTabBtn.append(glyph("github"), span("GitHub"));
+  tabs.append(urlTabBtn, ghTabBtn);
+
+  // ── URL panel ──────────────────────────────────────────────────────────────
+  const urlPanel = el("div", "clone-panel");
+  urlPanel.setAttribute("role", "tabpanel");
+  const urlInput = document.createElement("input");
+  urlInput.className = "modal-input clone-url-input";
+  urlInput.placeholder =
+    "https://github.com/owner/repo.git  or  git@github.com:owner/repo.git";
+  urlInput.setAttribute("aria-label", "Git repository URL");
+  urlInput.spellcheck = false;
+  urlInput.autocapitalize = "off";
+  urlInput.addEventListener("input", refreshClone);
+  urlInput.addEventListener("keydown", (e) => {
+    if (e.key === "Enter" && !primary.hasAttribute("disabled")) {
+      e.preventDefault();
+      void runClone();
+    }
+  });
+  urlPanel.append(urlInput);
+
+  // ── GitHub panel ───────────────────────────────────────────────────────────
+  const ghPanel = el("div", "clone-panel clone-gh");
+  ghPanel.setAttribute("role", "tabpanel");
+  ghPanel.hidden = true;
+
+  const ghSearch = document.createElement("input");
+  ghSearch.className = "modal-input clone-search";
+  ghSearch.placeholder = "Search your repositories…";
+  ghSearch.setAttribute("aria-label", "Search your GitHub repositories");
+  ghSearch.spellcheck = false;
+  ghSearch.autocapitalize = "off";
+
+  const ghList = el("div", "clone-repo-list");
+  ghList.setAttribute("role", "listbox");
+  ghList.setAttribute("aria-label", "Your repositories");
+
+  // HTTPS / SSH toggle for the chosen repo.
+  const schemeSeg = el("div", "gh-seg clone-scheme");
+  schemeSeg.setAttribute("role", "group");
+  schemeSeg.setAttribute("aria-label", "Clone protocol");
+  const httpsBtn = el("button", "gh-seg-btn");
+  httpsBtn.textContent = "HTTPS";
+  const sshBtn = el("button", "gh-seg-btn");
+  sshBtn.textContent = "SSH";
+  schemeSeg.append(httpsBtn, sshBtn);
+  schemeSeg.hidden = true;
+
+  ghPanel.append(ghSearch, ghList, schemeSeg);
+
+  // ── footer: destination + progress + actions ───────────────────────────────
+  const destRow = el("div", "clone-dest");
+  const destLabel = el("div", "clone-dest-label");
+  destLabel.textContent = "Destination";
+  const destValue = el("div", "clone-dest-path");
+  destValue.textContent = "No folder chosen";
+  const chooseBtn = el("button", "mini-btn clone-choose");
+  chooseBtn.append(glyph("folder-opened"), span("Choose…"));
+  chooseBtn.addEventListener("click", () => void pickDir());
+  const destText = el("div", "clone-dest-text");
+  destText.append(destLabel, destValue);
+  destRow.append(destText, chooseBtn);
+
+  const progress = el("div", "clone-progress");
+  progress.hidden = true;
+  const progBar = el("div", "clone-progress-bar");
+  const progFill = el("div", "clone-progress-fill");
+  progBar.appendChild(progFill);
+  const progPhase = el("div", "clone-progress-phase");
+  progress.setAttribute("role", "status");
+  progress.setAttribute("aria-live", "polite");
+  progress.append(progPhase, progBar);
+
+  const actions = el("div", "modal-actions clone-actions");
+  const cancel = el("button", "mini-btn");
+  cancel.textContent = "Cancel";
+  cancel.addEventListener("click", close);
+  const primary = el("button", "btn btn-primary modal-ok clone-go");
+  const primaryLabel = span("Clone");
+  primary.append(primaryLabel);
+  primary.setAttribute("disabled", "true");
+  primary.addEventListener("click", () => void runClone());
+  actions.append(cancel, primary);
+
+  card.append(h, tabs, urlPanel, ghPanel, destRow, progress, actions);
+  overlay.appendChild(card);
+
+  // ── tab switching ──────────────────────────────────────────────────────────
+  function setTab(next: Tab): void {
+    if (busy) return;
+    tab = next;
+    urlTabBtn.classList.toggle("active", next === "url");
+    ghTabBtn.classList.toggle("active", next === "github");
+    urlTabBtn.setAttribute("aria-selected", String(next === "url"));
+    ghTabBtn.setAttribute("aria-selected", String(next === "github"));
+    urlPanel.hidden = next !== "url";
+    ghPanel.hidden = next !== "github";
+    refreshClone();
+    if (next === "github") {
+      if (!loadedOnce) void loadRepos(ghSearch.value.trim());
+      setTimeout(() => ghSearch.focus(), 0);
+    } else {
+      setTimeout(() => urlInput.focus(), 0);
+    }
+  }
+  urlTabBtn.addEventListener("click", () => setTab("url"));
+  ghTabBtn.addEventListener("click", () => setTab("github"));
+
+  // ── GitHub repo loading + rendering ────────────────────────────────────────
+  let loadedOnce = false;
+  let searchTimer = 0;
+  ghSearch.addEventListener("input", () => {
+    window.clearTimeout(searchTimer);
+    searchTimer = window.setTimeout(
+      () => void loadRepos(ghSearch.value.trim()),
+      220,
+    );
+  });
+
+  async function loadRepos(search: string): Promise {
+    loadedOnce = true;
+    const seq = ++searchSeq;
+    ghList.replaceChildren(loadingState("Loading your repositories…"));
+    // Don't call github:repos while signed out (it throws + spams the log) —
+    // check the connection first and prompt the user to sign in instead.
+    try {
+      const status = await host.invoke("github:status", undefined);
+      if (seq !== searchSeq) return;
+      if (!status.connected) {
+        ghList.replaceChildren(
+          emptyState(
+            "Connect GitHub",
+            "Sign in from the account button at the top of the window to browse and clone your repositories.",
+            { icon: "github" },
+          ),
+        );
+        return;
+      }
+    } catch {
+      /* status check failed — fall through and let the repos call surface it */
+    }
+    let repos: GhRepoBrief[];
+    try {
+      repos = await host.invoke("github:repos", search ? { search } : undefined);
+    } catch (e) {
+      if (seq !== searchSeq) return;
+      ghList.replaceChildren(
+        emptyState(
+          "Couldn't load repositories",
+          cleanErr(e) ||
+            "Connect your GitHub account in Settings, then try again.",
+        ),
+      );
+      return;
+    }
+    if (seq !== searchSeq) return;
+    if (!repos.length) {
+      ghList.replaceChildren(
+        emptyState(
+          search ? "No matching repositories" : "No repositories found",
+          search
+            ? "Try a different search term."
+            : "Sign in to GitHub in Settings to browse your repositories.",
+        ),
+      );
+      return;
+    }
+    ghList.replaceChildren(...repos.map(repoRow));
+  }
+
+  function repoRow(repo: GhRepoBrief): HTMLElement {
+    const row = el("button", "list-row clone-repo");
+    row.setAttribute("role", "option");
+
+    const main = el("div", "clone-repo-main");
+    const name = el("div", "clone-repo-name");
+    name.textContent = repo.fullName;
+    if (repo.private) name.append(badge("Private"));
+    if (repo.fork) name.append(badge("Fork"));
+    main.appendChild(name);
+    if (repo.description) {
+      const desc = el("div", "clone-repo-desc");
+      desc.textContent = repo.description;
+      main.appendChild(desc);
+    }
+
+    const meta = el("div", "clone-repo-meta");
+    if (repo.stars > 0) meta.append(metaBit("★ " + repo.stars));
+    if (repo.language) meta.append(metaBit(repo.language));
+    const rel = relTimeISO(repo.updatedAt);
+    if (rel) meta.append(metaBit("Updated " + rel));
+    if (meta.childElementCount) main.appendChild(meta);
+
+    row.appendChild(main);
+    row.addEventListener("click", () => selectRepo(repo, row));
+    return row;
+  }
+
+  function selectRepo(repo: GhRepoBrief, row: HTMLElement): void {
+    selectedRepo = repo;
+    for (const n of ghList.querySelectorAll(".clone-repo.is-current")) {
+      n.classList.remove("is-current");
+      n.removeAttribute("aria-selected");
+    }
+    row.classList.add("is-current");
+    row.setAttribute("aria-selected", "true");
+    schemeSeg.hidden = false;
+    syncSchemeButtons();
+    refreshClone();
+  }
+
+  function syncSchemeButtons(): void {
+    httpsBtn.classList.toggle("active", scheme === "https");
+    sshBtn.classList.toggle("active", scheme === "ssh");
+    httpsBtn.setAttribute("aria-pressed", String(scheme === "https"));
+    sshBtn.setAttribute("aria-pressed", String(scheme === "ssh"));
+  }
+  httpsBtn.addEventListener("click", () => {
+    scheme = "https";
+    syncSchemeButtons();
+    refreshClone();
+  });
+  sshBtn.addEventListener("click", () => {
+    scheme = "ssh";
+    syncSchemeButtons();
+    refreshClone();
+  });
+  syncSchemeButtons();
+
+  // ── chosen clone target + folder name ──────────────────────────────────────
+  function chosenUrl(): string {
+    if (tab === "url") return urlInput.value.trim();
+    if (!selectedRepo) return "";
+    return scheme === "ssh" ? selectedRepo.sshUrl : selectedRepo.cloneUrl;
+  }
+
+  /** Derive the target folder name from the URL (so it's stable + predictable). */
+  function targetName(url: string): string | undefined {
+    const m = url.match(/([^/:]+?)(?:\.git)?\/?\s*$/);
+    return m ? m[1] : undefined;
+  }
+
+  async function pickDir(): Promise {
+    if (busy) return;
+    try {
+      const dir = await host.invoke("clone:pickDir", undefined);
+      if (dir) {
+        parentDir = dir;
+        destValue.textContent = dir;
+        destValue.title = dir;
+        refreshClone();
+      }
+    } catch (e) {
+      toast(cleanErr(e) || "Couldn't choose a folder.", "error");
+    }
+  }
+
+  function refreshClone(): void {
+    const ready = !busy && !!chosenUrl() && !!parentDir;
+    if (ready) primary.removeAttribute("disabled");
+    else primary.setAttribute("disabled", "true");
+  }
+
+  // ── progress + run ─────────────────────────────────────────────────────────
+  function updateBar(percent: number | undefined, label: string): void {
+    progress.hidden = false;
+    if (typeof percent === "number" && Number.isFinite(percent)) {
+      progFill.classList.remove("indeterminate");
+      progFill.style.width = Math.max(0, Math.min(100, percent)) + "%";
+    } else {
+      progFill.classList.add("indeterminate");
+      progFill.style.width = "100%";
+    }
+    progPhase.textContent = label || "Cloning…";
+  }
+
+  function setBusy(on: boolean): void {
+    busy = on;
+    card.classList.toggle("is-busy", on);
+    for (const ctl of [
+      urlInput,
+      ghSearch,
+      urlTabBtn,
+      ghTabBtn,
+      httpsBtn,
+      sshBtn,
+      chooseBtn,
+      cancel,
+    ]) {
+      if (on) ctl.setAttribute("disabled", "true");
+      else ctl.removeAttribute("disabled");
+    }
+    ghList.classList.toggle("is-disabled", on);
+    if (on) {
+      primary.setAttribute("disabled", "true");
+      primary.classList.add("is-loading");
+      primaryLabel.textContent = "Cloning…";
+    } else {
+      primary.classList.remove("is-loading");
+      primaryLabel.textContent = "Clone";
+      refreshClone();
+    }
+  }
+
+  async function runClone(): Promise {
+    if (busy) return;
+    const url = chosenUrl();
+    if (!url || !parentDir) return;
+    const name = targetName(url);
+
+    setBusy(true);
+    updateBar(undefined, "Preparing…");
+    offProgress = host.on("clone:progress", (p) =>
+      updateBar(p.percent, p.phase || p.raw),
+    );
+
+    let res;
+    try {
+      res = await host.invoke("clone:start", { url, parentDir, name });
+    } catch (e) {
+      offProgress?.();
+      offProgress = null;
+      toast(cleanErr(e) || "Clone failed.", "error");
+      progress.hidden = true;
+      setBusy(false);
+      return;
+    }
+    offProgress?.();
+    offProgress = null;
+
+    if (res.ok && res.root) {
+      const root = res.root;
+      toast("Cloned " + (name || "repository"), "success");
+      close();
+      onCloned(root);
+    } else {
+      toast(res.message || "Clone failed.", "error");
+      progress.hidden = true;
+      setBusy(false);
+    }
+  }
+
+  // ── mount ──────────────────────────────────────────────────────────────────
+  document.body.appendChild(overlay);
+  overlay.addEventListener("mousedown", (e) => {
+    if (e.target === overlay && !busy) close();
+  });
+  document.addEventListener("keydown", onKey, true);
+  setTab("url");
+}
+
+/** A tiny inline badge appended to a repo's name (Private / Fork). */
+function badge(text: string): HTMLElement {
+  const b = span(text, "clone-repo-badge");
+  return b;
+}
+
+/** One dot-separated meta fragment (★ stars · language · updated …). */
+function metaBit(text: string): HTMLElement {
+  return span(text, "clone-meta-bit");
+}
diff --git a/apps/desktop/src/renderer/compareDiff.ts b/apps/desktop/src/renderer/compareDiff.ts
new file mode 100644
index 0000000..2b6e75e
--- /dev/null
+++ b/apps/desktop/src/renderer/compareDiff.ts
@@ -0,0 +1,96 @@
+// A GitHub-style diff surface for the Compare view: Monaco's NATIVE diff editor,
+// which renders side-by-side when there's room and auto-folds to a single inline
+// view when the pane gets narrow (`useInlineViewWhenSpaceIsLimited`). That's the
+// behaviour the shared 2-pane DiffView can't give us — it's always split — so the
+// master/detail compare pane uses this instead.
+
+import * as monaco from "monaco-editor";
+import { ensureNativeTheme, nativeFontOptions } from "@gitstudio/webview-ui/theme";
+import { languageForFile } from "@gitstudio/webview-ui/language";
+import type { FileDiff } from "../shared/ipc";
+import { bootMonaco } from "./monacoBoot";
+
+/** Below this container width Monaco collapses the diff to a single inline view. */
+const INLINE_BREAKPOINT = 720;
+
+export class CompareDiff {
+  private editor?: monaco.editor.IStandaloneDiffEditor;
+  private models: monaco.editor.ITextModel[] = [];
+
+  constructor(private readonly container: HTMLElement) {
+    bootMonaco();
+  }
+
+  /** Render `file` as original (left/base) → modified (right/compare). */
+  show(file: FileDiff): void {
+    this.teardown();
+    const host = document.createElement("div");
+    host.className = "cmp-diff-editor";
+    this.container.replaceChildren(host);
+
+    const language = languageForFile(file.path);
+    const original = monaco.editor.createModel(file.leftText, language);
+    const modified = monaco.editor.createModel(file.rightText, language);
+    this.models = [original, modified];
+
+    this.editor = monaco.editor.createDiffEditor(host, {
+      theme: ensureNativeTheme(),
+      readOnly: true,
+      originalEditable: false,
+      automaticLayout: true,
+      renderSideBySide: true,
+      // GitHub-like: split when wide, inline when cramped.
+      useInlineViewWhenSpaceIsLimited: true,
+      renderSideBySideInlineBreakpoint: INLINE_BREAKPOINT,
+      minimap: { enabled: false },
+      scrollBeyondLastLine: false,
+      renderOverviewRuler: false,
+      overviewRulerLanes: 0,
+      hideCursorInOverviewRuler: true,
+      scrollbar: { useShadows: false },
+      folding: false,
+      glyphMargin: false,
+      lineNumbersMinChars: 3,
+      ignoreTrimWhitespace: false,
+      inlayHints: { enabled: "off" },
+      codeLens: false,
+      occurrencesHighlight: "off",
+      quickSuggestions: false,
+      ...nativeFontOptions(),
+    });
+    this.editor.setModel({ original, modified });
+  }
+
+  /** Composed placeholder (icon badge + text) when no file is selected. */
+  showEmpty(text: string): void {
+    this.teardown();
+    const empty = document.createElement("div");
+    empty.className = "diff-empty list-empty";
+    const badge = document.createElement("div");
+    badge.className = "list-empty-badge";
+    badge.innerHTML = '';
+    const t = document.createElement("div");
+    t.className = "list-empty-desc";
+    t.textContent = text;
+    empty.append(badge, t);
+    this.container.replaceChildren(empty);
+  }
+
+  layout(): void {
+    this.editor?.layout();
+  }
+
+  dispose(): void {
+    this.teardown();
+  }
+
+  private teardown(): void {
+    this.editor?.dispose();
+    this.editor = undefined;
+    for (const m of this.models) {
+      m.dispose();
+    }
+    this.models = [];
+    this.container.replaceChildren();
+  }
+}
diff --git a/apps/desktop/src/renderer/contextMenu.ts b/apps/desktop/src/renderer/contextMenu.ts
new file mode 100644
index 0000000..3204b5f
--- /dev/null
+++ b/apps/desktop/src/renderer/contextMenu.ts
@@ -0,0 +1,155 @@
+// A lightweight, theme-native right-click menu for a commit row in the graph.
+// Destructive actions (reset --hard, revert) are confirm-gated here in the
+// renderer (via the in-app dialog, not native confirm()) before the request
+// reaches the main process. Fully keyboard-navigable.
+
+import type { CommitActionRequest } from "../shared/ipc";
+import { confirmDialog, promptInline } from "./dialogs";
+
+interface MenuItem {
+  label: string;
+  action: CommitActionRequest["action"];
+  /** Requires a free-text name (new branch / tag). */
+  prompt?: string;
+  /** Show a confirm dialog before dispatching. */
+  confirm?: string;
+  danger?: boolean;
+}
+
+const ITEMS: MenuItem[] = [
+  { label: "Checkout", action: "checkout", confirm: "Checkout this commit (detached HEAD)?" },
+  { label: "Create Branch Here…", action: "branch", prompt: "feature/my-branch" },
+  { label: "Create Tag Here…", action: "tag", prompt: "v1.0.0" },
+  { label: "Cherry-pick", action: "cherry-pick" },
+  { label: "Revert", action: "revert", confirm: "Create a revert commit for this commit?" },
+  { label: "Reset (soft)", action: "reset-soft", confirm: "Move HEAD here, keep index & working tree?" },
+  { label: "Reset (mixed)", action: "reset-mixed", confirm: "Move HEAD here, reset index, keep working tree?" },
+  { label: "Reset (hard)", action: "reset-hard", confirm: "DISCARD all changes and reset HEAD here? This cannot be undone.", danger: true },
+  { label: "Copy SHA", action: "copy-sha" },
+];
+
+export class CommitContextMenu {
+  private menu?: HTMLElement;
+  private prevFocus?: HTMLElement | null;
+  private rows: HTMLElement[] = [];
+  private readonly onDocClick = (): void => this.close();
+  private readonly onKey = (e: KeyboardEvent): void => this.handleKey(e);
+
+  constructor(
+    /** Dispatches a fully-resolved action request to the host. */
+    public readonly resolve: (req: CommitActionRequest) => void,
+  ) {}
+
+  open(sha: string, x: number, y: number): void {
+    this.close();
+    this.prevFocus = document.activeElement as HTMLElement | null;
+    const menu = document.createElement("div");
+    menu.className = "ctx-menu";
+    menu.setAttribute("role", "menu");
+    const header = document.createElement("div");
+    header.className = "ctx-menu-header";
+    header.textContent = sha.slice(0, 10);
+    menu.appendChild(header);
+
+    this.rows = [];
+    for (const item of ITEMS) {
+      const button = document.createElement("button");
+      button.className = `ctx-menu-item${item.danger ? " ctx-danger" : ""}`;
+      button.textContent = item.label;
+      button.setAttribute("role", "menuitem");
+      button.tabIndex = -1;
+      button.addEventListener("click", (e) => {
+        e.stopPropagation();
+        this.close(false);
+        void this.dispatch(item, sha);
+      });
+      menu.appendChild(button);
+      this.rows.push(button);
+    }
+
+    document.body.appendChild(menu);
+    const rect = menu.getBoundingClientRect();
+    const left = Math.min(x, window.innerWidth - rect.width - 8);
+    const top = Math.min(y, window.innerHeight - rect.height - 8);
+    menu.style.left = `${Math.max(8, left)}px`;
+    menu.style.top = `${Math.max(8, top)}px`;
+    this.menu = menu;
+
+    document.addEventListener("keydown", this.onKey, true);
+    setTimeout(() => {
+      document.addEventListener("click", this.onDocClick);
+      this.rows[0]?.focus();
+    }, 0);
+  }
+
+  private focusAt(i: number): void {
+    if (!this.rows.length) return;
+    const idx = ((i % this.rows.length) + this.rows.length) % this.rows.length;
+    this.rows[idx].focus();
+  }
+
+  private handleKey(e: KeyboardEvent): void {
+    if (!this.menu) return;
+    const cur = this.rows.indexOf(document.activeElement as HTMLElement);
+    switch (e.key) {
+      case "Escape":
+        e.preventDefault();
+        this.close();
+        break;
+      case "ArrowDown":
+        e.preventDefault();
+        this.focusAt(cur < 0 ? 0 : cur + 1);
+        break;
+      case "ArrowUp":
+        e.preventDefault();
+        this.focusAt(cur < 0 ? this.rows.length - 1 : cur - 1);
+        break;
+      case "Home":
+        e.preventDefault();
+        this.focusAt(0);
+        break;
+      case "End":
+        e.preventDefault();
+        this.focusAt(this.rows.length - 1);
+        break;
+      case "Enter":
+      case " ":
+        if (cur >= 0) {
+          e.preventDefault();
+          this.rows[cur].click();
+        }
+        break;
+      case "Tab":
+        this.close(false);
+        break;
+    }
+  }
+
+  private async dispatch(item: MenuItem, sha: string): Promise {
+    let name: string | undefined;
+    if (item.prompt) {
+      const value = (await promptInline(item.label.replace(/…$/, ""), item.prompt))?.trim();
+      if (!value) return;
+      name = value;
+    }
+    if (item.confirm) {
+      const ok = await confirmDialog({
+        title: item.label,
+        message: item.confirm,
+        confirmLabel: item.danger ? "Reset" : item.label.replace(/…$/, ""),
+        danger: item.danger,
+      });
+      if (!ok) return;
+    }
+    this.resolve({ action: item.action, sha, name });
+  }
+
+  private close(restoreFocus = true): void {
+    document.removeEventListener("keydown", this.onKey, true);
+    document.removeEventListener("click", this.onDocClick);
+    this.menu?.remove();
+    this.menu = undefined;
+    this.rows = [];
+    if (restoreFocus) this.prevFocus?.focus?.();
+  }
+}
diff --git a/apps/desktop/src/renderer/css.d.ts b/apps/desktop/src/renderer/css.d.ts
new file mode 100644
index 0000000..1808e6f
--- /dev/null
+++ b/apps/desktop/src/renderer/css.d.ts
@@ -0,0 +1,3 @@
+// Lets TypeScript accept the side-effect CSS imports that esbuild bundles
+// (the shared diff/graph stylesheets and the app shell CSS).
+declare module "*.css";
diff --git a/apps/desktop/src/renderer/desktopTheme.ts b/apps/desktop/src/renderer/desktopTheme.ts
new file mode 100644
index 0000000..12f29db
--- /dev/null
+++ b/apps/desktop/src/renderer/desktopTheme.ts
@@ -0,0 +1,47 @@
+// The single most important reuse seam in the renderer.
+//
+// Every shared UI piece — the  element, the Monaco theme
+// bridge in @gitstudio/webview-ui/theme, the lane palette, and the diff/merge
+// CSS — keys entirely off two things: the `vscode-dark` / `vscode-light` class
+// on document.body, and a family of `--vscode-*` CSS custom properties. The
+// extension gets those for free from the VS Code webview host. The desktop app
+// supplies them itself here, so the exact same components render unmodified.
+//
+// We honor `prefers-color-scheme` and react to OS theme changes live.
+
+export type AppTheme = "dark" | "light";
+/** The user's choice: follow the OS, or pin light/dark. */
+export type ThemeMode = "system" | "light" | "dark";
+/** The dock icon choice: "auto" follows the resolved theme, or pin light/dark. */
+export type LogoMode = "auto" | "light" | "dark";
+
+/** Apply the theme: set the body class the shared code reads and let CSS vars resolve. */
+export function applyTheme(theme: AppTheme): void {
+  const body = document.body;
+  body.classList.remove("vscode-dark", "vscode-light");
+  body.classList.add(theme === "light" ? "vscode-light" : "vscode-dark");
+  body.dataset.theme = theme;
+}
+
+/** Resolve the current OS color-scheme preference. */
+export function preferredTheme(): AppTheme {
+  return window.matchMedia?.("(prefers-color-scheme: light)").matches
+    ? "light"
+    : "dark";
+}
+
+/** Resolve a mode to a concrete theme ("system" → the live OS preference). */
+export function resolveTheme(mode: ThemeMode): AppTheme {
+  return mode === "system" ? preferredTheme() : mode;
+}
+
+/** Notify on OS theme changes (does NOT auto-apply — the caller honors the mode). */
+export function followSystemTheme(onChange: (osTheme: AppTheme) => void): () => void {
+  const mq = window.matchMedia?.("(prefers-color-scheme: light)");
+  if (!mq) {
+    return () => {};
+  }
+  const listener = (): void => onChange(preferredTheme());
+  mq.addEventListener("change", listener);
+  return () => mq.removeEventListener("change", listener);
+}
diff --git a/apps/desktop/src/renderer/dialogs.ts b/apps/desktop/src/renderer/dialogs.ts
new file mode 100644
index 0000000..530938f
--- /dev/null
+++ b/apps/desktop/src/renderer/dialogs.ts
@@ -0,0 +1,287 @@
+// Shared in-app UI primitives — toasts, a confirm dialog, and a text prompt —
+// used by both the main renderer and the commit context menu. Self-contained
+// (no dependency on the renderer's DOM helpers) so any module can import them.
+// These replace the native alert()/confirm()/prompt(), which are jarring (and,
+// for prompt(), unsupported) in an Electron renderer.
+
+function mk(tag: string, cls = ""): HTMLElement {
+  const n = document.createElement(tag);
+  if (cls) n.className = cls;
+  return n;
+}
+
+function gl(name: string): HTMLElement {
+  const s = mk("span", `glyph codicon codicon-${name}`);
+  s.setAttribute("aria-hidden", "true");
+  return s;
+}
+
+export type ToastKind = "error" | "success" | "info";
+
+/** A non-blocking, auto-dismissing in-app toast (replaces native alert()). */
+export function toast(message: string, kind: ToastKind = "info", timeoutMs?: number): void {
+  let stack = document.getElementById("toast-stack");
+  if (!stack) {
+    stack = mk("div", "toast-stack");
+    stack.id = "toast-stack";
+    stack.setAttribute("role", "status");
+    stack.setAttribute("aria-live", "polite");
+    document.body.appendChild(stack);
+  }
+  const t = mk("div", `toast toast-${kind}`);
+  const icon = gl(kind === "error" ? "error" : kind === "success" ? "pass-filled" : "info");
+  const msg = mk("div", "toast-msg");
+  msg.textContent = message;
+  const close = mk("button", "toast-close");
+  close.setAttribute("aria-label", "Dismiss");
+  close.appendChild(gl("close"));
+  t.append(icon, msg, close);
+  stack.appendChild(t);
+  requestAnimationFrame(() => t.classList.add("in"));
+  let timer = 0;
+  const dismiss = (): void => {
+    if (!t.isConnected) return;
+    window.clearTimeout(timer);
+    t.classList.remove("in");
+    t.classList.add("out");
+    t.addEventListener("transitionend", () => t.remove(), { once: true });
+    window.setTimeout(() => t.remove(), 280);
+  };
+  close.addEventListener("click", dismiss);
+  timer = window.setTimeout(dismiss, timeoutMs ?? (kind === "error" ? 7000 : 4000));
+}
+
+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;
+}
+
+/** Focus-trapping modal scaffold shared by confirmDialog + promptInline. */
+function modal(build: (close: () => void) => ModalSpec): void {
+  const prevFocus = document.activeElement as HTMLElement | null;
+  const overlay = mk("div", "modal-overlay");
+  overlay.setAttribute("role", "dialog");
+  overlay.setAttribute("aria-modal", "true");
+  let spec: ModalSpec;
+  let closed = false;
+  const close = (): void => {
+    if (closed) return;
+    closed = true;
+    spec.onClose();
+    overlay.remove();
+    document.removeEventListener("keydown", onKey, true);
+    prevFocus?.focus?.();
+  };
+  const onKey = (e: KeyboardEvent): void => {
+    if (e.key === "Escape") {
+      e.preventDefault();
+      close();
+      return;
+    }
+    if (e.key !== "Tab") return;
+    const f = Array.from(
+      spec.card.querySelectorAll("button, input, [tabindex]:not([tabindex='-1'])"),
+    ).filter((n) => !n.hasAttribute("disabled"));
+    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();
+    }
+  };
+  spec = build(close);
+  if (spec.label) overlay.setAttribute("aria-label", spec.label);
+  overlay.appendChild(spec.card);
+  document.body.appendChild(overlay);
+  overlay.addEventListener("mousedown", (e) => {
+    if (e.target === overlay) close();
+  });
+  document.addEventListener("keydown", onKey, true);
+  setTimeout(() => spec.focusEl.focus(), 0);
+}
+
+/** A styled confirmation dialog (replaces native confirm()); resolves true/false. */
+export function confirmDialog(opts: {
+  title: string;
+  message: string;
+  confirmLabel?: string;
+  danger?: boolean;
+}): Promise {
+  return new Promise((resolve) => {
+    let settled = false;
+    modal((close) => {
+      const card = mk("div", "modal-card");
+      const h = mk("div", "modal-title");
+      h.textContent = opts.title;
+      const body = mk("div", "modal-message");
+      body.textContent = opts.message;
+      const actions = mk("div", "modal-actions");
+      const cancel = mk("button", "mini-btn");
+      cancel.textContent = "Cancel";
+      const ok = mk("button", `btn ${opts.danger ? "btn-danger" : "btn-primary"} modal-ok`);
+      const okLabel = mk("span");
+      okLabel.textContent = opts.confirmLabel ?? "Confirm";
+      ok.appendChild(okLabel);
+      actions.append(cancel, ok);
+      card.append(h, body, actions);
+      cancel.addEventListener("click", () => {
+        settled = true;
+        resolve(false);
+        close();
+      });
+      ok.addEventListener("click", () => {
+        settled = true;
+        resolve(true);
+        close();
+      });
+      return {
+        card,
+        focusEl: ok,
+        label: opts.title,
+        onClose: () => {
+          if (!settled) resolve(false);
+        },
+      };
+    });
+  });
+}
+
+/** A modal text prompt (Electron's renderer has no window.prompt). */
+/** A proper single-step form modal: a required title input + a body textarea →
+ *  `{title, body}` or null. Replaces clumsy sequential prompts for issue/PR-style
+ *  edits, so editing feels like GitHub, not a chain of one-line dialogs. */
+export function editForm(opts: {
+  title: string;
+  okLabel?: string;
+  titleValue?: string;
+  titlePlaceholder?: string;
+  bodyValue?: string;
+  bodyPlaceholder?: string;
+}): Promise<{ title: string; body: string } | null> {
+  return new Promise((resolve) => {
+    let settled = false;
+    modal((close) => {
+      const card = mk("div", "modal-card modal-card-form");
+      const h = mk("div", "modal-title");
+      h.textContent = opts.title;
+      const titleInput = document.createElement("input");
+      titleInput.className = "modal-input";
+      titleInput.placeholder = opts.titlePlaceholder ?? "Title";
+      titleInput.value = opts.titleValue ?? "";
+      const bodyInput = document.createElement("textarea");
+      bodyInput.className = "modal-input modal-textarea";
+      bodyInput.placeholder = opts.bodyPlaceholder ?? "Description…";
+      bodyInput.value = opts.bodyValue ?? "";
+      bodyInput.rows = 7;
+      const actions = mk("div", "modal-actions");
+      const cancel = mk("button", "mini-btn");
+      cancel.textContent = "Cancel";
+      const ok = mk("button", "btn btn-primary modal-ok");
+      const okSpan = mk("span");
+      okSpan.textContent = opts.okLabel ?? "Save";
+      ok.appendChild(okSpan);
+      actions.append(cancel, ok);
+      card.append(h, titleInput, bodyInput, actions);
+      const done = (v: { title: string; body: string } | null): void => {
+        settled = true;
+        resolve(v);
+        close();
+      };
+      const submit = (): void => {
+        const t = titleInput.value.trim();
+        if (!t) {
+          titleInput.focus();
+          return; // title is required
+        }
+        done({ title: t, body: bodyInput.value.trim() });
+      };
+      cancel.addEventListener("click", () => done(null));
+      ok.addEventListener("click", submit);
+      // Enter in the title moves to the body; ⌘/Ctrl+Enter anywhere submits.
+      titleInput.addEventListener("keydown", (e) => {
+        if (e.key === "Enter") {
+          e.preventDefault();
+          bodyInput.focus();
+        }
+      });
+      const metaSubmit = (e: KeyboardEvent): void => {
+        if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+          e.preventDefault();
+          submit();
+        }
+      };
+      titleInput.addEventListener("keydown", metaSubmit);
+      bodyInput.addEventListener("keydown", metaSubmit);
+      return {
+        card,
+        focusEl: titleInput,
+        label: opts.title,
+        onClose: () => {
+          if (!settled) resolve(null);
+        },
+      };
+    });
+  });
+}
+
+export function promptInline(
+  title: string,
+  placeholder: string,
+  value = "",
+  okLabel = "Create",
+  /** When true, an empty submission resolves "" (not null) — null then means
+   *  ONLY an explicit cancel/dismiss. Lets callers tell "cleared" from "cancelled". */
+  allowEmpty = false,
+): Promise {
+  return new Promise((resolve) => {
+    let settled = false;
+    const finish = (v: string | null, close: () => void): void => {
+      settled = true;
+      resolve(v);
+      close();
+    };
+    const submit = (raw: string): string | null => (allowEmpty ? raw.trim() : raw.trim() || null);
+    modal((close) => {
+      const card = mk("div", "modal-card");
+      const h = mk("div", "modal-title");
+      h.textContent = title;
+      const input = document.createElement("input");
+      input.className = "modal-input";
+      input.placeholder = placeholder;
+      input.value = value;
+      const actions = mk("div", "modal-actions");
+      const cancel = mk("button", "mini-btn");
+      cancel.textContent = "Cancel";
+      const ok = mk("button", "btn btn-primary modal-ok");
+      const okSpan = mk("span");
+      okSpan.textContent = okLabel;
+      ok.appendChild(okSpan);
+      actions.append(cancel, ok);
+      card.append(h, input, actions);
+      cancel.addEventListener("click", () => finish(null, close));
+      ok.addEventListener("click", () => finish(submit(input.value), close));
+      input.addEventListener("keydown", (e) => {
+        if (e.key === "Enter") {
+          e.preventDefault();
+          finish(submit(input.value), close);
+        }
+      });
+      return {
+        card,
+        focusEl: input,
+        label: title,
+        onClose: () => {
+          if (!settled) resolve(null);
+        },
+      };
+    });
+  });
+}
diff --git a/apps/desktop/src/renderer/diffPanel.ts b/apps/desktop/src/renderer/diffPanel.ts
new file mode 100644
index 0000000..76d5a9c
--- /dev/null
+++ b/apps/desktop/src/renderer/diffPanel.ts
@@ -0,0 +1,265 @@
+// Mounts the SHARED Monaco surfaces — @gitstudio/webview-ui/diffView (2-pane,
+// JetBrains-style, word-level) and mergeView (3-pane conflict resolver) — into
+// a desktop container. The views are reused unchanged; this only feeds them the
+// payload shapes they already expect (DiffInitPayload / MergeInitPayload), which
+// the main process produced from git-service + the engine diff/merge models.
+
+import * as monaco from "monaco-editor";
+import { DiffView } from "@gitstudio/webview-ui/diffView";
+import { MergeView } from "@gitstudio/webview-ui/mergeView";
+import { languageForFile } from "@gitstudio/webview-ui/language";
+import { ensureNativeTheme, nativeFontOptions } from "@gitstudio/webview-ui/theme";
+import type { DiffInitPayload, MergeInitPayload } from "@gitstudio/host-bridge/protocol";
+import type { ConflictModel, FileDiff } from "../shared/ipc";
+import { bootMonaco } from "./monacoBoot";
+import { host } from "./bridge";
+import { toast } from "./dialogs";
+import { el, span, glyph } from "./ui";
+
+/** How the diff renders: unified single column, or the 2-pane split view. */
+type DiffMode = "inline" | "split";
+const LS_DIFF_MODE = "gitstudio.diffMode";
+/** Below this surface width, an unset preference defaults to inline. */
+const INLINE_DEFAULT_BELOW = 1000;
+
+/**
+ * A single reusable diff/merge surface. Swaps between the 2-pane DiffView and
+ * the 3-pane MergeView depending on whether the opened file is conflicted,
+ * disposing the previous view so Monaco editors never leak.
+ */
+export class DiffPanel {
+  private diff?: DiffView;
+  private merge?: MergeView;
+  /** Inline (unified) mode: Monaco's native diff editor + its two models. */
+  private inline?: monaco.editor.IStandaloneDiffEditor;
+  private inlineModels: monaco.editor.ITextModel[] = [];
+  /** The last-shown file, so the mode toggle can re-render it. */
+  private lastFile?: FileDiff;
+
+  constructor(private readonly container: HTMLElement) {
+    bootMonaco();
+  }
+
+  /** The active mode: the user's persisted choice, else width-derived —
+   *  narrow surfaces read better unified, wide ones side-by-side. */
+  private resolveMode(): DiffMode {
+    try {
+      const saved = localStorage.getItem(LS_DIFF_MODE);
+      if (saved === "inline" || saved === "split") return saved;
+    } catch {
+      /* storage unavailable → width heuristic */
+    }
+    const w = this.container.clientWidth || window.innerWidth;
+    return w < INLINE_DEFAULT_BELOW ? "inline" : "split";
+  }
+
+  /** Renders a file diff — unified or 2-pane per the mode toggle. */
+  showDiff(file: FileDiff): void {
+    this.teardown();
+    this.lastFile = file;
+    const mode = this.resolveMode();
+
+    const wrap = el("div", "diffmode-wrap");
+    const bar = el("div", "diffmode-bar");
+    const seg = el("div", "cmp-mode diffmode-seg");
+    const mkBtn = (m: DiffMode, icon: string, label: string): HTMLButtonElement => {
+      const b = el("button", "cmp-mode-btn" + (mode === m ? " active" : "")) as HTMLButtonElement;
+      b.append(glyph(icon), span(label));
+      b.title = m === "inline" ? "Unified diff (one column)" : "Side-by-side diff";
+      b.setAttribute("aria-pressed", String(mode === m));
+      b.addEventListener("click", () => {
+        if (this.resolveMode() === m && localStorage.getItem(LS_DIFF_MODE)) return;
+        try {
+          localStorage.setItem(LS_DIFF_MODE, m);
+        } catch {
+          /* non-fatal */
+        }
+        if (this.lastFile) this.showDiff(this.lastFile);
+      });
+      return b;
+    };
+    seg.append(mkBtn("inline", "list-flat", "Inline"), mkBtn("split", "split-horizontal", "Split"));
+    bar.append(span(file.path, "diffmode-path"), seg);
+    const body = el("div", "diffmode-body");
+    wrap.append(bar, body);
+    this.container.replaceChildren(wrap);
+
+    if (mode === "split") {
+      const payload: DiffInitPayload = {
+        leftLabel: file.leftLabel,
+        rightLabel: file.rightLabel,
+        leftText: file.leftText,
+        rightText: file.rightText,
+        fileName: file.path,
+        rightEditable: false,
+      };
+      this.diff = new DiffView(body);
+      this.diff.render(payload);
+    } else {
+      this.renderInline(body, file);
+    }
+  }
+
+  /** Unified diff via Monaco's native diff editor (renderSideBySide: false). */
+  private renderInline(body: HTMLElement, file: FileDiff): void {
+    const language = languageForFile(file.path);
+    const original = monaco.editor.createModel(file.leftText, language);
+    const modified = monaco.editor.createModel(file.rightText, language);
+    this.inlineModels = [original, modified];
+    this.inline = monaco.editor.createDiffEditor(body, {
+      theme: ensureNativeTheme(),
+      ...nativeFontOptions(),
+      renderSideBySide: false,
+      readOnly: true,
+      automaticLayout: true,
+      minimap: { enabled: false },
+      scrollBeyondLastLine: false,
+      renderLineHighlight: "none",
+      folding: false,
+      stickyScroll: { enabled: false },
+      hideUnchangedRegions: { enabled: true },
+      renderOverviewRuler: false,
+      diffWordWrap: "off",
+      lineNumbersMinChars: 3,
+    });
+    this.inline.setModel({ original, modified });
+  }
+
+  /**
+   * Renders the 3-pane merge for a conflicted file via the engine merge model,
+   * with a resolution action bar — "Take ours/theirs" (whole-file, via git
+   * stages) and "Mark resolved" (writes the edited result + `git add`). The
+   * merge editor was previously display-only; this is the write-back path.
+   * `onResolved` fires after a successful resolve so the caller can refresh.
+   */
+  showMerge(model: ConflictModel, onResolved?: () => void): void {
+    this.teardown();
+
+    const wrap = el("div", "merge-wrap");
+    const bar = el("div", "merge-bar");
+    const title = el("div", "merge-bar-title");
+    title.append(glyph("git-merge"), span(model.path, "merge-bar-path"));
+    const actions = el("div", "merge-bar-actions");
+    const ours = el("button", "mini-btn") as HTMLButtonElement;
+    ours.append(glyph("arrow-left"), span("Take ours"));
+    ours.title = "Replace the file with your version (current change) and stage it";
+    const theirs = el("button", "mini-btn") as HTMLButtonElement;
+    theirs.append(glyph("arrow-right"), span("Take theirs"));
+    theirs.title = "Replace the file with the incoming version and stage it";
+    const resolve = el("button", "btn btn-primary mini-btn merge-resolve") as HTMLButtonElement;
+    resolve.append(glyph("check"), span("Mark resolved"));
+    resolve.title = "Save your merged result and stage the file as resolved";
+    actions.append(ours, theirs, resolve);
+    bar.append(title, actions);
+
+    const surface = el("div", "merge-surface");
+    wrap.append(bar, surface);
+    this.container.replaceChildren(wrap);
+
+    this.merge = new MergeView(surface);
+    this.merge.render({
+      fileName: model.path,
+      conflictType: "content",
+      source: "git-stages",
+      hasBase: model.hasBase,
+      oursLabel: model.oursLabel,
+      theirsLabel: model.theirsLabel,
+      base: model.base,
+      ours: model.ours,
+      theirs: model.theirs,
+      result: model.result,
+    });
+    // The surface starts at 0 height until Monaco lays out — nudge it.
+    requestAnimationFrame(() => (this.merge as { layout?: () => void } | undefined)?.layout?.());
+
+    const run = async (
+      btn: HTMLButtonElement,
+      op: () => Promise<{ ok: boolean; message?: string }>,
+      okMsg: string,
+    ): Promise => {
+      const prev = btn.textContent;
+      btn.disabled = true;
+      try {
+        const r = await op();
+        if (r.ok) {
+          toast(okMsg, "success");
+          onResolved?.();
+        } else {
+          toast(r.message || "Could not resolve the conflict.", "error");
+        }
+      } catch (err) {
+        toast(String(err), "error");
+      } finally {
+        btn.disabled = false;
+        void prev;
+      }
+    };
+
+    ours.addEventListener("click", () =>
+      run(ours, () => host.invoke("conflict:takeSide", { path: model.path, side: "ours" }), "Took your version."),
+    );
+    theirs.addEventListener("click", () =>
+      run(theirs, () => host.invoke("conflict:takeSide", { path: model.path, side: "theirs" }), "Took the incoming version."),
+    );
+    resolve.addEventListener("click", () =>
+      run(
+        resolve,
+        () =>
+          host.invoke("conflict:resolve", {
+            path: model.path,
+            content: this.merge?.getResultText() ?? model.result,
+          }),
+        "Resolved and staged.",
+      ),
+    );
+  }
+
+  /** The 1-based line numbers currently selected in the working (right) editor —
+   *  for line/hunk staging. Returns null when no real diff/selection is present. */
+  getSelectedLines(): number[] | null {
+    const ed = this.diff?.right;
+    if (!ed) return null;
+    const sel = ed.getSelection();
+    if (!sel) return null;
+    const lines: number[] = [];
+    // A zero-width selection (just a caret) still stages that one line.
+    for (let l = sel.startLineNumber; l <= sel.endLineNumber; l++) lines.push(l);
+    return lines;
+  }
+
+  /** Re-run the 2-pane diff with new whitespace / granularity options. */
+  setRenderOptions(opts: { whitespace?: "none" | "all"; showInner?: boolean }): void {
+    this.diff?.setRenderOptions(opts);
+  }
+
+  /** Shows a composed placeholder (icon badge + text) when nothing is selected. */
+  showEmpty(text: string): void {
+    this.teardown();
+    const empty = document.createElement("div");
+    empty.className = "diff-empty list-empty";
+    const badge = document.createElement("div");
+    badge.className = "list-empty-badge";
+    badge.innerHTML = '';
+    const t = document.createElement("div");
+    t.className = "list-empty-desc";
+    t.textContent = text;
+    empty.append(badge, t);
+    this.container.replaceChildren(empty);
+  }
+
+  dispose(): void {
+    this.teardown();
+  }
+
+  private teardown(): void {
+    this.diff?.dispose();
+    this.diff = undefined;
+    this.merge?.dispose();
+    this.merge = undefined;
+    this.inline?.dispose();
+    this.inline = undefined;
+    for (const m of this.inlineModels) m.dispose();
+    this.inlineModels = [];
+    this.container.replaceChildren();
+  }
+}
diff --git a/apps/desktop/src/renderer/graphMount.ts b/apps/desktop/src/renderer/graphMount.ts
new file mode 100644
index 0000000..53b130d
--- /dev/null
+++ b/apps/desktop/src/renderer/graphMount.ts
@@ -0,0 +1,176 @@
+// Mounts the SHARED  Lit element and drives it off the desktop
+// host. The element is imported and used UNCHANGED from @gitstudio/webview-ui;
+// we only adapt at the boundary: GraphHostAdapter pages via IPC and hands the
+// element the `graphInit`/`graphAppend` messages it already knows how to apply,
+// and the element's `onAction` callback (select/open/context/loadMore) is routed
+// to desktop handlers + the adapter. This is the exact reuse the brief calls for.
+
+import "@gitstudio/webview-ui/graph/commit-graph";
+import type { CommitGraph, GraphAction } from "@gitstudio/webview-ui/graph/commit-graph";
+import { GraphHostAdapter, host } from "./bridge";
+
+export interface GraphCallbacks {
+  onSelect(sha: string): void;
+  onOpen(sha: string): void;
+  onContext(sha: string, x: number, y: number): void;
+}
+
+export class GraphMount {
+  private readonly element: CommitGraph;
+  private readonly adapter: GraphHostAdapter;
+  private readonly container: HTMLElement;
+
+  constructor(container: HTMLElement, cb: GraphCallbacks) {
+    this.container = container;
+    this.element = document.createElement("gitstudio-graph") as CommitGraph;
+    this.element.status = "loading";
+    this.element.onAction = (action: GraphAction) => {
+      switch (action.type) {
+        case "select":
+          cb.onSelect(action.sha);
+          break;
+        case "open":
+          cb.onOpen(action.sha);
+          break;
+        case "context":
+          cb.onContext(action.sha, action.x, action.y);
+          break;
+        case "loadMore":
+          this.adapter.loadMore().catch(() => {
+            /* a paging failure is non-fatal; keep what's already shown */
+          });
+          break;
+        case "refresh":
+          void this.reload();
+          break;
+        case "requestStats":
+          void host
+            .invoke("commit:rowStats", action.shas)
+            .then((stats) => this.element.setRowStats(stats))
+            .catch(() => {});
+          break;
+      }
+    };
+    container.replaceChildren(this.element);
+
+    // Feed host messages straight into the element exactly as the VS Code graph
+    // webview entry does (graphInit replaces rows; graphAppend concatenates).
+    this.adapter = new GraphHostAdapter((message) => {
+      switch (message.type) {
+        case "graphInit":
+          this.element.head = message.head;
+          this.element.rows = message.rows;
+          this.element.totalColumns = message.totalColumns;
+          this.element.hasMore = message.hasMore;
+          if (message.rows.length === 0 && !message.hasMore) {
+            // A genuinely empty history gets the crafted tile, not the shared
+            // element's bare "No commits yet" — consistent with every other view.
+            this.renderEmpty();
+          } else {
+            this.element.status = message.rows.length === 0 ? "empty" : "ready";
+          }
+          break;
+        case "graphAppend":
+          this.element.rows = this.element.rows.concat(message.rows);
+          this.element.totalColumns = Math.max(
+            this.element.totalColumns,
+            message.totalColumns,
+          );
+          this.element.hasMore = message.hasMore;
+          if (this.element.status !== "ready" && this.element.rows.length > 0) {
+            this.element.status = "ready";
+          }
+          break;
+      }
+    });
+  }
+
+  /** (Re)load the graph from the first page — call on open + on repo change.
+   *  On failure, render an in-view error + Retry instead of spinning forever. */
+  async reload(): Promise {
+    this.container.replaceChildren(this.element);
+    this.element.status = "loading";
+    this.element.rows = [];
+    try {
+      await this.adapter.loadInitial();
+    } catch (err) {
+      this.renderError(err);
+    }
+  }
+
+  /** Build the shared crafted state tile (accent icon badge + title + desc + CTA),
+   *  matching the desktop `.list-empty` empty/error states used across every view. */
+  private buildTile(
+    iconName: string,
+    title: string,
+    desc: string,
+    action?: { icon: string; label: string; onClick: () => void; primary?: boolean },
+  ): HTMLElement {
+    const wrap = document.createElement("div");
+    wrap.className = "list-empty";
+    const badge = document.createElement("div");
+    badge.className = "list-empty-badge";
+    const icon = document.createElement("span");
+    icon.className = `glyph codicon codicon-${iconName}`;
+    badge.appendChild(icon);
+    const titleEl = document.createElement("div");
+    titleEl.className = "list-empty-title";
+    titleEl.textContent = title;
+    const descEl = document.createElement("div");
+    descEl.className = "list-empty-desc";
+    descEl.textContent = desc;
+    wrap.append(badge, titleEl, descEl);
+    if (action) {
+      const btn = document.createElement("button");
+      btn.className = `${action.primary ? "btn btn-primary" : "mini-btn"} list-empty-action`;
+      btn.innerHTML = `${action.label}`;
+      btn.addEventListener("click", action.onClick);
+      wrap.appendChild(btn);
+    }
+    return wrap;
+  }
+
+  /** Replace the graph with a centered "couldn't load history" + Retry panel. */
+  private renderError(err: unknown): void {
+    const desc =
+      (err instanceof Error ? err.message : String(err ?? "")).replace(
+        /^Error invoking remote method '[^']*':\s*/i,
+        "",
+      ) || "The git log couldn't be read for this repository.";
+    const wrap = this.buildTile("warning", "Couldn't load history", desc, {
+      icon: "refresh",
+      label: "Retry",
+      onClick: () => void this.reload(),
+    });
+    wrap.classList.add("list-error");
+    this.container.replaceChildren(wrap);
+  }
+
+  /** Replace the graph with a crafted "no commits yet" tile. */
+  private renderEmpty(): void {
+    const wrap = this.buildTile(
+      "git-commit",
+      "No commits yet",
+      "This branch has no history. Make your first commit and it'll appear here.",
+    );
+    this.container.replaceChildren(wrap);
+  }
+
+  /** Clear to the empty state (no repo open). */
+  clear(): void {
+    this.adapter.reset();
+    this.element.rows = [];
+    this.element.status = "empty";
+  }
+
+  /** Select + scroll a commit (e.g. a branch tip) into view. */
+  reveal(sha: string): void {
+    this.element.reveal(sha);
+  }
+
+  /** Detach the Lit element so its disconnectedCallback tears down listeners. */
+  dispose(): void {
+    this.adapter.reset();
+    this.element.remove();
+  }
+}
diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html
new file mode 100644
index 0000000..977271e
--- /dev/null
+++ b/apps/desktop/src/renderer/index.html
@@ -0,0 +1,26 @@
+
+
+  
+    
+    
+    
+    
+    
+    GitStudio
+  
+  
+    
+    
+
Loading GitStudio…
+
+ + + diff --git a/apps/desktop/src/renderer/markdown.ts b/apps/desktop/src/renderer/markdown.ts new file mode 100644 index 0000000..f9d5184 --- /dev/null +++ b/apps/desktop/src/renderer/markdown.ts @@ -0,0 +1,275 @@ +// GitHub-flavored-ish Markdown → safe HTML for the desktop renderer's README card. +// +// Design: HTML-escape ALL user content FIRST, then emit only a fixed, known set +// of tags from our own template strings. User text never reaches innerHTML +// un-escaped, so there is no XSS vector. CSP-safe: produces a string for +// `element.innerHTML` (no remote/inline + +`; +} diff --git a/apps/extension/src/ai/aiSettingsPanel.ts b/apps/extension/src/ai/aiSettingsPanel.ts new file mode 100644 index 0000000..4473d3a --- /dev/null +++ b/apps/extension/src/ai/aiSettingsPanel.ts @@ -0,0 +1,639 @@ +import * as vscode from "vscode"; +import type { GitBrain } from "./gitBrain"; +import { getNonce } from "../webview/html"; +// Shared design tokens (inlined as text by esbuild). We ALSO inline the desktop +// app's own tokens below so this panel is a pixel-for-pixel match of the app's +// "AI Models" settings — the same violet, cards, pills, gallery and fields. +import tokensCss from "../../../../packages/webview-ui/src/styles/tokens.css"; + +/** Real brand marks (from the app's Lobehub icon set, MIT) — monochrome inline + * SVG using currentColor. Keyed by our provider ids; injected into the webview. + * Providers without a mark fall back to a codicon. */ +const LOGO_PATHS: Record = { + anthropic: "", + openai: "", + openrouter: "", + groq: "", + ollama: "", + lmstudio: "", + google: "", + // Local-agent CLIs reuse the underlying vendor's mark. + "claude-code": "@anthropic", + codex: "@openai", + "gemini-cli": "@google", +}; + +interface FromPanel { + type: "ready" | "connect" | "disconnect" | "test" | "setStyle" | "openExternal" | "detectModels"; + kind?: string; + baseUrl?: string; + model?: string; + key?: string; + style?: string; + url?: string; +} + +/** + * The AI connection surface — an in-editor webview that faithfully reproduces the + * desktop app's "AI Models" settings: a card, the connected model as a row, and a + * "Connect a model" gallery (bring a key, run a model locally, or use your + * editor's AI) with an inline editor. Configures the extension's provider layer, + * so the ✨ commit message and AI code review just work once connected. + */ +export class AiSettingsPanel { + private static current: AiSettingsPanel | undefined; + private readonly disposables: vscode.Disposable[] = []; + + static show(brain: GitBrain, extensionUri: vscode.Uri): void { + if (AiSettingsPanel.current) { + AiSettingsPanel.current.panel.reveal(vscode.ViewColumn.Active); + void AiSettingsPanel.current.pushStatus(); + return; + } + const panel = vscode.window.createWebviewPanel( + "gitstudio.aiSettings", + "GitStudio · AI", + vscode.ViewColumn.Active, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.joinPath(extensionUri, "dist")], + }, + ); + AiSettingsPanel.current = new AiSettingsPanel(panel, brain, extensionUri); + } + + private constructor( + private readonly panel: vscode.WebviewPanel, + private readonly brain: GitBrain, + extensionUri: vscode.Uri, + ) { + this.panel.webview.html = this.html(this.panel.webview, extensionUri); + this.disposables.push( + this.panel.webview.onDidReceiveMessage((m: FromPanel) => this.onMessage(m)), + this.panel.onDidDispose(() => this.dispose()), + ); + } + + private async pushStatus(): Promise { + try { + const status = await this.brain.connectionStatus(); + void this.panel.webview.postMessage({ type: "status", status }); + } catch { + /* panel may be closing */ + } + } + + /** One atomic connect/test outcome + fresh status. */ + private async postResult(ok: boolean, message: string): Promise { + try { + const status = await this.brain.connectionStatus(); + void this.panel.webview.postMessage({ type: "result", ok, message, status }); + } catch { + /* panel may be closing */ + } + } + + private async onMessage(m: FromPanel): Promise { + switch (m.type) { + case "ready": + await this.pushStatus(); + return; + case "connect": + await this.connect(m); + return; + case "test": { + const r = await this.brain.testConnection(); + await this.postResult(r.ok, r.message); + return; + } + case "disconnect": + await this.brain.disconnectAll(); + await this.pushStatus(); + return; + case "setStyle": + if (m.style) { + await this.brain.setCommitStyle(m.style as "conventional" | "concise" | "descriptive"); + } + await this.pushStatus(); + return; + case "openExternal": + if (m.url) { + void vscode.env.openExternal(vscode.Uri.parse(m.url)); + } + return; + case "detectModels": { + const list = await this.brain.detectModels( + m.kind ?? "", + (m.baseUrl ?? "").trim(), + (m.key ?? "").trim(), + ); + void this.panel.webview.postMessage({ + type: "models", + kind: m.kind ?? "", + list, + }); + return; + } + } + } + + private async connect(m: FromPanel): Promise { + const kind = m.kind ?? ""; + const model = (m.model ?? "").trim(); + const key = (m.key ?? "").trim(); + const baseUrl = (m.baseUrl ?? "").trim(); + try { + switch (kind) { + case "copilot": + await this.brain.setProviderChoice("copilot"); + break; + case "anthropic": + if (key) { + await this.brain.setAnthropicKey(key); + } + if (model) { + await this.brain.setAnthropicModel(model); + } + await this.brain.setProviderChoice("anthropic"); + break; + case "openai": + case "openrouter": + case "groq": + case "custom": + await this.brain.setOpenAiEndpoint(baseUrl, model); + if (key) { + await this.brain.setOpenAiKey(key); + } + await this.brain.setProviderChoice("openai"); + break; + case "ollama": + case "lmstudio": + await this.brain.setOpenAiEndpoint(baseUrl, model); + await this.brain.setProviderChoice("openai"); + break; + case "claude-code": + case "codex": + case "gemini-cli": + await this.brain.setCliAgent(kind); + break; + default: + break; + } + const r = await this.brain.testConnection(); + await this.postResult(r.ok, r.message); + } catch (e) { + await this.postResult(false, e instanceof Error ? e.message : "Couldn't connect."); + } + } + + private html(webview: vscode.Webview, extensionUri: vscode.Uri): string { + const nonce = getNonce(); + const codiconUri = webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, "dist", "codicons", "codicon.css"), + ); + const csp = [ + `default-src 'none'`, + `style-src 'nonce-${nonce}' ${webview.cspSource}`, + `font-src ${webview.cspSource}`, + `script-src 'nonce-${nonce}'`, + ].join("; "); + + return ` + + + + + + + +
+

GitStudio AI

+

Connect a model to power the ✨ commit messages and AI code review. Bring your own API key, run a model locally (fully private), or use your editor's built-in AI. It's optional and never blocks Git.

+
+
+ +`; + } + + dispose(): void { + AiSettingsPanel.current = undefined; + this.panel.dispose(); + for (const d of this.disposables) { + d.dispose(); + } + this.disposables.length = 0; + } +} diff --git a/apps/extension/src/ai/anthropicProvider.ts b/apps/extension/src/ai/anthropicProvider.ts new file mode 100644 index 0000000..15d2288 --- /dev/null +++ b/apps/extension/src/ai/anthropicProvider.ts @@ -0,0 +1,229 @@ +import { + AnthropicSseParser, + extractAnthropicText, + type AnthropicMessageResponse, +} from "@gitstudio/engine/ai/gitBrainCore"; +import type { + GitBrainProvider, + CompleteRequest, + ModelTier, +} from "./gitBrain"; + +// The Anthropic bring-your-own-key provider. Runs entirely on the extension +// host (Node 22 → global fetch), so the API key never reaches a webview and +// there's no CORS to fight. The key storage is INJECTED via `getKey` so this +// class stays portable (the future desktop app can hand it a different store) +// and free of any vscode import beyond what the logger callback chooses to do. +// +// We hand-roll the HTTP rather than pull in @anthropic-ai/sdk: it keeps the +// extension bundle light and the request shape is small and stable. + +const ENDPOINT = "https://api.anthropic.com/v1/messages"; +const ANTHROPIC_VERSION = "2023-06-01"; + +/** Exact model IDs (no date suffixes) per the M10 authoritative facts. */ +const DEFAULT_MODELS: Record = { + fast: "claude-haiku-4-5", + mid: "claude-sonnet-4-6", + deep: "claude-opus-4-8", +}; + +export interface AnthropicProviderOptions { + /** + * Injected secret getter — returns the stored key, or undefined when unset. + * Accepts a Thenable so vscode's SecretStorage.get can be passed directly. + */ + getKey: () => PromiseLike; + /** Friendly-message sink for surfaced errors (toast / log). Never throws. */ + onError?: (message: string) => void; + /** Optional per-tier model overrides (from gitstudio.ai.anthropicModel*). */ + models?: Partial>; + /** Injected fetch (defaults to global). Lets tests stub the network. */ + fetchImpl?: typeof fetch; +} + +interface AnthropicSystemBlock { + type: "text"; + text: string; + cache_control?: { type: "ephemeral" }; +} + +interface AnthropicRequestBody { + model: string; + max_tokens: number; + system?: AnthropicSystemBlock[]; + messages: Array<{ role: "user" | "assistant"; content: string }>; + stream?: boolean; +} + +export class AnthropicProvider implements GitBrainProvider { + readonly id = "anthropic"; + + constructor(private readonly opts: AnthropicProviderOptions) {} + + /** Available iff a key is present. */ + async isAvailable(): Promise { + const key = await this.opts.getKey(); + return typeof key === "string" && key.trim().length > 0; + } + + private modelFor(tier: ModelTier): string { + return this.opts.models?.[tier] ?? DEFAULT_MODELS[tier]; + } + + private maxTokensFor(req: CompleteRequest): number { + if (req.maxTokens !== undefined) { + return req.maxTokens; + } + // Commit messages stay tight; explain/summaries get more room. + return req.model === "fast" ? 512 : 1024; + } + + private buildBody(req: CompleteRequest, stream: boolean): AnthropicRequestBody { + const tier: ModelTier = req.model ?? "fast"; + const body: AnthropicRequestBody = { + model: this.modelFor(tier), + max_tokens: this.maxTokensFor(req), + messages: [{ role: "user", content: req.prompt }], + }; + if (stream) { + body.stream = true; + } + if (req.system && req.system.trim().length > 0) { + // Put the stable repo-context prefix in `system` as a cacheable block; + // the volatile diff sits in the user message after it. + const block: AnthropicSystemBlock = { type: "text", text: req.system }; + if (req.systemCacheable) { + block.cache_control = { type: "ephemeral" }; + } + body.system = [block]; + } + return body; + } + + /** Common header set; resolves the key or returns undefined when missing. */ + private async headers(): Promise | undefined> { + const key = await this.opts.getKey(); + if (!key || key.trim().length === 0) { + return undefined; + } + return { + "x-api-key": key.trim(), + "anthropic-version": ANTHROPIC_VERSION, + "content-type": "application/json", + }; + } + + /** Map an HTTP status to a friendly message; null for "no friendly note". */ + private friendlyFor(status: number): string { + switch (status) { + case 401: + return "GitBrain: the Anthropic API key is missing or invalid. Run “GitStudio: Set AI API Key”."; + case 429: + return "GitBrain: Anthropic rate limit hit — try again in a moment."; + case 529: + return "GitBrain: Anthropic is temporarily overloaded — try again shortly."; + default: + return `GitBrain: Anthropic request failed (HTTP ${status}).`; + } + } + + private report(message: string): void { + this.opts.onError?.(message); + } + + async complete(req: CompleteRequest): Promise { + const headers = await this.headers(); + if (!headers) { + return null; + } + const fetchImpl = this.opts.fetchImpl ?? fetch; + try { + const res = await fetchImpl(ENDPOINT, { + method: "POST", + headers, + body: JSON.stringify(this.buildBody(req, false)), + signal: req.signal, + }); + if (!res.ok) { + this.report(this.friendlyFor(res.status)); + return null; + } + const json = (await res.json()) as AnthropicMessageResponse; + const text = extractAnthropicText(json); + if (text === null && json.stop_reason === "refusal") { + this.report("GitBrain: the model declined this request."); + } + return text; + } catch (err) { + if (isAbort(err)) { + return null; + } + this.report("GitBrain: couldn't reach Anthropic (network error)."); + return null; + } + } + + async stream( + req: CompleteRequest, + onDelta: (text: string) => void, + ): Promise { + const headers = await this.headers(); + if (!headers) { + return null; + } + const fetchImpl = this.opts.fetchImpl ?? fetch; + try { + const res = await fetchImpl(ENDPOINT, { + method: "POST", + headers, + body: JSON.stringify(this.buildBody(req, true)), + signal: req.signal, + }); + if (!res.ok) { + this.report(this.friendlyFor(res.status)); + return null; + } + const body = res.body; + if (!body) { + // No stream body — fall back to non-streaming. + return this.complete(req); + } + + const parser = new AnthropicSseParser(); + const decoder = new TextDecoder("utf8"); + let assembled = ""; + const reader = body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + const chunk = decoder.decode(value, { stream: true }); + for (const delta of parser.push(chunk)) { + assembled += delta; + onDelta(delta); + } + if (parser.done) { + break; + } + } + } finally { + reader.releaseLock(); + } + const trimmed = assembled.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch (err) { + if (isAbort(err)) { + return null; + } + this.report("GitBrain: couldn't reach Anthropic (network error)."); + return null; + } + } +} + +function isAbort(err: unknown): boolean { + return err instanceof Error && err.name === "AbortError"; +} diff --git a/apps/extension/src/ai/cliProvider.ts b/apps/extension/src/ai/cliProvider.ts new file mode 100644 index 0000000..27b5247 --- /dev/null +++ b/apps/extension/src/ai/cliProvider.ts @@ -0,0 +1,126 @@ +// A GitBrain provider that drives a locally-installed agent CLI — Claude Code +// (`claude`), Codex (`codex`), or the Gemini CLI (`gemini`) — in non-interactive +// "print" mode, using the CLI's OWN login/subscription instead of an API key. +// This is how the extension "connects to a local agent" alongside the BYO-key +// HTTP providers. It spawns a process, so it lives here (not in the shared core). + +import { spawn } from "node:child_process"; +import type { GitBrainProvider, CompleteRequest } from "./gitBrain"; + +interface CliSpec { + command: string; + /** argv (excluding the binary) for a one-shot prompt + optional model. */ + args(prompt: string, model?: string): string[]; + /** Install hint surfaced when the binary is missing. */ + install: string; +} + +/** agent id → how to invoke its CLI. Model flags kept conservative + optional. */ +export const CLI_SPECS: Record = { + "claude-code": { + command: "claude", + args: (prompt, model) => [ + "-p", + "--strict-mcp-config", + ...(model ? ["--model", model] : []), + prompt, + ], + install: "Install Claude Code and run `claude login` (docs.anthropic.com/claude-code).", + }, + codex: { + command: "codex", + args: (prompt, model) => ["exec", ...(model ? ["--model", model] : []), prompt], + install: "Install the Codex CLI and sign in (github.com/openai/codex).", + }, + "gemini-cli": { + command: "gemini", + args: (prompt, model) => ["-p", ...(model ? ["--model", model] : []), prompt], + install: "Install the Gemini CLI and sign in (github.com/google-gemini/gemini-cli).", + }, +}; + +// Strip ANSI escapes a CLI may emit even in print mode. +// eslint-disable-next-line no-control-regex +const ANSI = /\x1b\[[0-9;]*[A-Za-z]/g; + +export interface CliProviderOptions { + agent: string; + /** Working dir — the open repo, so the CLI grounds itself in real state. */ + cwd: () => string | undefined; + /** Optional model override (empty ⇒ the CLI's default). */ + model: () => string | undefined; +} + +export class CliProvider implements GitBrainProvider { + readonly id: string; + + constructor(private readonly opts: CliProviderOptions) { + this.id = "cli:" + opts.agent; + } + + /** True when the CLI binary is on PATH. */ + async isAvailable(): Promise { + const spec = CLI_SPECS[this.opts.agent]; + return spec ? binaryExists(spec.command) : false; + } + + async complete(req: CompleteRequest): Promise { + const spec = CLI_SPECS[this.opts.agent]; + if (!spec) { + return null; + } + const prompt = (req.system ? req.system + "\n\n" : "") + req.prompt; + const model = this.opts.model(); + return this.run(spec, prompt, model, req.signal); + } + + private run( + spec: CliSpec, + prompt: string, + model: string | undefined, + signal?: AbortSignal, + ): Promise { + return new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn(spec.command, spec.args(prompt, model), { + cwd: this.opts.cwd(), + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + resolve(null); + return; + } + let out = ""; + if (signal) { + if (signal.aborted) { + child.kill("SIGTERM"); + } else { + signal.addEventListener("abort", () => child.kill("SIGTERM"), { once: true }); + } + } + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (d: string) => (out += d)); + child.on("error", () => resolve(null)); + child.on("close", (code) => { + const text = out.replace(ANSI, "").trim(); + resolve(code === 0 && text ? text : null); + }); + }); + } +} + +/** Whether `cmd` resolves on PATH (`which` / `where`), best-effort. */ +function binaryExists(cmd: string): Promise { + return new Promise((resolve) => { + try { + const finder = process.platform === "win32" ? "where" : "which"; + const c = spawn(finder, [cmd], { stdio: "ignore" }); + c.on("error", () => resolve(false)); + c.on("close", (code) => resolve(code === 0)); + } catch { + resolve(false); + } + }); +} diff --git a/apps/extension/src/ai/gitBrain.ts b/apps/extension/src/ai/gitBrain.ts new file mode 100644 index 0000000..aa4b83b --- /dev/null +++ b/apps/extension/src/ai/gitBrain.ts @@ -0,0 +1,622 @@ +import * as vscode from "vscode"; +import { + buildCommitStyleSystem, + buildCommitPrompt, + buildExplainPrompt, + buildSummarizePrompt, + buildPrDescriptionPrompt, + truncateDiff, + type CommitStyle, +} from "@gitstudio/engine/ai/gitBrainCore"; +import { AnthropicProvider } from "./anthropicProvider"; +import { VsCodeLmProvider, type LmModelInfo } from "./vscodeLmProvider"; +import { CliProvider, CLI_SPECS } from "./cliProvider"; +import { OpenAiProvider, type OpenAiConfig } from "./openAiProvider"; + +// GitBrain — the optional, bring-your-own-key AI layer (M10). +// +// It is OFF until configured: with no usable provider, every feature returns +// null, the `gitstudio.ai.enabled` context key stays false, and the AI +// affordances (palette commands, the commit-box ✨) stay hidden. AI never gates +// or breaks a git operation — a missing key or a failed request just means "no +// AI here", silently. + +export type ModelTier = "fast" | "mid" | "deep"; + +/** What every GitBrain provider must implement. */ +export interface GitBrainProvider { + readonly id: string; + isAvailable(): Promise | boolean; + complete(req: CompleteRequest): Promise; + /** Optional streaming variant (explain/summaries prefer it). */ + stream?( + req: CompleteRequest, + onDelta: (text: string) => void, + ): Promise; +} + +export interface CompleteRequest { + /** Stable, cacheable repo-context prefix (goes in `system`). */ + system?: string; + /** Whether to mark `system` with cache_control: ephemeral. */ + systemCacheable?: boolean; + /** The volatile prompt (the staged diff lives here). */ + prompt: string; + /** Maps to a concrete model ID inside the provider. */ + model?: ModelTier; + maxTokens?: number; + signal?: AbortSignal; +} + +/** + * The user's provider choice (gitstudio.ai.provider). + * `copilot` selects the VS Code Language Model API (Copilot / Cursor models). + * `openai` selects any OpenAI-compatible endpoint (incl. local Ollama/LM Studio). + */ +export type ProviderChoice = "auto" | "copilot" | "anthropic" | "openai" | "cli" | "off"; + +/** Snapshot of the AI connection state for the settings webview panel. */ +export interface AiConnectionStatus { + provider: ProviderChoice; + /** A provider is actually usable right now. */ + ready: boolean; + /** Which provider is active (anthropic/openai/vscode-lm), when ready. */ + activeId?: string; + hasAnthropicKey: boolean; + hasOpenaiKey: boolean; + copilotAvailable: boolean; + openaiBaseUrl: string; + openaiModel: string; + /** The selected local-agent CLI (claude-code/codex/gemini-cli), or "". */ + cliAgent: string; + commitStyle: CommitStyle; +} + +/** Storage key for the Anthropic API key in SecretStorage. */ +export const ANTHROPIC_KEY_SECRET = "gitstudio.ai.anthropicApiKey"; + +/** Storage key for the OpenAI-compatible API key in SecretStorage (optional). */ +export const OPENAI_KEY_SECRET = "gitstudio.ai.openaiApiKey"; + +/** globalState key remembering the user's chosen vscode.lm model id. */ +export const LM_MODEL_STATE_KEY = "gitstudio.ai.lmModelId"; + +/** + * Built-in AI code-review prompt (used unless the user overrides it via the + * `gitstudio.ai.reviewPrompt` setting). Tuned to be useful like an editor's + * inline reviewer: substantive findings, severity-ranked, with fixes — not a + * restatement of the diff and not style nitpicking. + */ +export const DEFAULT_REVIEW_PROMPT = [ + "You are an expert code reviewer performing a focused review of a git diff,", + "like a senior engineer reviewing a pull request. Report ONLY substantive", + "findings: real bugs, correctness errors, security issues, resource leaks,", + "race conditions, missing error handling, and clear design/maintainability", + "problems the change introduces.", + "", + "Guidelines:", + "- Prioritize correctness and security. Skip pure style/formatting nitpicks", + " unless they cause a real bug.", + "- Order findings most-severe first. Do NOT invent issues; if the change is", + " clean, say so.", + "- Be specific and cite the exact code. Do NOT restate what the diff does.", + "- Only review the changes shown; note if you need more context.", + "", + "Respond in GitHub-flavored Markdown, structured EXACTLY like this:", + "", + "1. One sentence summarizing the overall risk of the change.", + "2. A line `## Findings`, then one bullet per finding, formatted as:", + " - **** `path/to/file.ext:line` — Short title. One sentence on the", + " concrete failure it causes. **Fix:** the specific change to make.", + " where is exactly High, Medium, or Low (bold, first in the bullet).", + "", + "If there are no substantive issues, respond with exactly this single line and", + "nothing else: `✅ No substantive issues found — the change looks clean.`", +].join("\n"); + +export interface CommitMessageOptions { + style?: CommitStyle; + /** Recent commit subjects for the cacheable style prefix. */ + recentSubjects?: readonly string[]; + signal?: AbortSignal; +} + +export class GitBrain implements vscode.Disposable { + private readonly anthropic: AnthropicProvider; + private readonly vscodeLm: VsCodeLmProvider; + private readonly openai: OpenAiProvider; + private readonly disposables: vscode.Disposable[] = []; + /** Last error a provider reported — surfaced by testConnection() with detail. */ + private lastProviderError: string | undefined; + /** While a test is running, route provider errors to the note (no toast). */ + private suppressErrorToast = false; + /** Fires when AI becomes enabled/disabled, so views can refresh live. */ + private readonly enabledChanged = new vscode.EventEmitter(); + readonly onDidChangeEnabled = this.enabledChanged.event; + private lastEnabled: boolean | undefined; + + constructor(private readonly context: vscode.ExtensionContext) { + const onError = (message: string) => { + this.lastProviderError = message; + if (!this.suppressErrorToast) { + void vscode.window.showWarningMessage(message); + } + }; + this.anthropic = new AnthropicProvider({ + getKey: () => this.context.secrets.get(ANTHROPIC_KEY_SECRET), + onError, + models: this.readModelOverrides(), + }); + this.vscodeLm = new VsCodeLmProvider({ + getPreferredModelId: () => this.preferredLmModelId(), + }); + this.openai = new OpenAiProvider({ + getKey: () => this.context.secrets.get(OPENAI_KEY_SECRET), + config: () => this.openAiConfig(), + onError, + }); + + // Re-evaluate availability when a key changes or a relevant setting flips. + this.disposables.push( + this.context.secrets.onDidChange((e) => { + if (e.key === ANTHROPIC_KEY_SECRET || e.key === OPENAI_KEY_SECRET) { + void this.refreshEnabled(); + } + }), + vscode.workspace.onDidChangeConfiguration((e) => { + if ( + e.affectsConfiguration("gitstudio.ai.provider") || + e.affectsConfiguration("gitstudio.ai.anthropicModelFast") || + e.affectsConfiguration("gitstudio.ai.anthropicModelMid") || + e.affectsConfiguration("gitstudio.ai.anthropicModelDeep") || + e.affectsConfiguration("gitstudio.ai.openai.baseUrl") || + e.affectsConfiguration("gitstudio.ai.openai.modelFast") || + e.affectsConfiguration("gitstudio.ai.openai.modelMid") || + e.affectsConfiguration("gitstudio.ai.openai.modelDeep") + ) { + void this.refreshEnabled(); + } + }), + this.enabledChanged, + ); + } + + /** Compute and publish `gitstudio.ai.enabled` so menus/buttons show/hide. */ + async refreshEnabled(): Promise { + const enabled = await this.isEnabled(); + await vscode.commands.executeCommand( + "setContext", + "gitstudio.ai.enabled", + enabled, + ); + // Notify listeners (the commit composer) so the ✨/plug buttons update the + // instant a model is connected or disconnected — not on the next git event. + if (enabled !== this.lastEnabled) { + this.lastEnabled = enabled; + this.enabledChanged.fire(enabled); + } + } + + private config() { + return vscode.workspace.getConfiguration("gitstudio.ai"); + } + + private readModelOverrides(): Partial> { + const cfg = this.config(); + const overrides: Partial> = {}; + const fast = cfg.get("anthropicModelFast"); + const mid = cfg.get("anthropicModelMid"); + const deep = cfg.get("anthropicModelDeep"); + if (fast) overrides.fast = fast; + if (mid) overrides.mid = mid; + if (deep) overrides.deep = deep; + return overrides; + } + + /** Read the OpenAI-compatible base URL + per-tier model IDs from settings. */ + private openAiConfig(): OpenAiConfig { + const cfg = this.config(); + return { + baseUrl: cfg.get("openai.baseUrl", "https://api.openai.com/v1"), + models: { + fast: cfg.get("openai.modelFast", ""), + mid: cfg.get("openai.modelMid", ""), + deep: cfg.get("openai.modelDeep", ""), + }, + }; + } + + /** The remembered vscode.lm model id (from the model picker), if any. */ + private preferredLmModelId(): string | undefined { + const id = this.context.globalState.get(LM_MODEL_STATE_KEY); + return id && id.length > 0 ? id : undefined; + } + + /** Remember the user's vscode.lm model pick (used by the model picker). */ + async setPreferredLmModelId(id: string | undefined): Promise { + await this.context.globalState.update(LM_MODEL_STATE_KEY, id); + } + + /** List available vscode.lm chat models (Copilot + Cursor), for the picker. */ + listLmModels(): Promise { + return this.vscodeLm.listModels(); + } + + private providerChoice(): ProviderChoice { + return this.config().get("provider", "auto"); + } + + private commitStyle(): CommitStyle { + return this.config().get("commitStyle", "conventional"); + } + + private cliAgent(): string { + return this.config().get("cliAgent", ""); + } + + /** Build the local-agent CLI provider for the current settings (or undefined). */ + private cliProviderFor(agent: string): CliProvider | undefined { + if (!CLI_SPECS[agent]) { + return undefined; + } + return new CliProvider({ + agent, + cwd: () => vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, + model: () => this.config().get("cliModel", "") || undefined, + }); + } + + /** + * Resolve the active provider per the `provider` setting: + * auto → vscode.lm (zero-key Copilot/Cursor) → Anthropic (if keyed) → + * OpenAI-compatible (if a model is configured) → none. + * copilot → vscode.lm iff available. + * anthropic → Anthropic iff a key is set. + * openai → OpenAI-compatible iff a model is configured. + * off → none. + * Returns undefined when nothing is usable (features hidden). + */ + async getProvider(): Promise { + const choice = this.providerChoice(); + if (choice === "off") { + return undefined; + } + if (choice === "copilot") { + return (await this.vscodeLm.isAvailable()) ? this.vscodeLm : undefined; + } + if (choice === "anthropic") { + return (await this.anthropic.isAvailable()) ? this.anthropic : undefined; + } + if (choice === "openai") { + return this.openai.isAvailable() ? this.openai : undefined; + } + if (choice === "cli") { + const cli = this.cliProviderFor(this.cliAgent()); + return cli && (await cli.isAvailable()) ? cli : undefined; + } + // auto: prefer the zero-key vscode.lm path, then a keyed Anthropic, then a + // configured OpenAI-compatible endpoint (incl. a local model server). + if (await this.vscodeLm.isAvailable()) { + return this.vscodeLm; + } + if (await this.anthropic.isAvailable()) { + return this.anthropic; + } + if (this.openai.isAvailable()) { + return this.openai; + } + return undefined; + } + + /** True when some provider is usable. */ + async isEnabled(): Promise { + return (await this.getProvider()) !== undefined; + } + + // ── High-level features ──────────────────────────────────────────────────── + + /** Draft a commit message from the staged diff. Null when AI is unavailable. */ + async generateCommitMessage( + diff: string, + opts?: CommitMessageOptions, + ): Promise { + const provider = await this.getProvider(); + if (!provider) { + return null; + } + const style = opts?.style ?? this.commitStyle(); + const system = buildCommitStyleSystem(opts?.recentSubjects ?? [], style); + const prompt = buildCommitPrompt(truncateDiff(diff)); + return provider.complete({ + system, + systemCacheable: true, + prompt, + model: "fast", + maxTokens: 512, + signal: opts?.signal, + }); + } + + /** Explain a diff (Markdown). Streams when the provider and `onDelta` allow. */ + async explainDiff( + diff: string, + onDelta?: (text: string) => void, + signal?: AbortSignal, + ): Promise { + const provider = await this.getProvider(); + if (!provider) { + return null; + } + const req: CompleteRequest = { + prompt: buildExplainPrompt(truncateDiff(diff, 8000)), + model: "mid", + maxTokens: 1024, + signal, + }; + if (onDelta && provider.stream) { + return provider.stream(req, onDelta); + } + return provider.complete(req); + } + + /** Summarize a set of changes (Markdown). */ + async summarizeChanges( + diff: string, + signal?: AbortSignal, + ): Promise { + const provider = await this.getProvider(); + if (!provider) { + return null; + } + return provider.complete({ + prompt: buildSummarizePrompt(truncateDiff(diff, 8000)), + model: "mid", + maxTokens: 1024, + signal, + }); + } + + /** + * Review a diff and return Markdown findings — an AI code review, Cursor-style. + * Uses the user's custom prompt (`gitstudio.ai.reviewPrompt`) when set, else a + * strong built-in one. Runs on the `deep` model tier and streams when possible. + */ + async reviewChanges( + diff: string, + onDelta?: (text: string) => void, + signal?: AbortSignal, + ): Promise { + const provider = await this.getProvider(); + if (!provider) { + return null; + } + const req: CompleteRequest = { + system: this.reviewPrompt(), + systemCacheable: true, + prompt: `Review the following git diff.\n\n${truncateDiff(diff, 12000)}`, + model: "deep", + maxTokens: 2048, + signal, + }; + if (onDelta && provider.stream) { + return provider.stream(req, onDelta); + } + return provider.complete(req); + } + + /** The code-review system prompt: the user's custom one, or the built-in. */ + private reviewPrompt(): string { + const custom = this.config().get("reviewPrompt", "").trim(); + return custom.length > 0 ? custom : DEFAULT_REVIEW_PROMPT; + } + + /** Draft a PR description (used by M11). Null when AI is unavailable. */ + async generatePrDescription( + commits: readonly string[], + diff: string, + signal?: AbortSignal, + ): Promise { + const provider = await this.getProvider(); + if (!provider) { + return null; + } + return provider.complete({ + prompt: buildPrDescriptionPrompt(commits, truncateDiff(diff, 8000)), + model: "mid", + maxTokens: 1024, + signal, + }); + } + + // ── Connection panel API (backs the AI settings webview) ─────────────────── + + /** A snapshot of the current AI connection, for the settings panel. */ + async connectionStatus(): Promise { + const [hasAnthropicKey, hasOpenaiKey, copilotAvailable, active] = + await Promise.all([ + this.context.secrets.get(ANTHROPIC_KEY_SECRET).then((v) => !!v), + this.context.secrets.get(OPENAI_KEY_SECRET).then((v) => !!v), + this.vscodeLm.isAvailable(), + this.getProvider(), + ]); + const oa = this.openAiConfig(); + return { + provider: this.providerChoice(), + ready: active !== undefined, + activeId: active?.id, + hasAnthropicKey, + hasOpenaiKey, + copilotAvailable, + openaiBaseUrl: oa.baseUrl, + openaiModel: oa.models.mid || oa.models.fast || "", + cliAgent: this.cliAgent(), + commitStyle: this.commitStyle(), + }; + } + + /** Select a local-agent CLI + switch the provider to it. */ + async setCliAgent(agent: string): Promise { + await this.config().update( + "cliAgent", + agent, + vscode.ConfigurationTarget.Global, + ); + await this.setProviderChoice("cli"); + } + + async setProviderChoice(choice: ProviderChoice): Promise { + await this.config().update( + "provider", + choice, + vscode.ConfigurationTarget.Global, + ); + await this.refreshEnabled(); + } + + async setCommitStyle(style: CommitStyle): Promise { + await this.config().update( + "commitStyle", + style, + vscode.ConfigurationTarget.Global, + ); + } + + async setAnthropicKey(key: string): Promise { + await this.context.secrets.store(ANTHROPIC_KEY_SECRET, key); + await this.refreshEnabled(); + } + + async setOpenAiKey(key: string): Promise { + await this.context.secrets.store(OPENAI_KEY_SECRET, key); + await this.refreshEnabled(); + } + + /** Point the OpenAI-compatible provider at an endpoint + model (covers OpenAI, + * OpenRouter, Groq, and local servers like Ollama / LM Studio). */ + async setOpenAiEndpoint(baseUrl: string, model: string): Promise { + const cfg = this.config(); + await cfg.update("openai.baseUrl", baseUrl, vscode.ConfigurationTarget.Global); + await cfg.update("openai.modelFast", model, vscode.ConfigurationTarget.Global); + await cfg.update("openai.modelMid", model, vscode.ConfigurationTarget.Global); + await cfg.update("openai.modelDeep", model, vscode.ConfigurationTarget.Global); + await this.refreshEnabled(); + } + + async setAnthropicModel(model: string): Promise { + const cfg = this.config(); + await cfg.update("anthropicModelFast", model, vscode.ConfigurationTarget.Global); + await cfg.update("anthropicModelMid", model, vscode.ConfigurationTarget.Global); + await cfg.update("anthropicModelDeep", model, vscode.ConfigurationTarget.Global); + } + + /** Disconnect: forget keys and turn AI off. */ + async disconnectAll(): Promise { + await this.context.secrets.delete(ANTHROPIC_KEY_SECRET); + await this.context.secrets.delete(OPENAI_KEY_SECRET); + await this.setProviderChoice("off"); + } + + /** Live-probe the active provider with a tiny prompt. */ + /** Read-and-clear the last provider error (via a method so TS keeps the type). */ + private takeLastError(): string | undefined { + const e = this.lastProviderError; + this.lastProviderError = undefined; + return e; + } + + async testConnection(): Promise<{ ok: boolean; message: string }> { + const provider = await this.getProvider(); + if (!provider) { + return { ok: false, message: "No model is connected yet — pick one below." }; + } + this.lastProviderError = undefined; + this.suppressErrorToast = true; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15000); + try { + const r = await provider.complete({ + prompt: "Reply with the single word: ok", + model: "fast", + maxTokens: 8, + signal: controller.signal, + }); + if (r && r.trim().length > 0) { + return { ok: true, message: "Success — the model replied. You're all set." }; + } + if (controller.signal.aborted) { + return { ok: false, message: "Timed out after 15s — is the server running and the model loaded?" }; + } + // The provider swallows the real reason into onError; surface it here. + const detail = this.takeLastError(); + return { + ok: false, + message: detail + ? detail.replace(/^GitBrain:\s*/, "") + : "No response from the model — check the server, base URL and model ID.", + }; + } catch (e) { + return { + ok: false, + message: e instanceof Error ? e.message : "Test failed.", + }; + } finally { + clearTimeout(timer); + this.suppressErrorToast = false; + } + } + + /** + * Best-effort: list the models a provider offers, for the connect panel's + * auto-detect. OpenAI-compatible endpoints (incl. Ollama / LM Studio) answer + * `GET {baseUrl}/models`; Anthropic answers `GET /v1/models`. Returns [] on any + * failure so the UI can fall back to a free-text field. + */ + async detectModels( + kind: string, + baseUrl: string, + key: string, + ): Promise { + try { + if (kind === "anthropic") { + const k = + key || (await this.context.secrets.get(ANTHROPIC_KEY_SECRET)) || ""; + if (!k) { + return []; + } + const r = await fetch("https://api.anthropic.com/v1/models?limit=100", { + headers: { "x-api-key": k, "anthropic-version": "2023-06-01" }, + }); + if (!r.ok) { + return []; + } + const j = (await r.json()) as { data?: { id?: string }[] }; + return (j.data ?? []).map((m) => m.id ?? "").filter(Boolean); + } + // OpenAI-compatible (openai/openrouter/groq/ollama/lmstudio/custom). + const base = (baseUrl || this.openAiConfig().baseUrl).replace(/\/+$/, ""); + const k = key || (await this.context.secrets.get(OPENAI_KEY_SECRET)) || ""; + const headers: Record = {}; + if (k) { + headers["Authorization"] = `Bearer ${k}`; + } + const r = await fetch(`${base}/models`, { headers }); + if (!r.ok) { + return []; + } + const j = (await r.json()) as { data?: { id?: string }[] }; + return (j.data ?? []) + .map((m) => m.id ?? "") + .filter(Boolean) + .sort(); + } catch { + return []; + } + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables.length = 0; + } +} diff --git a/apps/extension/src/ai/openAiProvider.ts b/apps/extension/src/ai/openAiProvider.ts new file mode 100644 index 0000000..5a57bf3 --- /dev/null +++ b/apps/extension/src/ai/openAiProvider.ts @@ -0,0 +1,259 @@ +import { + OpenAiSseParser, + extractOpenAiText, + type OpenAiChatResponse, +} from "@gitstudio/engine/ai/gitBrainCore"; +import type { + GitBrainProvider, + CompleteRequest, + ModelTier, +} from "./gitBrain"; + +// The OpenAI-compatible bring-your-own-endpoint provider. A SINGLE provider that +// covers OpenAI, Codex, OpenRouter, Ollama, LM Studio, and any other server that +// speaks the `/chat/completions` API — because they all share one request and +// response shape. Runs entirely on the extension host (Node 22 → global fetch), +// so a key (when present) never reaches a webview and there's no CORS to fight. +// +// The key getter and the config getter are INJECTED so this class stays portable +// (the desktop app can hand it a different store/settings source) and free of +// any vscode import. We hand-roll the HTTP rather than pull in the openai SDK: +// it keeps the bundle light and the request shape is small and stable. +// +// Local servers (Ollama, LM Studio) need NO key — so the Authorization header is +// only added when a key actually exists, and `isAvailable()` is true whenever a +// model is configured (the base URL always has a default). + +export interface OpenAiConfig { + /** Base URL, e.g. https://api.openai.com/v1 or http://localhost:11434/v1. */ + baseUrl: string; + /** Per-tier model IDs (empty string ⇒ that tier is unconfigured). */ + models: Record; +} + +export interface OpenAiProviderOptions { + /** + * Injected secret getter — returns the stored key, or undefined when unset. + * Accepts a Thenable so vscode's SecretStorage.get can be passed directly. + * A key is OPTIONAL: local servers (Ollama / LM Studio) need none. + */ + getKey: () => PromiseLike; + /** Reads the current base URL + per-tier models from settings. */ + config: () => OpenAiConfig; + /** Friendly-message sink for surfaced errors (toast / log). Never throws. */ + onError?: (message: string) => void; + /** Injected fetch (defaults to global). Lets tests stub the network. */ + fetchImpl?: typeof fetch; +} + +interface OpenAiRequestBody { + model: string; + max_tokens: number; + messages: Array<{ role: "system" | "user" | "assistant"; content: string }>; + stream?: boolean; +} + +export class OpenAiProvider implements GitBrainProvider { + readonly id = "openai"; + + constructor(private readonly opts: OpenAiProviderOptions) {} + + /** Available iff a model is configured (the base URL always has a default). */ + isAvailable(): boolean { + const cfg = this.opts.config(); + if (!cfg.baseUrl || cfg.baseUrl.trim().length === 0) { + return false; + } + return ( + this.hasModel(cfg, "fast") || + this.hasModel(cfg, "mid") || + this.hasModel(cfg, "deep") + ); + } + + private hasModel(cfg: OpenAiConfig, tier: ModelTier): boolean { + const id = cfg.models[tier]; + return typeof id === "string" && id.trim().length > 0; + } + + /** Resolve a tier to a concrete model id, falling back across tiers. */ + private modelFor(cfg: OpenAiConfig, tier: ModelTier): string | undefined { + const order: ModelTier[] = + tier === "fast" + ? ["fast", "mid", "deep"] + : tier === "mid" + ? ["mid", "deep", "fast"] + : ["deep", "mid", "fast"]; + for (const t of order) { + const id = cfg.models[t]; + if (typeof id === "string" && id.trim().length > 0) { + return id.trim(); + } + } + return undefined; + } + + private maxTokensFor(req: CompleteRequest): number { + if (req.maxTokens !== undefined) { + return req.maxTokens; + } + return req.model === "fast" ? 512 : 1024; + } + + private buildBody( + cfg: OpenAiConfig, + model: string, + req: CompleteRequest, + stream: boolean, + ): OpenAiRequestBody { + const messages: OpenAiRequestBody["messages"] = []; + if (req.system && req.system.trim().length > 0) { + messages.push({ role: "system", content: req.system }); + } + messages.push({ role: "user", content: req.prompt }); + const body: OpenAiRequestBody = { + model, + max_tokens: this.maxTokensFor(req), + messages, + }; + if (stream) { + body.stream = true; + } + return body; + } + + /** Headers: JSON always; Authorization only when a key exists (local: none). */ + private async headers(): Promise> { + const headers: Record = { + "Content-Type": "application/json", + }; + const key = await this.opts.getKey(); + if (typeof key === "string" && key.trim().length > 0) { + headers["Authorization"] = `Bearer ${key.trim()}`; + } + return headers; + } + + private endpoint(cfg: OpenAiConfig): string { + // Tolerate a trailing slash on the configured base URL. + return cfg.baseUrl.replace(/\/+$/, "") + "/chat/completions"; + } + + /** Map an HTTP status to a friendly message. */ + private friendlyFor(status: number): string { + switch (status) { + case 401: + return "GitBrain: the OpenAI-compatible endpoint rejected the API key (401). Run “GitStudio: Set OpenAI API Key”, or clear it for a local server."; + case 404: + return "GitBrain: the OpenAI-compatible endpoint returned 404 — check the base URL and model ID in settings."; + case 429: + return "GitBrain: OpenAI rate limit hit — try again in a moment."; + default: + return `GitBrain: OpenAI-compatible request failed (HTTP ${status}).`; + } + } + + private report(message: string): void { + this.opts.onError?.(message); + } + + /** A friendly note for a connection-level failure (local server down, etc.). */ + private reportNetwork(cfg: OpenAiConfig): void { + this.report( + `GitBrain: couldn't reach ${cfg.baseUrl} — is the model server running?`, + ); + } + + async complete(req: CompleteRequest): Promise { + const cfg = this.opts.config(); + const model = this.modelFor(cfg, req.model ?? "fast"); + if (!model) { + return null; + } + const fetchImpl = this.opts.fetchImpl ?? fetch; + try { + const res = await fetchImpl(this.endpoint(cfg), { + method: "POST", + headers: await this.headers(), + body: JSON.stringify(this.buildBody(cfg, model, req, false)), + signal: req.signal, + }); + if (!res.ok) { + this.report(this.friendlyFor(res.status)); + return null; + } + const json = (await res.json()) as OpenAiChatResponse; + return extractOpenAiText(json); + } catch (err) { + if (isAbort(err)) { + return null; + } + this.reportNetwork(cfg); + return null; + } + } + + async stream( + req: CompleteRequest, + onDelta: (text: string) => void, + ): Promise { + const cfg = this.opts.config(); + const model = this.modelFor(cfg, req.model ?? "fast"); + if (!model) { + return null; + } + const fetchImpl = this.opts.fetchImpl ?? fetch; + try { + const res = await fetchImpl(this.endpoint(cfg), { + method: "POST", + headers: await this.headers(), + body: JSON.stringify(this.buildBody(cfg, model, req, true)), + signal: req.signal, + }); + if (!res.ok) { + this.report(this.friendlyFor(res.status)); + return null; + } + const body = res.body; + if (!body) { + // No stream body — fall back to non-streaming. + return this.complete(req); + } + + const parser = new OpenAiSseParser(); + const decoder = new TextDecoder("utf8"); + let assembled = ""; + const reader = body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + const chunk = decoder.decode(value, { stream: true }); + for (const delta of parser.push(chunk)) { + assembled += delta; + onDelta(delta); + } + if (parser.done) { + break; + } + } + } finally { + reader.releaseLock(); + } + const trimmed = assembled.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch (err) { + if (isAbort(err)) { + return null; + } + this.reportNetwork(cfg); + return null; + } + } +} + +function isAbort(err: unknown): boolean { + return err instanceof Error && err.name === "AbortError"; +} diff --git a/apps/extension/src/ai/vscodeLmProvider.ts b/apps/extension/src/ai/vscodeLmProvider.ts new file mode 100644 index 0000000..0601e6d --- /dev/null +++ b/apps/extension/src/ai/vscodeLmProvider.ts @@ -0,0 +1,209 @@ +import * as vscode from "vscode"; +import type { + GitBrainProvider, + CompleteRequest, +} from "./gitBrain"; + +// The zero-key path: route GitBrain through vscode.lm (the VS Code Language Model +// API) when the host exposes it. This covers GitHub Copilot's models AND any +// other LM provider the editor surfaces — Cursor exposes its own models through +// the same API — so we select with NO vendor filter and let every available +// chat model show up. This API does NOT exist on the extension's `^1.74.0` +// baseline (@types/vscode@1.74.0 has no `lm` namespace), so everything here is +// FEATURE-DETECTED at runtime and accessed through a locally-declared structural +// shim — never `vscode.LanguageModelChatMessage`, which wouldn't compile against +// the pinned types. +// +// If the user hasn't granted LM access, sendRequest may throw (or the consent +// prompt appears on first use); we catch and return null gracefully so AI +// features simply stay hidden rather than erroring into a git flow. + +/** The slice of the vscode.lm surface we use, declared locally to compile on 1.74. */ +interface LmShim { + selectChatModels?: (selector?: { + vendor?: string; + }) => Thenable; +} + +interface LmChatModel { + /** Stable identifier for the model (used to remember the user's pick). */ + readonly id?: string; + readonly vendor?: string; + readonly family?: string; + readonly name?: string; + sendRequest: ( + messages: unknown[], + options: Record, + token?: vscode.CancellationToken, + ) => Thenable<{ text: AsyncIterable }>; +} + +interface LmMessageCtor { + User: (content: string) => unknown; +} + +/** Read `vscode.lm` if present (it isn't on the baseline). */ +function getLm(): LmShim | undefined { + const lm = (vscode as unknown as { lm?: LmShim }).lm; + return lm && typeof lm.selectChatModels === "function" ? lm : undefined; +} + +/** Read the `LanguageModelChatMessage` constructor if present. */ +function getMessageCtor(): LmMessageCtor | undefined { + const ctor = (vscode as unknown as { LanguageModelChatMessage?: LmMessageCtor }) + .LanguageModelChatMessage; + return ctor && typeof ctor.User === "function" ? ctor : undefined; +} + +/** Public shape of an available chat model, for the model picker. */ +export interface LmModelInfo { + id: string; + vendor: string; + family: string; + name: string; +} + +export interface VsCodeLmProviderOptions { + /** + * Returns the model id the user picked via the model picker (or undefined to + * use the first available). Injected so the provider stays decoupled from the + * globalState store. + */ + getPreferredModelId?: () => string | undefined; +} + +export class VsCodeLmProvider implements GitBrainProvider { + readonly id = "vscode-lm"; + + constructor(private readonly opts: VsCodeLmProviderOptions = {}) {} + + /** Available iff the host exposes vscode.lm AND a chat model is selectable. */ + async isAvailable(): Promise { + const lm = getLm(); + if (!lm || !getMessageCtor()) { + return false; + } + try { + // No vendor filter: Copilot's and Cursor's models both qualify. + const models = await lm.selectChatModels!(); + return Array.isArray(models) && models.length > 0; + } catch { + return false; + } + } + + /** List every available chat model (no vendor filter), for the picker. */ + async listModels(): Promise { + const lm = getLm(); + if (!lm || !getMessageCtor()) { + return []; + } + try { + const models = await lm.selectChatModels!(); + if (!Array.isArray(models)) { + return []; + } + return models + .filter((m) => typeof m.id === "string" && m.id.length > 0) + .map((m) => ({ + id: m.id as string, + vendor: m.vendor ?? "", + family: m.family ?? "", + name: m.name ?? m.family ?? m.id ?? "", + })); + } catch { + return []; + } + } + + /** + * Pick the model to use: the remembered id when it's still available, else the + * first available model (so a stale/removed pick degrades gracefully). + */ + private async selectModel(): Promise { + const lm = getLm(); + if (!lm) { + return undefined; + } + try { + const models = await lm.selectChatModels!(); + if (!Array.isArray(models) || models.length === 0) { + return undefined; + } + const preferred = this.opts.getPreferredModelId?.(); + if (preferred) { + const match = models.find((m) => m.id === preferred); + if (match) { + return match; + } + } + return models[0]; + } catch { + return undefined; + } + } + + async complete(req: CompleteRequest): Promise { + let assembled = ""; + const out = await this.stream(req, (t) => { + assembled += t; + }); + if (out !== null) { + return out; + } + const trimmed = assembled.trim(); + return trimmed.length > 0 ? trimmed : null; + } + + async stream( + req: CompleteRequest, + onDelta: (text: string) => void, + ): Promise { + const model = await this.selectModel(); + const ctor = getMessageCtor(); + if (!model || !ctor) { + return null; + } + + // vscode.lm has no separate system field; fold the cacheable prefix into a + // leading user message so the model still gets the style context. + const messages: unknown[] = []; + if (req.system && req.system.trim().length > 0) { + messages.push(ctor.User(req.system)); + } + messages.push(ctor.User(req.prompt)); + + const token = abortToCancellation(req.signal); + try { + const response = await model.sendRequest(messages, {}, token?.token); + let assembled = ""; + for await (const fragment of response.text) { + assembled += fragment; + onDelta(fragment); + } + const trimmed = assembled.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + // Consent denied, model error, or cancellation — stay silent. + return null; + } finally { + token?.dispose(); + } + } +} + +/** Bridge an AbortSignal to a vscode CancellationTokenSource (best-effort). */ +function abortToCancellation( + signal?: AbortSignal, +): vscode.CancellationTokenSource | undefined { + if (!signal) { + return undefined; + } + const source = new vscode.CancellationTokenSource(); + if (signal.aborted) { + source.cancel(); + } else { + signal.addEventListener("abort", () => source.cancel(), { once: true }); + } + return source; +} diff --git a/apps/extension/src/blame/blameController.ts b/apps/extension/src/blame/blameController.ts new file mode 100644 index 0000000..46eb1df --- /dev/null +++ b/apps/extension/src/blame/blameController.ts @@ -0,0 +1,655 @@ +import * as vscode from "vscode"; +import { relative } from "node:path"; +import type { BlameResult, BlameCommit } from "@gitstudio/git-service/index"; +import { UNCOMMITTED_SHA } from "@gitstudio/git-service/index"; +import type { RepoManager, RepoEntry } from "../git/repoManager"; +import { relativeTime } from "../util/relativeTime"; + +// How long after the selection settles before we run a blame — fast enough to +// feel live, slow enough not to spawn git on every cursor twitch. +const SELECTION_DEBOUNCE_MS = 200; +// Files larger than this skip inline/hover blame; full-file annotations are +// viewport-limited instead of refusing outright. +const MAX_BLAME_LINES = 20_000; +// When annotating a huge file, only decorate a window around the viewport. +const ANNOTATION_VIEWPORT_PAD = 200; +const ANNOTATION_MAX_LINES = 5_000; + +const NATIVE_BLAME_DISABLED_KEY = "gitstudio.blame.disabledNativeBlame"; + +/** + * The GitStudio blame surface: inline current-line annotation, a status bar + * item, a rich hover, and a full-file annotation toggle with a code-age + * heatmap. Backed by the git-service BlameProvider via the RepoManager's + * active GitContext. All decorations are file-scheme + in-repo only. + */ +export class BlameController implements vscode.Disposable { + private readonly disposables: vscode.Disposable[] = []; + + // One reusable decoration type for the inline current-line annotation. + private readonly inlineDecoration = + vscode.window.createTextEditorDecorationType({ + isWholeLine: false, + rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed, + }); + + // The status bar item summarising the current line's commit. + private readonly statusBar: vscode.StatusBarItem; + + // Per-document blame cache, keyed by document version so a single edit + // invalidates it. Holds the in-flight promise to coalesce concurrent reads. + private readonly blameCache = new Map< + string, + { version: number; result: Promise } + >(); + + // Full-file annotation state, per editor (by document uri string). + private readonly annotated = new Map(); + + private selectionTimer: ReturnType | undefined; + private inlineCts: vscode.CancellationTokenSource | undefined; + // Tracks the line we last rendered so a no-op selection move is cheap. + private lastRendered: { uri: string; line: number } | undefined; + + constructor( + private readonly repos: RepoManager, + private readonly context: vscode.ExtensionContext, + private readonly log?: (m: string) => void, + ) { + this.statusBar = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + -10, + ); + this.statusBar.command = "gitstudio.blame.showLineActions"; + + void this.maybeDisableNativeBlame(); + + this.disposables.push( + this.inlineDecoration, + this.statusBar, + vscode.languages.registerHoverProvider( + { scheme: "file" }, + new BlameHoverProvider(this), + ), + vscode.window.onDidChangeTextEditorSelection((e) => + this.scheduleInline(e.textEditor), + ), + vscode.window.onDidChangeActiveTextEditor((editor) => { + if (editor) { + this.scheduleInline(editor); + } else { + this.clearInline(); + } + }), + vscode.workspace.onDidChangeTextDocument((e) => { + // An edit invalidates the cached blame and any current annotation. + this.blameCache.delete(e.document.uri.toString()); + const editor = vscode.window.activeTextEditor; + if (editor && editor.document === e.document) { + this.clearInline(); + } + }), + vscode.workspace.onDidCloseTextDocument((doc) => { + this.blameCache.delete(doc.uri.toString()); + }), + vscode.window.onDidChangeTextEditorVisibleRanges((e) => { + // Re-render viewport-limited annotations as the user scrolls. + if (this.annotated.has(e.textEditor.document.uri.toString())) { + void this.renderAnnotations(e.textEditor); + } + }), + vscode.commands.registerCommand("gitstudio.toggleFileBlame", () => + this.toggleFileBlame(), + ), + vscode.commands.registerCommand("gitstudio.blame.showLineActions", () => + this.showLineActions(), + ), + // The repo set / active repo changed: stale blame may now be wrong. + this.repos.onDidChange(() => { + this.blameCache.clear(); + const editor = vscode.window.activeTextEditor; + if (editor) { + this.scheduleInline(editor); + } + }), + ); + + // Render for whatever is already open at activation. + if (vscode.window.activeTextEditor) { + this.scheduleInline(vscode.window.activeTextEditor); + } + } + + // --- Inline current-line blame ------------------------------------------ + + private scheduleInline(editor: vscode.TextEditor): void { + if (this.selectionTimer !== undefined) { + clearTimeout(this.selectionTimer); + } + this.selectionTimer = setTimeout(() => { + this.selectionTimer = undefined; + void this.renderInline(editor); + }, SELECTION_DEBOUNCE_MS); + } + + private clearInline(): void { + this.inlineCts?.cancel(); + this.lastRendered = undefined; + for (const editor of vscode.window.visibleTextEditors) { + editor.setDecorations(this.inlineDecoration, []); + } + this.statusBar.hide(); + } + + private async renderInline(editor: vscode.TextEditor): Promise { + const config = vscode.workspace.getConfiguration("gitstudio.blame"); + const inlineEnabled = config.get("inlineEnabled", true); + const statusBarEnabled = config.get("statusBarEnabled", true); + + if (editor !== vscode.window.activeTextEditor) { + return; + } + + const ctx = this.resolveFor(editor.document); + if (!ctx) { + this.clearForEditor(editor); + return; + } + + const line = editor.selection.active.line; // 0-based + if ( + this.lastRendered && + this.lastRendered.uri === editor.document.uri.toString() && + this.lastRendered.line === line + ) { + return; // same line — nothing to redo + } + + this.inlineCts?.cancel(); + const cts = new vscode.CancellationTokenSource(); + this.inlineCts = cts; + + const blame = await this.getBlame(editor.document, ctx, cts.token); + if (cts.token.isCancellationRequested || editor !== vscode.window.activeTextEditor) { + return; + } + if (!blame) { + this.clearForEditor(editor); + return; + } + + const commit = commitForLine(blame, line); + if (!commit) { + editor.setDecorations(this.inlineDecoration, []); + this.statusBar.hide(); + return; + } + + this.lastRendered = { uri: editor.document.uri.toString(), line }; + + if (inlineEnabled) { + const label = inlineLabel(commit); + const range = editor.document.lineAt(line).range; + const decoration: vscode.DecorationOptions = { + range: new vscode.Range(range.end, range.end), + renderOptions: { + after: { + contentText: label, + color: new vscode.ThemeColor("editorCodeLens.foreground"), + fontStyle: "italic", + margin: "0 0 0 3em", + }, + }, + }; + editor.setDecorations(this.inlineDecoration, [decoration]); + } else { + editor.setDecorations(this.inlineDecoration, []); + } + + if (statusBarEnabled) { + this.statusBar.text = statusBarText(commit); + this.statusBar.tooltip = statusBarTooltip(commit); + this.statusBar.show(); + } else { + this.statusBar.hide(); + } + } + + private clearForEditor(editor: vscode.TextEditor): void { + editor.setDecorations(this.inlineDecoration, []); + this.statusBar.hide(); + this.lastRendered = undefined; + } + + // --- Status-bar line actions -------------------------------------------- + + private async showLineActions(): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + const ctx = this.resolveFor(editor.document); + if (!ctx) { + return; + } + const blame = await this.getBlame(editor.document, ctx); + if (!blame) { + return; + } + const commit = commitForLine(blame, editor.selection.active.line); + if (!commit) { + return; + } + + const isUncommitted = commit.sha === UNCOMMITTED_SHA; + const items: Array = [ + { id: "sha", label: "$(copy) Copy SHA", description: short(commit.sha) }, + { id: "author", label: "$(account) Copy Author", description: commit.author }, + { id: "history", label: "$(history) Show File History" }, + { id: "toggle", label: "$(list-flat) Toggle File Blame" }, + ]; + const picked = await vscode.window.showQuickPick( + isUncommitted ? items.filter((i) => i.id === "toggle") : items, + { placeHolder: isUncommitted ? "Uncommitted changes" : commit.summary }, + ); + if (!picked) { + return; + } + switch (picked.id) { + case "sha": + await vscode.env.clipboard.writeText(commit.sha); + void vscode.window.showInformationMessage(`Copied ${short(commit.sha)}`); + break; + case "author": + await vscode.env.clipboard.writeText(commit.author); + void vscode.window.showInformationMessage(`Copied ${commit.author}`); + break; + case "history": + // Stub: file history lands in a later milestone. + void vscode.window.showInformationMessage( + "File history is coming in a later GitStudio milestone.", + ); + break; + case "toggle": + await this.toggleFileBlame(); + break; + } + } + + // --- Full-file annotations toggle ---------------------------------------- + + private async toggleFileBlame(): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + const key = editor.document.uri.toString(); + const existing = this.annotated.get(key); + if (existing) { + editor.setDecorations(existing, []); + existing.dispose(); + this.annotated.delete(key); + return; + } + const ctx = this.resolveFor(editor.document); + if (!ctx) { + void vscode.window.showInformationMessage( + "GitStudio: this file isn't in an open Git repository.", + ); + return; + } + const type = vscode.window.createTextEditorDecorationType({ + before: { margin: "0 1em 0 0" }, + }); + this.annotated.set(key, type); + await this.renderAnnotations(editor); + } + + private async renderAnnotations(editor: vscode.TextEditor): Promise { + const key = editor.document.uri.toString(); + const type = this.annotated.get(key); + if (!type) { + return; + } + const ctx = this.resolveFor(editor.document); + if (!ctx) { + return; + } + const blame = await this.getBlame(editor.document, ctx); + if (!blame || !this.annotated.has(key)) { + return; + } + + const heatmap = vscode.workspace + .getConfiguration("gitstudio.blame") + .get("heatmap", true); + + const total = editor.document.lineCount; + const { start, end } = annotationWindow(editor, total); + + // Newest/oldest author times across the file drive the heatmap ramp. + const times = [...blame.commits.values()] + .map((c) => c.authorTime) + .filter((t) => t > 0); + const newest = times.length ? Math.max(...times) : 0; + const oldest = times.length ? Math.min(...times) : 0; + + const decorations: vscode.DecorationOptions[] = []; + for (let line = start; line < end; line++) { + const commit = commitForLine(blame, line); + if (!commit) { + continue; + } + const ramp = + heatmap && commit.sha !== UNCOMMITTED_SHA + ? heatColor(commit.authorTime, oldest, newest) + : undefined; + const range = editor.document.lineAt(line).range; + decorations.push({ + range: new vscode.Range(range.start, range.start), + renderOptions: { + before: { + contentText: annotationGutter(commit), + color: new vscode.ThemeColor("editorCodeLens.foreground"), + backgroundColor: ramp, + // Fixed-width monospace column with a thin right rule — the + // JetBrains "Annotate" gutter. The border is injected via the + // textDecoration escape hatch (decoration CSS can't set it directly). + width: "21ch", + margin: "0 0.8em 0 0", + textDecoration: + "none; border-right: 1px solid var(--vscode-panel-border); padding-right: 0.6em; white-space: pre", + }, + }, + }); + } + editor.setDecorations(type, decorations); + } + + // --- Blame access (used by the hover provider too) ----------------------- + + resolveFor(document: vscode.TextDocument): RepoEntry | undefined { + if (document.uri.scheme !== "file") { + return undefined; + } + const active = this.repos.getActive(); + if (active && isInside(document.uri.fsPath, active.root)) { + return active; + } + for (const entry of this.repos.getAll()) { + if (isInside(document.uri.fsPath, entry.root)) { + return entry; + } + } + return undefined; + } + + async getBlame( + document: vscode.TextDocument, + ctx: RepoEntry, + token?: vscode.CancellationToken, + ): Promise { + if (document.lineCount > MAX_BLAME_LINES) { + return undefined; + } + const key = document.uri.toString(); + const cached = this.blameCache.get(key); + if (cached && cached.version === document.version) { + return cached.result; + } + + const relPath = relative(ctx.root, document.uri.fsPath); + const controller = new AbortController(); + if (token) { + token.onCancellationRequested(() => controller.abort()); + } + // Feed the live (possibly dirty) buffer so blame matches what's on screen. + const contents = document.isDirty ? document.getText() : undefined; + + const promise = ctx.ctx.blame + .blameFile(relPath, { contents, signal: controller.signal }) + .catch((e: unknown) => { + if (!controller.signal.aborted) { + const msg = e instanceof Error ? e.message : String(e); + this.log?.(`blame failed for ${relPath}: ${msg}`); + console.error("[GitStudio] blame failed", e); + } + return undefined; + }); + this.blameCache.set(key, { version: document.version, result: promise }); + return promise; + } + + // --- Native-blame de-duplication (one-time) ------------------------------ + + private async maybeDisableNativeBlame(): Promise { + if (this.context.globalState.get(NATIVE_BLAME_DISABLED_KEY)) { + return; + } + const git = vscode.workspace.getConfiguration("git"); + const editorDecoration = git.inspect("blame.editorDecoration.enabled"); + const statusBarItem = git.inspect("blame.statusBarItem.enabled"); + + // Only act when the *effective* value is on AND the user hasn't explicitly + // set it (globally or per-workspace) themselves. + const userSet = (v?: { globalValue?: boolean; workspaceValue?: boolean }) => + v?.globalValue !== undefined || v?.workspaceValue !== undefined; + + const decOn = + editorDecoration?.defaultValue === true && !userSet(editorDecoration); + const sbOn = statusBarItem?.defaultValue === true && !userSet(statusBarItem); + + // Mark handled regardless, so we never nag again on this machine. + await this.context.globalState.update(NATIVE_BLAME_DISABLED_KEY, true); + + if (!decOn && !sbOn) { + return; + } + try { + if (decOn) { + await git.update( + "blame.editorDecoration.enabled", + false, + vscode.ConfigurationTarget.Global, + ); + } + if (sbOn) { + await git.update( + "blame.statusBarItem.enabled", + false, + vscode.ConfigurationTarget.Global, + ); + } + void vscode.window.showInformationMessage( + "GitStudio inline blame is on; disabled the built-in blame to avoid " + + "duplicate annotations. You can re-enable it in settings.", + ); + } catch { + // Best-effort: a settings write failure shouldn't break activation. + } + } + + dispose(): void { + if (this.selectionTimer !== undefined) { + clearTimeout(this.selectionTimer); + } + this.inlineCts?.cancel(); + for (const type of this.annotated.values()) { + type.dispose(); + } + this.annotated.clear(); + this.blameCache.clear(); + for (const d of this.disposables) { + d.dispose(); + } + this.disposables.length = 0; + } +} + +/** The rich hover for a blamed line. */ +class BlameHoverProvider implements vscode.HoverProvider { + constructor(private readonly controller: BlameController) {} + + async provideHover( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken, + ): Promise { + const ctx = this.controller.resolveFor(document); + if (!ctx) { + return undefined; + } + const blame = await this.controller.getBlame(document, ctx, token); + if (!blame || token.isCancellationRequested) { + return undefined; + } + const commit = commitForLine(blame, position.line); + if (!commit) { + return undefined; + } + return new vscode.Hover(hoverMarkdown(commit), document.lineAt(position.line).range); + } +} + +// --- Presentation helpers (pure, no editor state) -------------------------- + +/** Inline label format: ` , `. */ +function inlineLabel(commit: BlameCommit): string { + if (commit.sha === UNCOMMITTED_SHA) { + return " You, now • Uncommitted changes"; + } + return ` ${commit.author}, ${relativeTime(commit.authorTime)} • ${truncate(commit.summary, 60)}`; +} + +function statusBarText(commit: BlameCommit): string { + if (commit.sha === UNCOMMITTED_SHA) { + return "$(git-commit) Uncommitted changes"; + } + return `$(git-commit) ${commit.author}, ${relativeTime(commit.authorTime)}`; +} + +function statusBarTooltip(commit: BlameCommit): vscode.MarkdownString { + const md = new vscode.MarkdownString(undefined, true); + if (commit.sha === UNCOMMITTED_SHA) { + md.appendMarkdown("Uncommitted changes"); + return md; + } + md.appendMarkdown(`**${escapeMarkdown(commit.summary)}**\n\n`); + md.appendMarkdown(`$(git-commit) \`${short(commit.sha)}\``); + return md; +} + +/** JetBrains-style gutter annotation: ` `, padded to align. */ +function annotationGutter(commit: BlameCommit): string { + if (commit.sha === UNCOMMITTED_SHA) { + return pad("Uncommitted", 21); + } + const date = isoDate(commit.authorTime); // 2024-06-20 + const author = authorShort(commit.author, 9); + return pad(`${date} ${author}`, 21); +} + +function hoverMarkdown(commit: BlameCommit): vscode.MarkdownString { + const md = new vscode.MarkdownString(undefined, true); + md.isTrusted = { enabledCommands: ["gitstudio.copyCommitSha"] }; + + if (commit.sha === UNCOMMITTED_SHA) { + md.appendMarkdown("$(git-commit) **Uncommitted changes**\n\n"); + md.appendMarkdown("This line has local, not-yet-committed edits."); + return md; + } + + const date = new Date(commit.authorTime * 1000); + md.appendMarkdown(`**${escapeMarkdown(commit.summary)}**\n\n`); + md.appendMarkdown( + `$(account) ${escapeMarkdown(commit.author)} <${escapeMarkdown(commit.authorMail)}>\n\n`, + ); + md.appendMarkdown( + `$(calendar) ${escapeMarkdown(date.toLocaleString())} (${relativeTime(commit.authorTime)})\n\n`, + ); + const copyArg = encodeURIComponent(JSON.stringify(commit.sha)); + md.appendMarkdown( + `$(git-commit) \`${short(commit.sha)}\` ` + + ` [$(copy) Copy SHA](command:gitstudio.copyCommitSha?${copyArg})`, + ); + return md; +} + +/** Map an author time onto a warm (recent) → cool (old) translucent ramp. */ +function heatColor(time: number, oldest: number, newest: number): string { + if (newest <= oldest) { + return "rgba(255, 153, 51, 0.10)"; + } + // 0 = oldest, 1 = newest. + const t = Math.max(0, Math.min(1, (time - oldest) / (newest - oldest))); + // Warm orange (recent) → cool blue (old). Keep alpha low to stay subtle. + const r = Math.round(60 + t * (255 - 60)); + const g = Math.round(120 + t * (153 - 120)); + const b = Math.round(220 - t * (220 - 51)); + return `rgba(${r}, ${g}, ${b}, 0.12)`; +} + +function commitForLine( + blame: BlameResult, + zeroBasedLine: number, +): BlameCommit | undefined { + const finalLine = zeroBasedLine + 1; // blame is 1-based + // lines are sorted; a small file makes a linear scan fine, but index for O(1). + const entry = blame.lines[finalLine - 1]; + const sha = + entry && entry.finalLine === finalLine + ? entry.sha + : blame.lines.find((l) => l.finalLine === finalLine)?.sha; + return sha ? blame.commits.get(sha) : undefined; +} + +function annotationWindow( + editor: vscode.TextEditor, + total: number, +): { start: number; end: number } { + if (total <= ANNOTATION_MAX_LINES) { + return { start: 0, end: total }; + } + const ranges = editor.visibleRanges; + const first = ranges.length ? ranges[0].start.line : 0; + const last = ranges.length ? ranges[ranges.length - 1].end.line : total; + return { + start: Math.max(0, first - ANNOTATION_VIEWPORT_PAD), + end: Math.min(total, last + ANNOTATION_VIEWPORT_PAD), + }; +} + +function truncate(text: string, max: number): string { + const oneLine = text.replace(/\s+/g, " ").trim(); + return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine; +} + +function authorShort(author: string, max = 12): string { + // First name keeps the column tidy when the full name is long. + const first = author.split(/\s+/)[0] ?? author; + const base = first.length <= max ? author : first; + return base.length > max ? `${base.slice(0, max - 1)}…` : base; +} + +function pad(text: string, width: number): string { + return text.length >= width ? text : text + " ".repeat(width - text.length); +} + +function short(sha: string): string { + return sha.slice(0, 7); +} + +function isoDate(epochSeconds: number): string { + return new Date(epochSeconds * 1000).toISOString().slice(0, 10); +} + +/** Escapes the markdown control characters that show up in commit text. */ +function escapeMarkdown(text: string): string { + return text.replace(/[\\`*_{}[\]()#+\-.!|>]/g, "\\$&"); +} + +/** True when `filePath` sits at or below `dir` (path-boundary aware). */ +function isInside(filePath: string, dir: string): boolean { + const rel = relative(dir, filePath); + return rel.length > 0 && !rel.startsWith("..") && !rel.startsWith("/"); +} diff --git a/apps/extension/src/changes/changesView.ts b/apps/extension/src/changes/changesView.ts new file mode 100644 index 0000000..5facbfe --- /dev/null +++ b/apps/extension/src/changes/changesView.ts @@ -0,0 +1,152 @@ +import * as vscode from "vscode"; +import type { Change } from "../git/git"; +import { toRevisionUri } from "../history/revisionContentProvider"; + +// Change-row helpers shared by the unified Commit webview (commitView.ts) and +// the line/hunk-staging commands. The standalone "Changes" tree view was folded +// into the Commit webview (which now renders the working-tree changes inline, +// SCM-style), so the TreeDataProvider no longer lives here — only the diff +// opener, the vscode.git Status → icon/letter mapping, and the relative-path +// helper remain, reused verbatim by the webview's host side. + +// vscode.git's `Status` is an ambient enum in git.d.ts (types only — no runtime +// value), so we mirror its numeric values here for the runtime switch. The order +// matches microsoft/vscode's extensions/git/src/api/git.d.ts (API v1). +const enum St { + INDEX_MODIFIED = 0, + INDEX_ADDED = 1, + INDEX_DELETED = 2, + INDEX_RENAMED = 3, + INDEX_COPIED = 4, + MODIFIED = 5, + DELETED = 6, + UNTRACKED = 7, + IGNORED = 8, + INTENT_TO_ADD = 9, + INTENT_TO_RENAME = 10, + TYPE_CHANGED = 11, + ADDED_BY_US = 12, + ADDED_BY_THEM = 13, + DELETED_BY_US = 14, + DELETED_BY_THEM = 15, + BOTH_ADDED = 16, + BOTH_DELETED = 17, + BOTH_MODIFIED = 18, +} + +/** Which group a change belongs to (used by the diff opener + the webview). */ +export type GroupKind = "merge" | "staged" | "unstaged"; + +const FILE_CONTEXT: Record = { + merge: "gitstudio.change.merge", + staged: "gitstudio.change.staged", + unstaged: "gitstudio.change.unstaged", +}; + +/** + * A lightweight changed-file descriptor. Retained (instead of a TreeItem) so the + * Commit webview's host side can reuse `openChangeDiff` to open the same diffs + * without standing up a tree. Mirrors the fields the diff opener needs. + */ +export class ChangeFileNode { + readonly resourceUri: vscode.Uri; + readonly contextValue: string; + + constructor( + readonly kind: GroupKind, + readonly root: string, + readonly change: Change, + ) { + this.resourceUri = change.uri; + this.contextValue = FILE_CONTEXT[kind]; + } +} + +/** + * Opens the appropriate diff for a change row: working-tree vs index for + * unstaged edits, index vs HEAD for staged edits, and working vs HEAD for merge + * entries. Reuses the `gitstudio-rev` content provider (rev "" = index, + * "HEAD" = committed) so no extra scheme is needed. + */ +export async function openChangeDiff(node: ChangeFileNode): Promise { + const { root, change } = node; + const rel = relativePath(root, change.uri.fsPath); + const fileName = baseName(rel); + + if (node.kind === "staged") { + const left = toRevisionUri(root, "HEAD", rel); + const right = toRevisionUri(root, "", rel); // index + await vscode.commands.executeCommand( + "vscode.diff", + left, + right, + `${fileName} (Staged)`, + { preview: true }, + ); + return; + } + + // unstaged / merge: working tree (right) vs index or HEAD (left). + const baseRev = node.kind === "merge" ? "HEAD" : ""; + const left = toRevisionUri(root, baseRev, rel); + const right = change.uri; // live working-tree file + const label = node.kind === "merge" ? "Working Tree vs HEAD" : "Working Tree"; + await vscode.commands.executeCommand( + "vscode.diff", + left, + right, + `${fileName} (${label})`, + { preview: true }, + ); +} + +/** The single-letter status code (M/A/D/U/R/!/I/T) for a vscode.git Status. */ +export function statusLetter(status: number): string { + switch (status) { + case St.INDEX_ADDED: + case St.INTENT_TO_ADD: + return "A"; + case St.UNTRACKED: + return "U"; + case St.INDEX_DELETED: + case St.DELETED: + return "D"; + case St.INDEX_RENAMED: + case St.INDEX_COPIED: + return "R"; + case St.BOTH_MODIFIED: + case St.BOTH_ADDED: + case St.ADDED_BY_US: + case St.ADDED_BY_THEM: + case St.DELETED_BY_US: + case St.DELETED_BY_THEM: + case St.BOTH_DELETED: + return "!"; + case St.IGNORED: + return "I"; + case St.TYPE_CHANGED: + return "T"; + case St.INDEX_MODIFIED: + case St.MODIFIED: + default: + return "M"; + } +} + +/** Repo-root-relative, forward-slashed path. */ +export function relativePath(root: string, fsPath: string): string { + const normRoot = root.replace(/\\/g, "/").replace(/\/+$/, ""); + const normPath = fsPath.replace(/\\/g, "/"); + if (normPath === normRoot) { + return ""; + } + if (normPath.startsWith(normRoot + "/")) { + return normPath.slice(normRoot.length + 1); + } + return normPath; +} + +function baseName(rel: string): string { + const parts = rel.split("/"); + return parts[parts.length - 1] || rel; +} diff --git a/apps/extension/src/changes/commitView.ts b/apps/extension/src/changes/commitView.ts new file mode 100644 index 0000000..386f437 --- /dev/null +++ b/apps/extension/src/changes/commitView.ts @@ -0,0 +1,3303 @@ +import * as vscode from "vscode"; +import type { GitRef } from "@gitstudio/git-service/index"; +import type { RepoManager, RepoEntry } from "../git/repoManager"; +import type { Change } from "../git/git"; +import { getNonce } from "../webview/html"; +// The shared design tokens, inlined as text by esbuild (the extension ctx uses +// the ".css": "text" loader). Injected into the webview + + + +
+ + +
+ +
+
+ + + + +
+ +
+
+ +
+ + + +
+ + + +
+ + +
+
+ +
+ Changed Files + 0 + + + + + + + + +
+ +
+ +
+ + + + Working tree clean + No changes to commit. +
+ +
+ + + + No repository open + Open a folder that's under Git to see your changes, branches, and history. +
+ + +
+
+ + + +`; + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables.length = 0; + } +} + +/** Find the Change whose repo-relative path matches `path`. */ +function findIn( + changes: Change[], + root: string, + path: string, +): Change | undefined { + return changes.find((c) => relativePath(root, c.uri.fsPath) === path); +} diff --git a/apps/extension/src/changes/lineStaging.ts b/apps/extension/src/changes/lineStaging.ts new file mode 100644 index 0000000..ad93e9c --- /dev/null +++ b/apps/extension/src/changes/lineStaging.ts @@ -0,0 +1,279 @@ +import * as vscode from "vscode"; +import { + applySelectedChanges, + computeHunks, + type LineRange, +} from "@gitstudio/engine/staging/applyLineChanges"; +import type { RepoManager, RepoEntry } from "../git/repoManager"; +import { relativePath } from "./changesView"; + +// Line / hunk staging commands — the headline differentiator. These operate on +// the active editor (a working file, or the modified side of a diff editor): the +// user's selection (or the hunk under the cursor) is reconstructed against the +// staged/working baseline and written to the index via the engine's pure +// applySelectedChanges + git-service's content staging. After each op we refresh +// the views and invalidate open index/HEAD diffs. + +/** What a staging command needs from the host after refreshing the index. */ +export interface StagingRefresh { + refresh(): void; +} + +/** + * Resolves the active editor to a (repo, relPath, document) triple, or shows a + * gentle message and returns undefined. Works for a plain file editor and for + * the modified side of a diff editor (both expose a `file:` document). + */ +function resolveTarget( + repos: RepoManager, +): { entry: RepoEntry; rel: string; doc: vscode.TextDocument } | undefined { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.uri.scheme !== "file") { + void vscode.window.showInformationMessage( + "GitStudio: open a file in a Git repository to stage lines.", + ); + return undefined; + } + const entry = repoForFile(repos, editor.document.uri.fsPath); + if (!entry) { + void vscode.window.showInformationMessage( + "GitStudio: this file is not inside an open Git repository.", + ); + return undefined; + } + const rel = relativePath(entry.root, editor.document.uri.fsPath); + return { entry, rel, doc: editor.document }; +} + +/** Finds the open repo whose root contains `fsPath` (longest match wins). */ +function repoForFile(repos: RepoManager, fsPath: string): RepoEntry | undefined { + const norm = fsPath.replace(/\\/g, "/"); + let best: RepoEntry | undefined; + for (const entry of repos.getAll()) { + const root = entry.root.replace(/\\/g, "/").replace(/\/+$/, ""); + if (norm === root || norm.startsWith(root + "/")) { + if (!best || entry.root.length > best.root.length) { + best = entry; + } + } + } + return best; +} + +/** The editor's selections as 0-based inclusive line ranges (document coords). */ +function selectionRanges(editor: vscode.TextEditor): LineRange[] { + return editor.selections.map((sel) => ({ + start: sel.start.line, + end: sel.end.line, + })); +} + +/** + * Stage the selected lines of the active editor. `original` is the staged + * (index) version — falling back to HEAD, then to "" for a brand-new file — and + * `modified` is the live document text, so unsaved edits are honored. + */ +export async function stageSelectedLines( + repos: RepoManager, + refresh: StagingRefresh, +): Promise { + const target = resolveTarget(repos); + if (!target) { + return; + } + const editor = vscode.window.activeTextEditor!; + const ranges = selectionRanges(editor); + await stageRangesAgainstIndex(target.entry, target.rel, target.doc, ranges, refresh); +} + +/** + * Stage the hunk(s) the cursor(s) currently sit in. Computes the hunks between + * the index baseline and the document, then selects those whose modified span + * contains a cursor line. + */ +export async function stageHunk( + repos: RepoManager, + refresh: StagingRefresh, +): Promise { + const target = resolveTarget(repos); + if (!target) { + return; + } + const editor = vscode.window.activeTextEditor!; + const original = await baselineForStaging(target.entry, target.rel); + const modified = target.doc.getText(); + const hunks = computeHunks(original, modified); + const cursorLines = editor.selections.map((s) => s.active.line); + const picked = hunks + .filter((h) => + cursorLines.some((line) => line >= h.modified.start && line <= h.modified.end), + ) + .map((h) => h.modified); + + if (picked.length === 0) { + void vscode.window.setStatusBarMessage( + "$(info) GitStudio: no change under the cursor to stage", + 2500, + ); + return; + } + const content = applySelectedChanges(original, modified, picked); + await commitStage(target.entry, target.rel, content, picked.length, refresh); +} + +/** Shared: reconstruct `ranges` against the index baseline and stage. */ +async function stageRangesAgainstIndex( + entry: RepoEntry, + rel: string, + doc: vscode.TextDocument, + ranges: LineRange[], + refresh: StagingRefresh, +): Promise { + const original = await baselineForStaging(entry, rel); + const modified = doc.getText(); + const hunks = computeHunks(original, modified); + const selectedHunks = hunks.filter((h) => + ranges.some((r) => rangesOverlap(h.modified, r)), + ); + if (selectedHunks.length === 0) { + void vscode.window.setStatusBarMessage( + "$(info) GitStudio: nothing to stage in the selection", + 2500, + ); + return; + } + const content = applySelectedChanges( + original, + modified, + selectedHunks.map((h) => h.modified), + ); + await commitStage(entry, rel, content, selectedHunks.length, refresh); +} + +/** + * Unstage the selected lines / hunk under the cursor. We reconstruct the index + * WITHOUT the selected change: baseline = HEAD, target = current index, and we + * apply every staged hunk EXCEPT the ones the selection covers. The result + * becomes the new index content. The selection is interpreted in index + * coordinates (the staged version), which matches unstaging from the diff + * editor's "Staged" view or a whole-file mental model. + */ +export async function unstageSelectedLines( + repos: RepoManager, + refresh: StagingRefresh, +): Promise { + await unstageByPredicate(repos, refresh, (hunk, ranges) => + ranges.some((r) => rangesOverlap(hunk.modified, r)), + ); +} + +/** Unstage the hunk(s) under the cursor (index-coordinate hunks). */ +export async function unstageHunk( + repos: RepoManager, + refresh: StagingRefresh, +): Promise { + await unstageByPredicate(repos, refresh, (hunk, _ranges, cursorLines) => + cursorLines.some( + (line) => line >= hunk.modified.start && line <= hunk.modified.end, + ), + ); +} + +/** + * Core unstage: HEAD is the baseline, the index is the "modified" target, and we + * re-stage every staged hunk that the `shouldDrop` predicate does NOT match — + * effectively removing the matched change from the index while keeping the rest. + */ +async function unstageByPredicate( + repos: RepoManager, + refresh: StagingRefresh, + shouldDrop: ( + hunk: ReturnType[number], + ranges: LineRange[], + cursorLines: number[], + ) => boolean, +): Promise { + const target = resolveTarget(repos); + if (!target) { + return; + } + const editor = vscode.window.activeTextEditor!; + const head = await target.entry.ctx.staging.headContent(target.rel); + const index = await target.entry.ctx.staging.indexContent(target.rel); + const hunks = computeHunks(head, index); + if (hunks.length === 0) { + void vscode.window.setStatusBarMessage( + "$(info) GitStudio: nothing staged to unstage here", + 2500, + ); + return; + } + const ranges = selectionRanges(editor); + const cursorLines = editor.selections.map((s) => s.active.line); + // Keep every staged hunk the predicate does NOT flag for removal. + const keep = hunks.filter((h) => !shouldDrop(h, ranges, cursorLines)); + if (keep.length === hunks.length) { + void vscode.window.setStatusBarMessage( + "$(info) GitStudio: no staged change selected to unstage", + 2500, + ); + return; + } + const newIndex = applySelectedChanges(head, index, keep.map((h) => h.modified)); + const result = await target.entry.ctx.staging.stageContent( + target.rel, + newIndex, + ); + finishStaging(result.ok, result.stderr, "Unstaged selection", refresh); +} + +/** + * The baseline used when STAGING: the staged (index) version if the file is + * tracked there, else HEAD, else "" (a brand-new file). Staging selected lines + * means "make the index look like this for the selected hunks", so the index is + * the right baseline to layer the selection onto. + */ +async function baselineForStaging(entry: RepoEntry, rel: string): Promise { + const indexed = await entry.ctx.staging.indexContent(rel); + if (indexed !== "") { + return indexed; + } + return entry.ctx.staging.headContent(rel); +} + +/** Stage reconstructed `content`, then report + refresh. */ +async function commitStage( + entry: RepoEntry, + rel: string, + content: string, + hunkCount: number, + refresh: StagingRefresh, +): Promise { + const result = await entry.ctx.staging.stageContent(rel, content); + const label = + hunkCount === 1 ? "Staged 1 change" : `Staged ${hunkCount} changes`; + finishStaging(result.ok, result.stderr, label, refresh); +} + +function finishStaging( + ok: boolean, + stderr: string, + label: string, + refresh: StagingRefresh, +): void { + if (!ok) { + void vscode.window.showErrorMessage( + `GitStudio: staging failed — ${stderr.trim() || "unknown error"}`, + ); + return; + } + void vscode.window.setStatusBarMessage(`$(check) ${label}`, 2500); + refresh.refresh(); +} + +/** True when two 0-based inclusive ranges overlap (insertion points included). */ +function rangesOverlap(a: LineRange, b: LineRange): boolean { + const aEnd = a.end < a.start ? a.start : a.end; + const bEnd = b.end < b.start ? b.start : b.end; + return a.start <= bEnd && b.start <= aEnd; +} diff --git a/apps/extension/src/compare/comparePanel.ts b/apps/extension/src/compare/comparePanel.ts new file mode 100644 index 0000000..2759509 --- /dev/null +++ b/apps/extension/src/compare/comparePanel.ts @@ -0,0 +1,364 @@ +import * as vscode from "vscode"; +import type { RepoManager } from "../git/repoManager"; +import { getNonce } from "../webview/html"; +import { relativeTime } from "../util/relativeTime"; +// Shared design tokens, inlined as text by esbuild (see esbuild.js .css loader), +// so the compare panel matches every other GitStudio surface. +import tokensCss from "../../../../packages/webview-ui/src/styles/tokens.css"; +import { + compareRefsData, + openCompareFileDiff, + pickRef, + type CompareFile, + type CompareResult, +} from "./refCompare"; +import type { CommitRecord, GitRef } from "@gitstudio/host-bridge/git"; + +/** Messages the compare webview posts back to the host. */ +type CompareMessage = + | { type: "pickBase" } + | { type: "pickHead" } + | { type: "swap" } + | { type: "setMode"; threeDot: boolean } + | { type: "openCommit"; sha: string } + | { type: "openFile"; path: string } + | { type: "refresh" }; + +/** + * The branch/ref comparison panel (editor area) — GitHub/GitKraken-style: a base + * and compare ref, a "what head adds (3-dot)" vs "all differences (2-dot)" + * toggle, ahead/behind counts, and a Commits | Files tab pair. Commits reveal in + * the graph; files open as a native side-by-side diff. Replaces the old Search & + * Compare tree with the app's richer experience. + */ +export class ComparePanel { + private static current: ComparePanel | undefined; + + static async show( + repos: RepoManager, + extensionUri: vscode.Uri, + base?: string, + head?: string, + ): Promise { + const active = repos.getActive(); + if (!active) { + void vscode.window.showInformationMessage( + "GitStudio: no active repository to compare.", + ); + return; + } + const headRef = await active.ctx.refs.getHead(); + const current = headRef.detached ? headRef.sha : headRef.branch; + const b = base ?? current; + let h = head; + if (!h) { + // Palette entry point — prompt for the ref to compare `b` against. + const refs = (await active.ctx.refs.listRefs()).filter( + (r: GitRef) => + r.type === "head" || r.type === "remote" || r.type === "tag", + ); + h = await pickRef(refs, `Compare ${b} with…`); + } + if (!b || !h) { + return; + } + + if (ComparePanel.current) { + ComparePanel.current.setRefs(b, h); + ComparePanel.current.panel.reveal(vscode.ViewColumn.Active); + return; + } + ComparePanel.current = new ComparePanel(repos, extensionUri, b, h); + } + + private readonly panel: vscode.WebviewPanel; + private readonly disposables: vscode.Disposable[] = []; + private disposed = false; + private base: string; + private head: string; + private threeDot = true; + + private constructor( + private readonly repos: RepoManager, + private readonly extensionUri: vscode.Uri, + base: string, + head: string, + ) { + this.base = base; + this.head = head; + this.panel = vscode.window.createWebviewPanel( + "gitstudio.compare", + "Compare", + vscode.ViewColumn.Active, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.joinPath(extensionUri, "dist")], + }, + ); + this.disposables.push( + this.panel.webview.onDidReceiveMessage((m: CompareMessage) => + this.onMessage(m), + ), + this.panel.onDidDispose(() => this.dispose()), + this.repos.onDidChange(() => void this.update()), + ); + void this.update(); + } + + private setRefs(base: string, head: string): void { + this.base = base; + this.head = head; + void this.update(); + } + + /** Re-run the comparison and re-render. */ + private async update(): Promise { + this.panel.title = `Compare: ${this.base} ↔ ${this.head}`; + const active = this.repos.getActive(); + if (!active) { + this.panel.webview.html = this.errorHtml("No active repository."); + return; + } + let result: CompareResult; + try { + result = await compareRefsData( + active, + this.base, + this.head, + this.threeDot, + ); + } catch { + if (this.disposed) { + return; + } + this.panel.webview.html = this.errorHtml( + `Couldn't compare ${this.base} with ${this.head}.`, + ); + return; + } + // The panel can be closed while compareRefsData() is in flight; writing to a + // disposed webview throws an unhandled "Webview is disposed" rejection. + if (this.disposed) { + return; + } + this.panel.webview.html = this.render(result, active.root); + } + + private async onMessage(m: CompareMessage): Promise { + const active = this.repos.getActive(); + switch (m.type) { + case "pickBase": + case "pickHead": { + if (!active) { + return; + } + const refs = (await active.ctx.refs.listRefs()).filter( + (r: GitRef) => + r.type === "head" || r.type === "remote" || r.type === "tag", + ); + const picked = await pickRef( + refs, + m.type === "pickBase" ? "Compare from (base)…" : "Compare to (head)…", + ); + if (picked) { + if (m.type === "pickBase") { + this.base = picked; + } else { + this.head = picked; + } + void this.update(); + } + return; + } + case "swap": { + [this.base, this.head] = [this.head, this.base]; + void this.update(); + return; + } + case "setMode": { + this.threeDot = m.threeDot; + void this.update(); + return; + } + case "openCommit": { + await vscode.commands.executeCommand( + "gitstudio.openCommitInGraph", + m.sha, + ); + return; + } + case "openFile": { + if (!active) { + return; + } + // The left side depends on the dot-mode (merge-base for 3-dot); the + // panel already resolved it into `filesLeftRef` for the current render. + await openCompareFileDiff({ + root: active.root, + refA: this.filesLeftRef, + refB: this.head, + path: m.path, + }); + return; + } + case "refresh": { + void this.update(); + return; + } + } + } + + /** The files-left ref for the current render (set in render()). */ + private filesLeftRef = ""; + + private render(result: CompareResult, root: string): string { + this.filesLeftRef = result.filesLeftRef; + const nonce = getNonce(); + const codiconUri = this.panel.webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, "dist", "codicons", "codicon.css"), + ); + const csp = [ + `default-src 'none'`, + `style-src 'nonce-${nonce}' ${this.panel.webview.cspSource}`, + `font-src ${this.panel.webview.cspSource}`, + `script-src 'nonce-${nonce}'`, + ].join("; "); + + const commitsHtml = result.commits.length + ? result.commits.map((c) => this.commitRow(c)).join("") + : `
No commits — ${esc(this.head)} has nothing that ${esc(this.base)} doesn't.
`; + const filesHtml = result.files.length + ? result.files.map((f) => this.fileRow(f)).join("") + : `
No file changes between these refs.
`; + + const behindNote = + result.behind > 0 + ? ` · ${result.behind} behind` + : ""; + const summary = `${esc(this.head)} is ${result.ahead} ahead${behindNote} of ${esc(this.base)}`; + + return ` + + + + + + + +
+ + ${this.threeDot ? "..." : ".."} + + +
+ + +
+
+
${summary}
+
+ + +
+
${commitsHtml}
+ + +`; + } + + private commitRow(c: CommitRecord): string { + const shortSha = c.sha.slice(0, 7); + const meta = `${esc(c.author)} · ${shortSha} · ${esc(relativeTime(c.authorDate))}`; + return ``; + } + + private fileRow(f: CompareFile): string { + const st = (f.status || "M").charAt(0).toUpperCase(); + const name = f.path.split("/").pop() ?? f.path; + const dir = f.path.includes("/") + ? f.path.slice(0, f.path.lastIndexOf("/")) + : ""; + return ``; + } + + private errorHtml(message: string): string { + const nonce = getNonce(); + const csp = `default-src 'none'; style-src 'nonce-${nonce}'`; + return `${esc(message)}`; + } + + private dispose(): void { + this.disposed = true; + ComparePanel.current = undefined; + this.panel.dispose(); + for (const d of this.disposables) { + d.dispose(); + } + } +} + +function esc(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} diff --git a/apps/extension/src/compare/refCompare.ts b/apps/extension/src/compare/refCompare.ts new file mode 100644 index 0000000..6c728f2 --- /dev/null +++ b/apps/extension/src/compare/refCompare.ts @@ -0,0 +1,235 @@ +import * as vscode from "vscode"; +import type { CommitRecord, GitRef } from "@gitstudio/host-bridge/git"; +import type { RepoEntry } from "../git/repoManager"; +import { toRevisionUri } from "../history/revisionContentProvider"; + +// The ref-comparison engine, lifted out of the retired Search & Compare view so +// the branch-compare panel (and any future consumer) can reuse the same +// battle-tested git plumbing: `git log base..head` for the commits, `git diff +// --name-status -M` (3-dot or 2-dot) for the files, `rev-list --count` for the +// behind count, and native `vscode.diff` over `gitstudio-rev:` URIs per file. + +const COMPARE_COMMIT_LIMIT = 400; + +/** A file changed between the two compared refs. */ +export interface CompareFile { + path: string; + /** Single-letter git status (A/M/D/R…). */ + status: string; +} + +/** The full result of comparing two refs. */ +export interface CompareResult { + /** Commits in `head` that `base` doesn't have (base..head). */ + commits: CommitRecord[]; + files: CompareFile[]; + /** `head` is this many commits ahead of `base`. Counted exactly via + * `rev-list --count`; the `commits` LIST above is capped for display, but + * this number is not, so a >400-commit lead still reports truthfully. */ + ahead: number; + /** `head` is this many commits behind `base` (commits base has, head lacks). */ + behind: number; + /** + * The ref to diff each file FROM. For 3-dot this is the merge-base of the two + * refs (GitHub's "what head introduced"); for 2-dot it's `base` directly. + */ + filesLeftRef: string; +} + +/** Collect up to `limit` commits from a `git log` invocation. */ +export async function collectCommits( + repo: RepoEntry, + logArgs: string[], + paths: string[] = [], + limit = COMPARE_COMMIT_LIMIT, +): Promise { + const FIELD = "\x1f"; + const RECORD = "\x1e"; + const format = + `--pretty=format:%H${FIELD}%P${FIELD}%an${FIELD}%ae${FIELD}%at` + + `${FIELD}%cn${FIELD}%ce${FIELD}%ct${FIELD}%s${FIELD}%b${RECORD}`; + const args = [ + "log", + "--date-order", + format, + `--max-count=${limit}`, + ...logArgs, + ]; + if (paths.length > 0) { + args.push("--", ...paths); + } + const result = await repo.ctx.process.run(args); + if (result.code !== 0) { + return []; + } + const commits: CommitRecord[] = []; + for (const raw of result.stdout.split(RECORD)) { + const trimmed = raw.startsWith("\n") ? raw.slice(1) : raw; + if (trimmed.length === 0) { + continue; + } + const f = trimmed.split(FIELD); + if (f.length < 10 || f[0] === "") { + continue; + } + commits.push({ + sha: f[0], + parents: f[1].split(" ").filter((p) => p.length > 0), + author: f[2], + authorEmail: f[3], + authorDate: Number(f[4]), + committer: f[5], + committerEmail: f[6], + committerDate: Number(f[7]), + subject: f[8], + body: f[9], + }); + } + return commits; +} + +/** Files changed between two refs. `threeDot` uses `A...B` (what B introduced, + * GitHub-style); otherwise the direct `A B` diff. */ +export async function collectCompareFiles( + repo: RepoEntry, + refA: string, + refB: string, + threeDot = true, +): Promise { + const range = threeDot ? [`${refA}...${refB}`] : [refA, refB]; + const r = await repo.ctx.process.run([ + "diff", + "--name-status", + "-M", + ...range, + ]); + if (r.code !== 0) { + return []; + } + const files: CompareFile[] = []; + for (const line of r.stdout.split("\n")) { + if (!line.trim()) { + continue; + } + const parts = line.split("\t"); + const status = (parts[0] ?? "").charAt(0); + const path = parts.length >= 3 ? parts[2] : (parts[1] ?? ""); + if (path) { + files.push({ path, status }); + } + } + return files; +} + +/** The merge-base of two refs, or undefined if none / on error. */ +async function mergeBase( + repo: RepoEntry, + a: string, + b: string, +): Promise { + const r = await repo.ctx.process.run(["merge-base", a, b]); + const sha = r.stdout.trim(); + return r.code === 0 && sha ? sha : undefined; +} + +/** How many commits are in `from..to` (i.e. reachable from `to` but not `from`). + * Used for BOTH ahead (base..head) and behind (head..base), uncapped. */ +async function countRange( + repo: RepoEntry, + from: string, + to: string, +): Promise { + const r = await repo.ctx.process.run([ + "rev-list", + "--count", + `${from}..${to}`, + ]); + if (r.code !== 0) { + return 0; + } + const n = Number(r.stdout.trim()); + return Number.isFinite(n) ? n : 0; +} + +/** Throw if `ref` does not resolve to a commit, so an invalid/nonexistent ref + * surfaces as an error instead of a silent all-empty ("identical") result. */ +async function assertRef(repo: RepoEntry, ref: string): Promise { + const r = await repo.ctx.process.run([ + "rev-parse", + "--verify", + "--quiet", + `${ref}^{commit}`, + ]); + if (r.code !== 0 || !r.stdout.trim()) { + throw new Error(`Unknown ref: ${ref}`); + } +} + +/** Compare `base` against `head` — commits, files, ahead/behind. */ +export async function compareRefsData( + repo: RepoEntry, + base: string, + head: string, + threeDot: boolean, +): Promise { + // Fail loudly on an unknown ref (the panel's catch turns the throw into an + // error view) instead of silently rendering an all-empty "identical" result. + await Promise.all([assertRef(repo, base), assertRef(repo, head)]); + const [commits, files, ahead, behind, mb] = await Promise.all([ + collectCommits(repo, [`${base}..${head}`], []), + collectCompareFiles(repo, base, head, threeDot), + countRange(repo, base, head), // ahead: commits head has, base lacks + countRange(repo, head, base), // behind: commits base has, head lacks + threeDot ? mergeBase(repo, base, head) : Promise.resolve(undefined), + ]); + return { + commits, + files, + ahead, + behind, + filesLeftRef: threeDot ? (mb ?? base) : base, + }; +} + +/** Open a native side-by-side diff of one file between two refs. */ +export async function openCompareFileDiff(arg: { + root: string; + refA: string; + refB: string; + path: string; +}): Promise { + if (!arg) { + return; + } + const left = toRevisionUri(arg.root, arg.refA, arg.path); + const right = toRevisionUri(arg.root, arg.refB, arg.path); + const name = arg.path.split("/").pop() ?? arg.path; + await vscode.commands.executeCommand( + "vscode.diff", + left, + right, + `${name} (${arg.refA} ↔ ${arg.refB})`, + ); +} + +/** A QuickPick over the repo's branches/tags; returns the chosen ref name. */ +export async function pickRef( + refs: GitRef[], + title: string, +): Promise { + const icon = (r: GitRef) => + r.type === "tag" + ? "$(tag)" + : r.type === "remote" + ? "$(cloud)" + : "$(git-branch)"; + const picked = await vscode.window.showQuickPick( + refs.map((r) => ({ + label: `${icon(r)} ${r.name}`, + description: r.sha.slice(0, 7), + name: r.name, + })), + { title, placeHolder: "Pick a branch / tag" }, + ); + return picked?.name; +} diff --git a/apps/extension/src/css.d.ts b/apps/extension/src/css.d.ts new file mode 100644 index 0000000..d05a6dc --- /dev/null +++ b/apps/extension/src/css.d.ts @@ -0,0 +1,8 @@ +// The extension esbuild context loads `*.css` imports with the `text` loader +// (see esbuild.js), so an inline-template webview can embed a stylesheet's raw +// bytes into its + + + +
+

#${pr.number} ${esc(pr.title)}

+
+ ${stateBadge} + ${avatar} + ${esc(author?.login ?? "unknown")} + · + opened ${esc(age)} ago +
+
+ ${ICON.gitBranch}${esc(pr.base.ref)} + ${ICON.arrowLeft} + ${ICON.gitBranch}${esc(pr.head.label)} +
+
+ +
+ + + + + +
+ + ${checks ? `
${checks}
` : ""} + ${labels ? `

Labels

${labels}
` : ""} + ${reviewers ? `

Reviewers

${reviewers}
` : ""} + +
+

Description

+
${bodyHtml || `No description provided.`}
+
+ +
+

Changed files (${files.length})${files.length > 0 ? `+${totalAdd}−${totalDel}` : ""}

+ ${files.length > 0 ? `
    ${fileRows}
` : `No file data.`} +
+ + + +`; +} + +function checkClass(state: string): string { + if (state === "success") return "ok"; + if (state === "failure" || state === "error") return "fail"; + return "pending"; +} + +function checkLabel(status: CombinedStatus): string { + if (status.totalCount === 0) return "No checks"; + switch (status.state) { + case "success": + return `All ${status.totalCount} checks passed`; + case "failure": + case "error": + return `Some checks failed`; + default: + return `Checks pending`; + } +} + +/** Inline SVG icon for the checks summary (currentColor, no emoji). */ +function checkIcon(state: string): string { + if (state === "success") return ICON.pass; + if (state === "failure" || state === "error") return ICON.fail; + return ICON.pending; +} + +/** Capitalize the first letter of a state word ("closed" → "Closed"). */ +function cap(s: string): string { + return s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s; +} + +/** Map a GitHub per-file status to a stable CSS class. */ +function fileStatusClass(status: string): string { + switch (status) { + case "added": + return "added"; + case "removed": + return "deleted"; + case "renamed": + return "renamed"; + default: + return "modified"; + } +} + +/** Single-letter status glyph (A/M/D/R), tabular-monospace — not an emoji. */ +function fileStatusGlyph(status: string): string { + switch (status) { + case "added": + return "A"; + case "removed": + return "D"; + case "renamed": + return "R"; + default: + return "M"; + } +} + +/** + * Inline SVG icons drawn with `currentColor` so they inherit theme-native text + * color in dark, light, and high-contrast. These replace every former emoji / + * decorative unicode glyph (the old check / cross / dot / arrow) in this + * surface. + */ +/** + * The real VS Code icon font (codicons). Each entry is an the webview styles + * via the linked codicon stylesheet — no more bespoke hand-drawn SVGs. + */ +const ICON = { + pass: codicon("pass"), + fail: codicon("error"), + pending: codicon("clock"), + prOpen: codicon("git-pull-request"), + merged: codicon("git-merge"), + draft: codicon("git-pull-request-draft"), + gitBranch: codicon("git-branch"), + arrowLeft: codicon("arrow-left"), + person: codicon("account"), + checkout: codicon("arrow-down"), + review: codicon("comment-discussion"), + merge: codicon("git-merge"), + external: codicon("link-external"), + refresh: codicon("refresh"), +} as const; + +/** A codicon glyph element by name (e.g. "git-merge"). */ +function codicon(name: string): string { + return ``; +} + +/** Only allow a 3- or 6-hex-digit color; otherwise fall back to a neutral. */ +function sanitizeColor(color: string): string { + return /^[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(color) ? color : "888888"; +} + +/** HTML-escape a string for safe insertion into text/attribute positions. */ +function esc(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * A tiny, SAFE Markdown-ish renderer. The input is escaped FIRST, so no raw + * HTML can survive; we then re-introduce a small, fixed set of formatting tags + * (headings, bold/italic, inline + fenced code, links, list items). Links only + * accept http/https URLs. This is intentionally minimal — readability, not + * fidelity, is the goal, and security is non-negotiable. + */ +function renderMarkdownish(src: string): string { + if (src.trim().length === 0) { + return ""; + } + // Pull out fenced code blocks first (on escaped text) so their contents are + // not further formatted. + const escaped = esc(src.replace(/\r\n/g, "\n")); + const blocks: string[] = []; + let withFences = escaped.replace(/```([\s\S]*?)```/g, (_m, code: string) => { + const idx = blocks.push(`
${code.replace(/^\n/, "")}
`) - 1; + return `BLOCK${idx}`; + }); + + const lines = withFences.split("\n"); + const out: string[] = []; + let inList = false; + for (const raw of lines) { + const placeholder = /^BLOCK\d+$/.test(raw.trim()); + if (placeholder) { + if (inList) { + out.push(""); + inList = false; + } + out.push(raw.trim()); + continue; + } + const line = raw; + const heading = /^(#{1,6})\s+(.*)$/.exec(line); + const listItem = /^[-*]\s+(.*)$/.exec(line); + if (heading) { + if (inList) { + out.push(""); + inList = false; + } + const level = Math.min(heading[1].length + 2, 6); // h3..h6 inside body + out.push(`${inline(heading[2])}`); + } else if (listItem) { + if (!inList) { + out.push("
    "); + inList = true; + } + out.push(`
  • ${inline(listItem[1])}
  • `); + } else if (line.trim().length === 0) { + if (inList) { + out.push("
"); + inList = false; + } + out.push(""); + } else { + if (inList) { + out.push(""); + inList = false; + } + out.push(`

${inline(line)}

`); + } + } + if (inList) { + out.push(""); + } + + let html = out.join("\n"); + // Restore fenced code blocks. + html = html.replace(/BLOCK(\d+)/g, (_m, i: string) => blocks[Number(i)] ?? ""); + return html; +} + +/** Inline formatting on already-escaped text: code, bold, italic, links. */ +function inline(text: string): string { + let s = text; + // Inline code (no further formatting inside). + const codes: string[] = []; + s = s.replace(/`([^`]+)`/g, (_m, c: string) => { + const idx = codes.push(`${c}`) - 1; + return `C${idx}`; + }); + // Links [text](http...). URL is validated to http/https only. The slashes + // survive esc() untouched; only a literal `&` would have become `&`. + s = s.replace( + /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, + (_m, label: string, url: string) => { + const clean = url.replace(/&/g, "&"); + return `${label}`; + }, + ); + // Bold then italic. + s = s.replace(/\*\*([^*]+)\*\*/g, "$1"); + s = s.replace(/(^|[^*])\*([^*]+)\*/g, "$1$2"); + s = s.replace(/C(\d+)/g, (_m, i: string) => codes[Number(i)] ?? ""); + return s; +} diff --git a/apps/extension/src/pr/prFeature.ts b/apps/extension/src/pr/prFeature.ts new file mode 100644 index 0000000..bf50ac4 --- /dev/null +++ b/apps/extension/src/pr/prFeature.ts @@ -0,0 +1,282 @@ +import * as vscode from "vscode"; +import type { RepoManager } from "../git/repoManager"; +import type { GitBrain } from "../ai/gitBrain"; +import { GitHubAuth } from "./githubAuth"; +import { GitHubApi, GitHubApiError, type MergeMethod, type PullRequest } from "./githubApi"; +import { PullRequestsTreeProvider, PrNode } from "./pullRequestsView"; +import { PrContentProvider, PR_SCHEME } from "./prContentProvider"; +import { PrDescriptionPanel } from "./prDescriptionPanel"; +import { ReviewController } from "./reviewMode"; +import { checkoutPullRequest } from "./checkoutPr"; +import { createPullRequest } from "./createPr"; +import { resolveGitHubContext, type GitHubRepoContext } from "./repoContext"; + +// Wires the whole M11 PR feature: GitHub auth + API, the Pull Requests tree, the +// PR-blob content provider, the description panel, review mode (Comments API), +// checkout, merge, and create. Everything degrades gracefully: not a GitHub +// repo or not signed in → the view is empty + the connect-prompt shows, and no +// command throws. +// +// A command's PR argument may arrive as a PrNode (from the tree), as a +// { pr, ctx } object (from the description panel / review), or be absent (from +// the command palette) — `resolvePr` normalises all three. + +interface PrCommandArg { + pr?: PullRequest; + ctx?: GitHubRepoContext; +} + +export function registerPrFeature( + context: vscode.ExtensionContext, + repos: RepoManager, + brain: GitBrain, +): void { + const auth = new GitHubAuth(); + const api = new GitHubApi({ getToken: (o) => auth.getToken(o) }); + context.subscriptions.push(auth); + void auth.refreshConnected(); + + const tree = new PullRequestsTreeProvider(repos, auth); + context.subscriptions.push(tree); + const view = vscode.window.createTreeView("gitstudio.pullRequests", { + treeDataProvider: tree, + showCollapseAll: true, + }); + context.subscriptions.push(view); + + // PR-blob content provider (base/head file contents for diffs). + const contentProvider = new PrContentProvider(auth); + context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider( + PR_SCHEME, + contentProvider, + ), + ); + + // Review mode (one CommentController + the pending-thread registry). + const review = new ReviewController(auth, api); + context.subscriptions.push(review); + + /** Resolve a PR + its GitHub context from any command argument shape. */ + const resolvePr = async ( + arg: PrNode | PrCommandArg | undefined, + ): Promise<{ pr: PullRequest; ctx: GitHubRepoContext } | undefined> => { + if (arg instanceof PrNode) { + return { pr: arg.pr, ctx: arg.ctx }; + } + if (arg && arg.pr) { + const ctx = arg.ctx ?? (await resolveGitHubContext(repos)) ?? undefined; + if (ctx) { + return { pr: arg.pr, ctx }; + } + } + // From the palette with no argument: ask the user to pick an open PR. + const ctx = await resolveGitHubContext(repos); + if (!ctx) { + void vscode.window.showInformationMessage( + "This repository isn't connected to GitHub.", + ); + return undefined; + } + if (!(await auth.getToken({ interactive: true }))) { + return undefined; + } + try { + const pulls = await api.listOpenPulls(ctx.owner, ctx.repo, { + interactiveAuth: true, + }); + if (pulls.length === 0) { + void vscode.window.showInformationMessage("No open pull requests."); + return undefined; + } + const pick = await vscode.window.showQuickPick( + pulls.map((p) => ({ + label: `#${p.number} ${p.title}`, + description: p.user?.login ?? "", + pr: p, + })), + { placeHolder: "Select a pull request" }, + ); + return pick ? { pr: pick.pr, ctx } : undefined; + } catch (err) { + void warn(err, "Couldn't list pull requests."); + return undefined; + } + }; + + const openDescription = async (pr: PullRequest, ctx: GitHubRepoContext) => { + await PrDescriptionPanel.show( + { api, ctx, extensionUri: context.extensionUri }, + pr, + ); + }; + + context.subscriptions.push( + // ── Title actions ────────────────────────────────────────────────────────── + vscode.commands.registerCommand("gitstudio.pr.refresh", () => { + tree.refresh(); + }), + vscode.commands.registerCommand("gitstudio.pr.signIn", async () => { + const token = await auth.getToken({ interactive: true }); + if (token) { + tree.refresh(); + } + }), + vscode.commands.registerCommand("gitstudio.pr.create", () => + createPullRequest(repos, brain, api, context.extensionUri, () => + tree.refresh(), + ), + ), + + // ── Item actions ───────────────────────────────────────────────────────────── + vscode.commands.registerCommand( + "gitstudio.pr.openDescription", + async (arg?: PrNode | PrCommandArg) => { + const resolved = await resolvePr(arg); + if (resolved) { + await openDescription(resolved.pr, resolved.ctx); + } + }, + ), + vscode.commands.registerCommand( + "gitstudio.pr.checkout", + async (arg?: PrNode | PrCommandArg) => { + const resolved = await resolvePr(arg); + if (!resolved) { + return; + } + await checkoutPullRequest( + resolved.ctx.entry, + resolved.ctx.remoteName, + resolved.pr, + () => tree.refresh(), + ); + }, + ), + vscode.commands.registerCommand( + "gitstudio.pr.startReview", + async (arg?: PrNode | PrCommandArg) => { + const resolved = await resolvePr(arg); + if (!resolved) { + return; + } + if (!(await auth.getToken({ interactive: true }))) { + return; + } + await review.startReview(resolved.ctx, resolved.pr); + }, + ), + vscode.commands.registerCommand("gitstudio.pr.submitReview", () => + review.submitReview(), + ), + vscode.commands.registerCommand("gitstudio.pr.cancelReview", () => + review.cancelReview(), + ), + vscode.commands.registerCommand( + "gitstudio.pr.addReviewComment", + (reply: vscode.CommentReply) => review.addComment(reply), + ), + vscode.commands.registerCommand( + "gitstudio.pr.addSingleComment", + (reply: vscode.CommentReply) => void review.addSingleComment(reply), + ), + vscode.commands.registerCommand( + "gitstudio.pr.deleteReviewComment", + (arg: vscode.CommentThread | { thread?: vscode.CommentThread }) => { + // From comments/comment/title VS Code passes the comment node (which + // carries `.thread`); from elsewhere a thread directly. + const thread = + arg && "thread" in arg && arg.thread + ? arg.thread + : (arg as vscode.CommentThread); + if (thread) { + review.removeThread(thread); + } + }, + ), + vscode.commands.registerCommand( + "gitstudio.pr.openOnGitHub", + async (arg?: PrNode | PrCommandArg) => { + const resolved = await resolvePr(arg); + if (resolved) { + void vscode.env.openExternal(vscode.Uri.parse(resolved.pr.htmlUrl)); + } + }, + ), + vscode.commands.registerCommand( + "gitstudio.pr.copyUrl", + async (arg?: PrNode | PrCommandArg) => { + const resolved = await resolvePr(arg); + if (resolved) { + await vscode.env.clipboard.writeText(resolved.pr.htmlUrl); + void vscode.window.showInformationMessage("PR URL copied."); + } + }, + ), + vscode.commands.registerCommand( + "gitstudio.pr.merge", + async (arg?: PrNode | PrCommandArg) => { + const resolved = await resolvePr(arg); + if (resolved) { + await mergePr(api, resolved.ctx, resolved.pr, () => tree.refresh()); + } + }, + ), + ); +} + +async function mergePr( + api: GitHubApi, + ctx: GitHubRepoContext, + pr: PullRequest, + onMerged: () => void, +): Promise { + const configured = vscode.workspace + .getConfiguration("gitstudio.pr") + .get("defaultMergeMethod", "squash"); + + const methods: { label: string; method: MergeMethod }[] = [ + { label: "$(git-merge) Create a merge commit", method: "merge" }, + { label: "$(git-commit) Squash and merge", method: "squash" }, + { label: "$(git-pull-request) Rebase and merge", method: "rebase" }, + ]; + // Surface the configured default first. + methods.sort((a, b) => + a.method === configured ? -1 : b.method === configured ? 1 : 0, + ); + + const pick = await vscode.window.showQuickPick(methods, { + placeHolder: `Merge PR #${pr.number} "${pr.title}"`, + }); + if (!pick) { + return; + } + const confirm = await vscode.window.showWarningMessage( + `Merge PR #${pr.number} into ${pr.base.ref} (${pick.method})?`, + { modal: true }, + "Merge", + ); + if (confirm !== "Merge") { + return; + } + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: `Merging PR #${pr.number}…` }, + async () => { + try { + await api.mergePull(ctx.owner, ctx.repo, pr.number, pick.method); + onMerged(); + void vscode.window.showInformationMessage( + `Merged PR #${pr.number}.`, + ); + } catch (err) { + await warn(err, "Couldn't merge the pull request."); + } + }, + ); +} + +async function warn(err: unknown, fallback: string): Promise { + const msg = err instanceof GitHubApiError ? err.message : fallback; + void vscode.window.showWarningMessage(msg); +} diff --git a/apps/extension/src/pr/pullRequestsView.ts b/apps/extension/src/pr/pullRequestsView.ts new file mode 100644 index 0000000..e4624e0 --- /dev/null +++ b/apps/extension/src/pr/pullRequestsView.ts @@ -0,0 +1,405 @@ +import * as vscode from "vscode"; +import { relativeTime } from "../util/relativeTime"; +import type { RepoManager } from "../git/repoManager"; +import type { GitHubAuth } from "./githubAuth"; +import { GitHubApi, GitHubApiError, type PullRequest } from "./githubApi"; +import { resolveGitHubContext, type GitHubRepoContext } from "./repoContext"; + +// The Pull Requests tree (gitstudio.pullRequests). It groups the active GitHub +// repo's open PRs into "Waiting for my review" / "Created by me" / "All open", +// best-effort using the signed-in login. Loads are silent: if GitHub isn't +// connected or the repo isn't on github.com, the tree is empty and the +// viewsWelcome connect-prompt shows. A short cache + debounced refresh keeps it +// responsive on RepoManager churn. + +const REFRESH_DEBOUNCE_MS = 400; + +type PrTreeNode = GroupNode | PrNode | MessageNode; + +type GroupKind = "review" | "mine" | "open"; + +const GROUP_LABELS: Record = { + review: "Waiting for my review", + mine: "Created by me", + open: "All open", +}; + +const GROUP_ICONS: Record = { + review: "eye", + mine: "account", + open: "git-pull-request", +}; + +/** A collapsible group header. */ +class GroupNode extends vscode.TreeItem { + readonly kind = "group" as const; + constructor( + readonly group: GroupKind, + readonly prs: PullRequest[], + ) { + super( + GROUP_LABELS[group], + prs.length > 0 + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed, + ); + this.description = String(prs.length); + this.iconPath = new vscode.ThemeIcon(GROUP_ICONS[group]); + this.tooltip = `${GROUP_LABELS[group]} — ${prs.length} pull request${ + prs.length === 1 ? "" : "s" + }`; + this.contextValue = `gitstudio.prGroup.${group}`; + } +} + +/** A single pull request row. */ +export class PrNode extends vscode.TreeItem { + readonly kind = "pr" as const; + constructor( + readonly pr: PullRequest, + readonly ctx: GitHubRepoContext, + statusGlyph?: string, + ) { + super(`#${pr.number} ${pr.title}`, vscode.TreeItemCollapsibleState.None); + + const author = pr.user?.login ?? "unknown"; + const age = relativeTime(Date.parse(pr.createdAt) / 1000); + + // The PR icon carries CI status as a themed color so the row stays a clean + // single line; draft PRs read muted regardless of CI. + const ciColor = statusColor(statusGlyph); + this.iconPath = pr.draft + ? new vscode.ThemeIcon("git-pull-request-draft") + : new vscode.ThemeIcon("git-pull-request", ciColor); + + // CI glyph (when known) leads the muted metadata: status · author · age. + this.description = statusGlyph + ? `${statusGlyph} ${author} · ${age}` + : `${author} · ${age}`; + + this.contextValue = "gitstudio.pr"; + this.tooltip = buildTooltip(pr, statusGlyph); + this.command = { + command: "gitstudio.pr.openDescription", + title: "Open Description", + arguments: [this], + }; + } +} + +/** A leaf row used for transient messages (loading / errors). */ +class MessageNode extends vscode.TreeItem { + readonly kind = "message" as const; + constructor(label: string, icon = "info") { + super(label, vscode.TreeItemCollapsibleState.None); + this.iconPath = new vscode.ThemeIcon(icon); + this.contextValue = "gitstudio.prMessage"; + } +} + +/** Map a CI glyph to a themed status color for the row icon. */ +function statusColor(glyph?: string): vscode.ThemeColor | undefined { + switch (glyph) { + case "$(check)": + return new vscode.ThemeColor("charts.green"); + case "$(x)": + return new vscode.ThemeColor("charts.red"); + case "$(circle-filled)": + return new vscode.ThemeColor("charts.yellow"); + default: + return undefined; + } +} + +/** Human-readable CI label for the tooltip. */ +function statusLabel(glyph?: string): string | undefined { + switch (glyph) { + case "$(check)": + return "$(check) Checks passing"; + case "$(x)": + return "$(x) Checks failing"; + case "$(circle-filled)": + return "$(circle-filled) Checks running"; + default: + return undefined; + } +} + +function buildTooltip( + pr: PullRequest, + statusGlyph?: string, +): vscode.MarkdownString { + const md = new vscode.MarkdownString(undefined, true); + md.supportThemeIcons = true; + const headIcon = pr.draft + ? "$(git-pull-request-draft)" + : "$(git-pull-request)"; + md.appendMarkdown(`${headIcon} **#${pr.number} ${escapeMd(pr.title)}**\n\n`); + const author = pr.user?.login; + if (author) { + md.appendMarkdown(`$(account) ${escapeMd(author)}\n\n`); + } + if (pr.draft) { + md.appendMarkdown(`$(git-pull-request-draft) Draft\n\n`); + } + const ci = statusLabel(statusGlyph); + if (ci) { + md.appendMarkdown(`${ci}\n\n`); + } + // Inside a `code span` backslash escapes render LITERALLY (CommonMark), so + // escapeMd would display "release\-1\.x". Backticks are the only character + // that can break the span — neutralize just those. + const codeSpan = (s: string) => `\`${s.replace(/`/g, "'")}\``; + md.appendMarkdown( + `$(git-branch) ${codeSpan(pr.base.ref)} ← ${codeSpan(pr.head.label)}\n\n`, + ); + const body = (pr.body ?? "").trim(); + if (body.length > 0) { + const excerpt = body.length > 240 ? `${body.slice(0, 240)}…` : body; + md.appendMarkdown(`${escapeMd(excerpt)}\n`); + } + return md; +} + +function escapeMd(text: string): string { + return text.replace(/[\\`*_{}[\]()#+\-.!|>]/g, "\\$&"); +} + +interface LoadedData { + ctx: GitHubRepoContext; + groups: Record; + statusByNumber: Map; +} + +export class PullRequestsTreeProvider + implements vscode.TreeDataProvider, vscode.Disposable +{ + private readonly emitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this.emitter.event; + + private readonly disposables: vscode.Disposable[] = []; + private refreshTimer: ReturnType | undefined; + + private readonly api: GitHubApi; + private data: LoadedData | undefined; + private lastError: string | undefined; + + constructor( + private readonly repos: RepoManager, + private readonly auth: GitHubAuth, + ) { + this.api = new GitHubApi({ getToken: (o) => this.auth.getToken(o) }); + this.disposables.push( + this.repos.onDidChange(() => this.scheduleRefresh()), + this.auth.onDidChange(() => this.scheduleRefresh()), + ); + } + + /** Resolve the current GitHub context, for commands that need owner/repo. */ + resolveContext(): Promise { + return resolveGitHubContext(this.repos); + } + + getApi(): GitHubApi { + return this.api; + } + + refresh(): void { + this.lastError = undefined; + if (this.data) { + // Stale-while-revalidate: keep the current rows on screen and refresh in + // the background. Routine git activity (a local commit, a fetch) triggers + // a refresh, and we don't want it to blank the list and re-run the whole + // GitHub load before anything shows again. + void this.revalidate(); + } else { + // Nothing loaded yet — let getChildren do the first (lazy) load. + this.emitter.fire(undefined); + } + } + + private revalidating = false; + private async revalidate(): Promise { + if (this.revalidating) { + return; + } + this.revalidating = true; + try { + const ctx = await resolveGitHubContext(this.repos); + if (ctx && (await this.auth.isConnected())) { + this.data = await this.load(ctx); + this.lastError = undefined; + } else { + this.data = undefined; + } + } catch (err) { + this.lastError = friendlyError(err); + } finally { + this.revalidating = false; + this.emitter.fire(undefined); + } + } + + private scheduleRefresh(): void { + if (this.refreshTimer !== undefined) { + clearTimeout(this.refreshTimer); + } + this.refreshTimer = setTimeout(() => { + this.refreshTimer = undefined; + this.refresh(); + }, REFRESH_DEBOUNCE_MS); + } + + getTreeItem(element: PrTreeNode): vscode.TreeItem { + return element; + } + + async getChildren(element?: PrTreeNode): Promise { + if (element) { + if (element.kind === "group") { + return element.prs.map( + (pr) => + new PrNode( + pr, + this.data!.ctx, + this.data?.statusByNumber.get(pr.number), + ), + ); + } + return []; + } + + // Root: ensure data is loaded. + const ctx = await resolveGitHubContext(this.repos); + if (!ctx) { + // Not a GitHub repo (or no active repo) → empty; welcome view covers it. + return []; + } + + // Connected? A silent check; the connect-prompt (viewsWelcome) handles the + // not-connected case so we don't show a noisy error row. + if (!(await this.auth.isConnected())) { + return []; + } + + if (!this.data) { + try { + this.data = await this.load(ctx); + this.lastError = undefined; + } catch (err) { + this.lastError = friendlyError(err); + return [new MessageNode(this.lastError, "warning")]; + } + } + + const groups = this.data.groups; + const result: GroupNode[] = []; + // "Waiting for my review" only when we could compute it (login known). + if (groups.review.length > 0) { + result.push(new GroupNode("review", groups.review)); + } + result.push(new GroupNode("mine", groups.mine)); + result.push(new GroupNode("open", groups.open)); + return result; + } + + private async load(ctx: GitHubRepoContext): Promise { + // The pulls list and the current login are independent — fetch them together + // rather than one after the other (saves a full GitHub round-trip on first + // paint). + const [pulls, me] = await Promise.all([ + this.api.listOpenPulls(ctx.owner, ctx.repo), + this.api.currentLogin(), + ]); + const login = me?.login; + + const mine: PullRequest[] = []; + const review: PullRequest[] = []; + for (const pr of pulls) { + if (login && pr.user?.login === login) { + mine.push(pr); + } + if ( + login && + pr.user?.login !== login && + pr.requestedReviewers.some((r) => r.login === login) + ) { + review.push(pr); + } + } + + const data: LoadedData = { + ctx, + groups: { review, mine, open: pulls }, + statusByNumber: new Map(), + }; + // CI status is a purely cosmetic per-row glyph. Previously the whole list + // waited on up to 8 `commits/{sha}/status` calls before ANY row painted; + // now we return immediately and fetch them in the background, repainting the + // glyphs when they arrive. + void this.loadStatuses(ctx, pulls.slice(0, 8), data); + return data; + } + + /** Best-effort CI-status glyphs, fetched off the first-paint path. */ + private async loadStatuses( + ctx: GitHubRepoContext, + pulls: PullRequest[], + data: LoadedData, + ): Promise { + await Promise.all( + pulls.map(async (pr) => { + try { + const status = await this.api.getCombinedStatus( + ctx.owner, + ctx.repo, + pr.head.sha, + ); + const glyph = statusGlyph(status.state); + if (glyph) { + data.statusByNumber.set(pr.number, glyph); + } + } catch { + // ignore — no glyph for this PR. + } + }), + ); + // Only repaint if this data is still the one on screen — a later refresh may + // have replaced it, and we must not clobber fresher rows with stale glyphs. + if (this.data === data && data.statusByNumber.size > 0) { + this.emitter.fire(undefined); + } + } + + dispose(): void { + if (this.refreshTimer !== undefined) { + clearTimeout(this.refreshTimer); + } + for (const d of this.disposables) { + d.dispose(); + } + this.disposables.length = 0; + this.emitter.dispose(); + } +} + +function statusGlyph(state: string): string | undefined { + switch (state) { + case "success": + return "$(check)"; + case "failure": + case "error": + return "$(x)"; + case "pending": + return "$(circle-filled)"; + default: + return undefined; + } +} + +function friendlyError(err: unknown): string { + if (err instanceof GitHubApiError) { + return err.message; + } + return "Couldn't load pull requests from GitHub."; +} diff --git a/apps/extension/src/pr/repoContext.ts b/apps/extension/src/pr/repoContext.ts new file mode 100644 index 0000000..1a0c97d --- /dev/null +++ b/apps/extension/src/pr/repoContext.ts @@ -0,0 +1,66 @@ +import { + parseRemote, + isGitHubRemote, + type ParsedRemote, +} from "@gitstudio/engine/forge/parseRemote"; +import type { RepoManager, RepoEntry } from "../git/repoManager"; + +// Resolves the active repository's GitHub coordinates ({owner, repo}) from its +// configured remotes, using the pure `parseRemote` from the engine. `origin` is +// preferred; we fall back to the first github.com remote we find so a repo that +// names its GitHub remote "upstream" still works. Returns null when there is no +// active repo or no GitHub remote — the PR features then stay silently +// unavailable, never erroring. + +export interface GitHubRepoContext { + owner: string; + repo: string; + /** The git remote whose URL we resolved (e.g. "origin"). */ + remoteName: string; + /** The active repo entry, for git operations (fetch/checkout). */ + entry: RepoEntry; +} + +/** + * Resolves the active repo's GitHub {owner, repo}. Prefers the `origin` remote; + * otherwise the first github.com remote. Returns null when not a GitHub repo. + */ +export async function resolveGitHubContext( + repos: RepoManager, +): Promise { + const entry = repos.getActive(); + if (!entry) { + return null; + } + + let remotes: { name: string; fetchUrl: string; pushUrl: string }[]; + try { + remotes = await entry.ctx.remotes.list(); + } catch { + return null; + } + if (remotes.length === 0) { + return null; + } + + // origin first, then any other github.com remote. + const ordered = [...remotes].sort((a, b) => { + if (a.name === "origin") return -1; + if (b.name === "origin") return 1; + return 0; + }); + + for (const remote of ordered) { + const url = remote.fetchUrl || remote.pushUrl; + const parsed: ParsedRemote | null = parseRemote(url); + if (isGitHubRemote(parsed)) { + return { + owner: parsed.owner, + repo: parsed.repo, + remoteName: remote.name, + entry, + }; + } + } + return null; +} diff --git a/apps/extension/src/pr/reviewDiff.ts b/apps/extension/src/pr/reviewDiff.ts new file mode 100644 index 0000000..8972786 --- /dev/null +++ b/apps/extension/src/pr/reviewDiff.ts @@ -0,0 +1,49 @@ +import * as vscode from "vscode"; +import type { PullRequest, PrFile } from "./githubApi"; +import type { GitHubRepoContext } from "./repoContext"; +import { toPrContentUri } from "./prContentProvider"; + +// Opens a PR's changed file as a side-by-side diff: the base blob (at +// base.sha, the previous filename for renames) on the left, the head blob (at +// head.sha) on the right. The `gitstudio-pr` content provider fetches both via +// the GitHub contents API; added/deleted files resolve to an empty pane on the +// missing side. The right-hand head URI is what the review-mode commenting +// range provider attaches to, so comments map to RIGHT-side lines. + +/** The head-side URI for a PR file (where inline review comments live). */ +export function prHeadUri( + ctx: GitHubRepoContext, + pr: PullRequest, + file: PrFile, +): vscode.Uri { + return toPrContentUri({ + owner: ctx.owner, + repo: ctx.repo, + sha: pr.head.sha, + path: file.filename, + }); +} + +export async function openPrFileDiff( + ctx: GitHubRepoContext, + pr: PullRequest, + file: PrFile, +): Promise { + const basePath = file.previousFilename ?? file.filename; + const left = toPrContentUri({ + owner: ctx.owner, + repo: ctx.repo, + sha: pr.base.sha, + path: basePath, + }); + const right = prHeadUri(ctx, pr, file); + const title = `${baseName(file.filename)} (PR #${pr.number})`; + await vscode.commands.executeCommand("vscode.diff", left, right, title, { + preview: true, + } satisfies vscode.TextDocumentShowOptions); +} + +function baseName(rel: string): string { + const parts = rel.replace(/\\/g, "/").split("/"); + return parts[parts.length - 1] || rel; +} diff --git a/apps/extension/src/pr/reviewMode.ts b/apps/extension/src/pr/reviewMode.ts new file mode 100644 index 0000000..af14e04 --- /dev/null +++ b/apps/extension/src/pr/reviewMode.ts @@ -0,0 +1,348 @@ +import * as vscode from "vscode"; +import { GitHubApi, GitHubApiError, type PullRequest, type PrFile, type ReviewComment, type ReviewEvent } from "./githubApi"; +import type { GitHubAuth } from "./githubAuth"; +import type { GitHubRepoContext } from "./repoContext"; +import { openPrFileDiff } from "./reviewDiff"; +import { PR_SCHEME } from "./prContentProvider"; + +// Review mode (the VS Code Comments API). One CommentController for the whole +// extension drives inline commenting on a PR's changed files. Because the +// Comments API gives us NO way to enumerate the threads it owns, we keep a +// SELF-MANAGED registry of every CommentThread we create, keyed by +// `${path}:${line}` — that registry is the single source of truth for the +// pending draft review. On submit we collect each thread's pending comments +// into the `comments[]` array of one `POST .../reviews` call, then dispose every +// thread and clear the registry. +// +// A "pending" comment is a draft: the user authors it locally, it never hits +// GitHub until they pick Comment / Approve / Request changes. We mark such +// comments with a distinct context value so the thread's "Add to review" action +// can promote a freshly-typed input into the registry. + +/** A pending review comment plus the thread it lives on. */ +interface PendingThread { + thread: vscode.CommentThread; + path: string; + /** 1-based line on the RIGHT (head) side. */ + line: number; +} + +/** Our Comment implementation (the API only specifies the interface). */ +class ReviewComment_ implements vscode.Comment { + contextValue = "gitstudio.prReviewComment"; + constructor( + public body: string | vscode.MarkdownString, + public mode: vscode.CommentMode, + public author: vscode.CommentAuthorInformation, + ) {} +} + +export class ReviewController implements vscode.Disposable { + private readonly controller: vscode.CommentController; + private readonly disposables: vscode.Disposable[] = []; + + /** The PR currently under review, if any. */ + private active: + | { pr: PullRequest; ctx: GitHubRepoContext; files: PrFile[] } + | undefined; + + /** Self-managed thread registry, keyed by `${path}:${line}`. */ + private readonly threads = new Map(); + + private login: string | undefined; + + constructor( + private readonly auth: GitHubAuth, + private readonly api: GitHubApi, + ) { + this.controller = vscode.comments.createCommentController( + "gitstudio.prReview", + "GitStudio PR Review", + ); + // Allow commenting on any line of a head-side PR file once review is active. + this.controller.commentingRangeProvider = { + provideCommentingRanges: (document) => this.commentingRanges(document), + }; + this.disposables.push(this.controller); + } + + private commentingRanges( + document: vscode.TextDocument, + ): vscode.Range[] | undefined { + if (!this.active || document.uri.scheme !== PR_SCHEME) { + return undefined; + } + // Only the head-side blob of a file in this PR is commentable. + const path = headPathOf(document.uri, this.active.pr.head.sha); + if (!path) { + return undefined; + } + const isChanged = this.active.files.some((f) => f.filename === path); + if (!isChanged) { + return undefined; + } + const last = Math.max(document.lineCount - 1, 0); + return [new vscode.Range(0, 0, last, 0)]; + } + + /** True when a review is in progress. */ + isReviewing(): boolean { + return this.active !== undefined; + } + + activePr(): PullRequest | undefined { + return this.active?.pr; + } + + /** + * Enter review mode for a PR: fetch its changed files, open them as diffs, + * enable commenting, and flip the `gitstudio.pr.reviewing` context key. + */ + async startReview( + ctx: GitHubRepoContext, + pr: PullRequest, + ): Promise { + // Re-entering: clear any prior review first. + this.clearThreads(); + + let files: PrFile[]; + try { + files = await this.api.getPullFiles(ctx.owner, ctx.repo, pr.number); + } catch (err) { + void this.warn(err, "Couldn't load the PR's changed files."); + return; + } + this.login = (await this.api.currentLogin())?.login ?? this.auth.accountLabel(); + this.active = { pr, ctx, files }; + await this.setReviewing(true); + + // Open the first few files as diffs so the user lands in the code. + const toOpen = files.slice(0, 5); + for (const f of toOpen) { + try { + await openPrFileDiff(ctx, pr, f); + } catch { + // best-effort + } + } + if (files.length === 0) { + void vscode.window.showInformationMessage( + `PR #${pr.number} has no changed files to review.`, + ); + } else { + void vscode.window.showInformationMessage( + `Reviewing PR #${pr.number}. Click the + in the gutter of a changed file to leave a comment, then Submit Review.`, + ); + } + } + + /** + * Create a pending thread from a brand-new comment input. Called by the + * `gitstudio.pr.addReviewComment` command, wired to the comment-thread input. + */ + addComment(reply: vscode.CommentReply): void { + if (!this.active) { + return; + } + const thread = reply.thread; + const path = headPathOf(thread.uri, this.active.pr.head.sha); + if (!path) { + return; + } + const line = thread.range.start.line + 1; // 1-based for GitHub. + const comment = new ReviewComment_( + new vscode.MarkdownString(reply.text), + vscode.CommentMode.Preview, + { name: this.login ? `@${this.login}` : "You" }, + ); + thread.comments = [...thread.comments, comment]; + thread.label = "Pending review comment"; + thread.contextValue = "gitstudio.prPendingThread"; + thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; + + this.threads.set(`${path}:${line}`, { thread, path, line }); + } + + /** Drop a single pending thread (the "Delete comment" action). */ + removeThread(thread: vscode.CommentThread): void { + for (const [key, pending] of this.threads) { + if (pending.thread === thread) { + this.threads.delete(key); + break; + } + } + thread.dispose(); + } + + /** Count of pending draft comments. */ + pendingCount(): number { + return this.threads.size; + } + + /** + * Submit the pending review: QuickPick Comment / Approve / Request changes, + * optional summary, then one `POST .../reviews` with all collected comments. + */ + async submitReview(): Promise { + if (!this.active) { + void vscode.window.showInformationMessage( + "Start a review first (open a PR and choose Start Review).", + ); + return; + } + const { pr, ctx } = this.active; + + const pick = await vscode.window.showQuickPick( + [ + { label: "$(comment) Comment", event: "COMMENT" as ReviewEvent, detail: "Submit general feedback without explicit approval." }, + { label: "$(check) Approve", event: "APPROVE" as ReviewEvent, detail: "Approve these changes." }, + { label: "$(request-changes) Request changes", event: "REQUEST_CHANGES" as ReviewEvent, detail: "Request changes before merging." }, + ], + { placeHolder: `Submit review for PR #${pr.number} (${this.threads.size} inline comment(s))` }, + ); + if (!pick) { + return; + } + + const summary = await vscode.window.showInputBox({ + prompt: "Review summary (optional)", + placeHolder: "Leave a summary comment…", + }); + // Escape (undefined) cancels; an empty string is a valid no-summary submit. + if (summary === undefined) { + return; + } + + const comments: ReviewComment[] = []; + for (const pending of this.threads.values()) { + const body = pending.thread.comments + .map((c) => mdToString(c.body)) + .filter((t) => t.length > 0) + .join("\n\n"); + if (body.length > 0) { + comments.push({ + path: pending.path, + line: pending.line, + side: "RIGHT", + body, + }); + } + } + + // A COMMENT review with neither a body nor comments is rejected by GitHub. + if (pick.event === "COMMENT" && comments.length === 0 && summary.trim().length === 0) { + void vscode.window.showWarningMessage( + "Add a comment or a summary before submitting a Comment review.", + ); + return; + } + + try { + await this.api.submitReview(ctx.owner, ctx.repo, pr.number, { + event: pick.event, + body: summary, + comments, + }); + } catch (err) { + void this.warn(err, "Couldn't submit the review."); + return; + } + + this.clearThreads(); + await this.setReviewing(false); + this.active = undefined; + void vscode.window.showInformationMessage( + `Review submitted for PR #${pr.number} (${pick.label.replace(/\$\([^)]*\)\s*/, "")}).`, + ); + } + + /** + * A one-off single comment, independent of a draft review: prompt for line + + * body and POST a one-comment COMMENT review. Used by gitstudio.pr.addSingleComment. + */ + async addSingleComment(reply: vscode.CommentReply): Promise { + if (!this.active) { + return; + } + const { pr, ctx } = this.active; + const path = headPathOf(reply.thread.uri, pr.head.sha); + if (!path) { + return; + } + const line = reply.thread.range.start.line + 1; + try { + await this.api.submitReview(ctx.owner, ctx.repo, pr.number, { + event: "COMMENT", + body: "", + comments: [{ path, line, side: "RIGHT", body: reply.text }], + }); + } catch (err) { + void this.warn(err, "Couldn't post the comment."); + return; + } + // Reflect it as a submitted (non-pending) comment on the thread. + const comment = new ReviewComment_( + new vscode.MarkdownString(reply.text), + vscode.CommentMode.Preview, + { name: this.login ? `@${this.login}` : "You" }, + ); + reply.thread.comments = [...reply.thread.comments, comment]; + reply.thread.label = "Comment posted"; + void vscode.window.showInformationMessage("Comment posted to GitHub."); + } + + /** Abandon the in-progress review and clear all pending threads. */ + async cancelReview(): Promise { + this.clearThreads(); + await this.setReviewing(false); + this.active = undefined; + } + + private clearThreads(): void { + for (const pending of this.threads.values()) { + pending.thread.dispose(); + } + this.threads.clear(); + } + + private async setReviewing(value: boolean): Promise { + await vscode.commands.executeCommand( + "setContext", + "gitstudio.pr.reviewing", + value, + ); + } + + private async warn(err: unknown, fallback: string): Promise { + const msg = err instanceof GitHubApiError ? err.message : fallback; + void vscode.window.showWarningMessage(msg); + } + + dispose(): void { + this.clearThreads(); + for (const d of this.disposables) { + d.dispose(); + } + this.disposables.length = 0; + } +} + +/** + * Given a `gitstudio-pr` URI, returns its file path iff it is the HEAD-side blob + * for `headSha` (so we never treat the base/left pane as commentable). + */ +function headPathOf(uri: vscode.Uri, headSha: string): string | undefined { + if (uri.scheme !== PR_SCHEME) { + return undefined; + } + const params = new URLSearchParams(uri.query); + if (params.get("sha") !== headSha) { + return undefined; + } + return uri.path.replace(/^\/+/, ""); +} + +/** Render a Comment body (string | MarkdownString) to plain text. */ +function mdToString(body: string | vscode.MarkdownString): string { + return typeof body === "string" ? body : body.value; +} diff --git a/apps/extension/src/rebase/rebaseCommands.ts b/apps/extension/src/rebase/rebaseCommands.ts new file mode 100644 index 0000000..96e549b --- /dev/null +++ b/apps/extension/src/rebase/rebaseCommands.ts @@ -0,0 +1,170 @@ +import * as vscode from "vscode"; +import type { GitContext } from "@gitstudio/git-service/index"; +import type { RepoManager, RepoEntry } from "../git/repoManager"; +import type { UndoLedger } from "../undo/undoLedger"; + +// Launching & aborting interactive rebases. +// +// Launch mechanism (the GitLens-style approach, simplified for M8): +// We spawn the rebase in an integrated terminal with +// GIT_SEQUENCE_EDITOR='code --wait' so git opens the generated +// `git-rebase-todo` in this VS Code window. Our CustomTextEditorProvider +// (priority "default", filenamePattern "**/git-rebase-todo") then renders it +// as the interactive-rebase webview. When the user presses Start, the editor +// writes the reordered todo and saves; `code --wait` returns and git replays +// the plan. We wrap the launch in runWithUndo so the pre-rebase HEAD is one +// keystroke from restorable, and surface conflicts (the existing auto-open +// routes conflicted files into the merge editor). +// +// We use a terminal rather than a spawned child because `code --wait` must be +// able to talk back to *this* window, and a terminal inherits the user's PATH +// where the `code` CLI lives. + +/** + * `gitstudio.startInteractiveRebase` — start `git rebase -i ` where the + * base defaults to the parent of `sha` (rebase the commit and everything after + * it). When called without a sha (palette), prompt for an upstream ref. + */ +export async function startInteractiveRebase( + repos: RepoManager, + undo: UndoLedger, + sha?: string, +): Promise { + const active = repos.getActive(); + if (!active) { + void vscode.window.showInformationMessage("No active repository."); + return; + } + + if (await isRebaseInProgress(active.ctx)) { + void vscode.window.showWarningMessage( + "A rebase is already in progress. Continue or abort it first.", + ); + return; + } + + if (await isDirty(active.ctx)) { + const proceed = await vscode.window.showWarningMessage( + "You have uncommitted changes. Interactive rebase works best on a clean " + + "tree — commit or stash first. GitStudio will snapshot your work so you " + + "can Undo, but git may refuse to start.", + { modal: true }, + "Continue Anyway", + ); + if (proceed !== "Continue Anyway") { + return; + } + } + + const base = await resolveBase(active, sha); + if (!base) { + return; + } + + // Snapshot before launching so Undo can restore the pre-rebase state. The + // terminal launch is fire-and-forget (we can't await the terminal), so we + // record the snapshot immediately; the user's Undo resets HEAD to it. + await undo.runWithUndo(active, `Interactive rebase onto ${short(base)}`, async () => { + launchRebaseTerminal(active, base); + }); +} + +/** `gitstudio.abortRebase` — `git rebase --abort`. */ +export async function abortRebase(repos: RepoManager): Promise { + const active = repos.getActive(); + if (!active) { + void vscode.window.showInformationMessage("No active repository."); + return; + } + const result = await active.ctx.process.run(["rebase", "--abort"]); + if (result.code === 0) { + void vscode.window.setStatusBarMessage( + "$(discard) Rebase aborted", + 2500, + ); + } else { + const stderr = result.stderr.trim(); + void vscode.window.showErrorMessage( + stderr ? `Abort rebase failed: ${stderr}` : "No rebase in progress.", + ); + } +} + +// ── Internals ──────────────────────────────────────────────────────────────── + +/** + * Resolve the rebase base. With a sha, default to `^` (its parent) so the + * commit itself is included in the todo; for a root commit (no parent) use + * `--root`. Without a sha, prompt for an upstream ref. + */ +async function resolveBase( + active: RepoEntry, + sha?: string, +): Promise { + if (!sha) { + const ref = await vscode.window.showInputBox({ + title: "Interactive rebase", + prompt: "Rebase onto which commit/branch? (the base, exclusive)", + placeHolder: "e.g. HEAD~5, main, origin/main", + }); + return ref?.trim() || undefined; + } + // Does the commit have a parent? + const parent = await active.ctx.process.run([ + "rev-parse", + "--verify", + "--quiet", + `${sha}^`, + ]); + if (parent.code === 0) { + return `${sha}^`; + } + // Root commit — rebase --root rewrites the whole history including it. + return "--root"; +} + +function launchRebaseTerminal(active: RepoEntry, base: string): void { + const terminal = vscode.window.createTerminal({ + name: "GitStudio: Interactive Rebase", + cwd: active.root, + env: { + // `code --wait` opens the todo in this window and blocks until it's + // closed; our customEditor (priority default) renders it. + GIT_SEQUENCE_EDITOR: "code --wait", + // Keep the commit-message editor sane too (reword/squash), so it doesn't + // fall back to vi inside the terminal. + GIT_EDITOR: "code --wait", + }, + }); + const baseArg = base === "--root" ? "--root" : base; + terminal.show(true); + // -i forces the sequence editor; the trailing message nudges the user. + terminal.sendText(`git rebase -i ${baseArg}`, true); +} + +async function isRebaseInProgress(ctx: GitContext): Promise { + // rebase-merge (interactive) or rebase-apply (am) dir present under .git. + const result = await ctx.process.run([ + "rev-parse", + "--git-path", + "rebase-merge", + ]); + if (result.code !== 0) { + return false; + } + // `git rev-parse --git-path` prints the path whether or not it exists; test + // existence via `status` instead (cheap and robust). + const status = await ctx.process.run(["status"]); + return /rebase in progress|interactive rebase in progress/i.test( + status.stdout, + ); +} + +async function isDirty(ctx: GitContext): Promise { + const result = await ctx.process.run(["status", "--porcelain"]); + return result.stdout.trim().length > 0; +} + +function short(ref: string): string { + return ref.length === 40 ? ref.slice(0, 7) : ref; +} diff --git a/apps/extension/src/rebase/rebaseHtml.ts b/apps/extension/src/rebase/rebaseHtml.ts new file mode 100644 index 0000000..f266933 --- /dev/null +++ b/apps/extension/src/rebase/rebaseHtml.ts @@ -0,0 +1,54 @@ +import * as vscode from "vscode"; + +/** + * Builds the interactive-rebase webview HTML with a locked-down CSP and the + * bundled rebase entry + its stylesheet. Mirrors the graph webview's pattern: + * a per-load nonce gates inline + bundled scripts, cspSource scopes the bundled + * assets, and Lit injects component styles into shadow roots at runtime. + * + * Never hardcode the URI scheme returned by asWebviewUri — it is opaque. + */ +export function getRebaseHtml( + webview: vscode.Webview, + extensionUri: vscode.Uri, + nonce: string, +): string { + const dist = (...parts: string[]) => + webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, "dist", ...parts)); + + const scriptUri = dist("webview", "rebase.js"); + const styleUri = dist("webview", "rebase.css"); + + const csp = [ + `default-src 'none'`, + `img-src ${webview.cspSource} https: data:`, + `style-src ${webview.cspSource} 'unsafe-inline'`, + `font-src ${webview.cspSource} data:`, + `script-src 'nonce-${nonce}' ${webview.cspSource}`, + ].join("; "); + + return ` + + + + + + + Interactive Rebase + + +
Loading rebase plan…
+ + +`; +} + +export function getNonce(): string { + let text = ""; + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +} diff --git a/apps/extension/src/rebase/rebaseTodoEditor.ts b/apps/extension/src/rebase/rebaseTodoEditor.ts new file mode 100644 index 0000000..d3b4a0f --- /dev/null +++ b/apps/extension/src/rebase/rebaseTodoEditor.ts @@ -0,0 +1,266 @@ +import * as vscode from "vscode"; +import { + parseRebaseTodo, + serializeRebaseTodo, + detectEol, + hasTrailingNewline, + summarizeRebaseTodo, + type RebaseCommitEntry, + type RebaseLine, +} from "@gitstudio/engine/rebase/todo"; +import type { + RebaseHostMessage, + RebaseWebviewMessage, + WireRebaseRow, +} from "@gitstudio/host-bridge/rebaseProtocol"; +import { getRebaseHtml, getNonce } from "./rebaseHtml"; + +// A CustomTextEditorProvider that renders any `git-rebase-todo` document as the +// interactive-rebase webview. Registered with priority "default" and the +// filename pattern "**/git-rebase-todo", so it captures the file a terminal +// `git rebase -i` (with GIT_SEQUENCE_EDITOR='code --wait') opens. +// +// On resolve we parse the todo with the (pure) engine and stream the commit +// rows to the webview. "Start" reorders/retypes the parsed lines, serializes +// them back with the engine (so the comment block + unmodeled directives are +// preserved byte-for-byte) and replaces the document text + saves — which lets +// the underlying `git rebase` proceed. "Abort" routes to gitstudio.abortRebase. +// +// Echo-loop guard: we hash the exact text WE write and ignore the resulting +// onDidChangeTextDocument so our own write doesn't re-trigger a parse/render. +export class RebaseTodoEditorProvider + implements vscode.CustomTextEditorProvider +{ + public static readonly viewType = "gitstudio.rebaseTodoEditor"; + + static register(context: vscode.ExtensionContext): vscode.Disposable { + const provider = new RebaseTodoEditorProvider(context); + return vscode.window.registerCustomEditorProvider( + RebaseTodoEditorProvider.viewType, + provider, + { + webviewOptions: { retainContextWhenHidden: true }, + supportsMultipleEditorsPerDocument: false, + }, + ); + } + + /** Text we wrote ourselves, to skip the echoed change event. */ + private readonly selfWrites = new Set(); + + private constructor(private readonly context: vscode.ExtensionContext) {} + + resolveCustomTextEditor( + document: vscode.TextDocument, + webviewPanel: vscode.WebviewPanel, + _token: vscode.CancellationToken, + ): void { + const webview = webviewPanel.webview; + webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.context.extensionUri, "dist"), + ], + }; + const nonce = getNonce(); + webview.html = getRebaseHtml(webview, this.context.extensionUri, nonce); + + const pushInit = () => { + const text = document.getText(); + const lines = parseRebaseTodo(text); + const summary = summarizeRebaseTodo(lines); + const rows = toRows(lines); + const message: RebaseHostMessage = { + type: "rebaseInit", + headerComment: summary.headerComment, + rows, + }; + void webview.postMessage(message); + }; + + const changeSub = vscode.workspace.onDidChangeTextDocument((e) => { + if (e.document.uri.toString() !== document.uri.toString()) { + return; + } + const text = e.document.getText(); + if (this.selfWrites.has(text)) { + // Our own edit echoing back — ignore it. + this.selfWrites.delete(text); + return; + } + pushInit(); + }); + + const messageSub = webview.onDidReceiveMessage( + (msg: RebaseWebviewMessage) => { + switch (msg.type) { + case "ready": + pushInit(); + break; + case "start": + void this.applyAndSave(document, msg.rows); + break; + case "abort": + void this.abort(document); + break; + } + }, + ); + + webviewPanel.onDidDispose(() => { + changeSub.dispose(); + messageSub.dispose(); + }); + } + + /** + * Reorder + retype the parsed todo per the webview's row list, serialize via + * the engine (preserving the comment block + unmodeled lines), write it into + * the document and save so `git rebase` continues. + */ + private async applyAndSave( + document: vscode.TextDocument, + orderedRows: Array<{ id: number; action: WireRebaseRow["action"] }>, + ): Promise { + const original = document.getText(); + const lines = parseRebaseTodo(original); + const newLines = applyRowOrder(lines, orderedRows); + + const eol = detectEol(original); + const trailingNewline = hasTrailingNewline(original); + const newText = serializeRebaseTodo(newLines, { eol, trailingNewline }); + + if (newText === original) { + // Nothing changed — just save so git proceeds with the original plan. + await document.save(); + return; + } + + // Mark this exact text as a self-write before applying it. + this.selfWrites.add(newText); + + const edit = new vscode.WorkspaceEdit(); + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(original.length), + ); + edit.replace(document.uri, fullRange, newText); + const ok = await vscode.workspace.applyEdit(edit); + if (!ok) { + this.selfWrites.delete(newText); + void vscode.window.showErrorMessage( + "GitStudio could not write the rebase plan.", + ); + return; + } + await document.save(); + flash("Rebase plan applied"); + } + + /** + * A clean abort: rather than corrupt the todo, we save the file unchanged and + * point the user at the Abort Rebase command (which runs `git rebase + * --abort`). This avoids the risk of git interpreting a half-cleared file. + */ + private async abort(document: vscode.TextDocument): Promise { + const choice = await vscode.window.showWarningMessage( + "Abort this interactive rebase? No commits will be changed.", + { modal: true }, + "Abort Rebase", + ); + if (choice !== "Abort Rebase") { + return; + } + // Clear the todo to a no-op so the in-flight `git rebase -i` exits cleanly + // without replaying anything, then run --abort to unwind to the start. + const original = document.getText(); + const eol = detectEol(original); + const cleared = `noop${eol}`; + this.selfWrites.add(cleared); + const edit = new vscode.WorkspaceEdit(); + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(original.length), + ); + edit.replace(document.uri, fullRange, cleared); + await vscode.workspace.applyEdit(edit); + await document.save(); + await vscode.commands.executeCommand("gitstudio.abortRebase"); + } +} + +// ── Pure-ish helpers (no vscode) ───────────────────────────────────────────── + +/** Map parsed commit entries to wire rows (index === the row id). */ +function toRows(lines: RebaseLine[]): WireRebaseRow[] { + const rows: WireRebaseRow[] = []; + lines.forEach((line, index) => { + if (line.kind === "commit") { + rows.push({ + id: index, + action: line.action, + sha: line.sha, + shortSha: line.sha.slice(0, 7), + subject: line.subject, + }); + } + }); + return rows; +} + +/** + * Rebuild the line list so the commit slots are filled in the webview's order, + * each carrying its (possibly retyped) action. Passthrough lines stay pinned to + * their positions. Rows the user removed entirely become a `drop` of the + * original entry (we never silently lose a commit). Order is the array order + * the webview sent. + */ +function applyRowOrder( + lines: RebaseLine[], + orderedRows: Array<{ id: number; action: WireRebaseRow["action"] }>, +): RebaseLine[] { + // Original commit entries keyed by their line index (the row id). + const byId = new Map(); + const commitSlots: number[] = []; + lines.forEach((line, index) => { + if (line.kind === "commit") { + byId.set(index, line); + commitSlots.push(index); + } + }); + + // Build the new ordered commit entries from the webview's list. + const ordered: RebaseCommitEntry[] = []; + const seen = new Set(); + for (const row of orderedRows) { + const original = byId.get(row.id); + if (!original) { + continue; + } + seen.add(row.id); + ordered.push({ ...original, action: row.action }); + } + // Any commit the webview didn't mention (defensive) is preserved as a drop so + // we never silently lose it — though the UI always sends every row. + for (const [id, entry] of byId) { + if (!seen.has(id)) { + ordered.push({ ...entry, action: "drop" }); + } + } + + // Refill the commit slots in order; pad with extra drops if needed. + const result = lines.slice(); + ordered.forEach((entry, i) => { + if (i < commitSlots.length) { + result[commitSlots[i]] = entry; + } else { + // More entries than slots can't happen (we only ever reorder), but guard. + result.push(entry); + } + }); + return result; +} + +function flash(message: string): void { + void vscode.window.setStatusBarMessage(`$(check) ${message}`, 2500); +} diff --git a/apps/extension/src/statusBar/syncStatus.ts b/apps/extension/src/statusBar/syncStatus.ts new file mode 100644 index 0000000..41b4160 --- /dev/null +++ b/apps/extension/src/statusBar/syncStatus.ts @@ -0,0 +1,283 @@ +import * as vscode from "vscode"; +import type { RepoManager, RepoEntry } from "../git/repoManager"; + +// A compact left status-bar segment for the active repo's sync state: +// $(git-branch) $(arrow-down) $(arrow-up) +// Clicking opens a QuickPick of Sync / Push / Pull / Fetch / Publish. Updated +// (debounced) on RepoManager.onDidChange; hidden when no repo is open. Coexists +// with built-in git's own item by staying terse and in its own segment. + +const UPDATE_DEBOUNCE_MS = 500; +const COMMAND_ID = "gitstudio.syncStatus.menu"; + +export class SyncStatusItem implements vscode.Disposable { + private readonly item: vscode.StatusBarItem; + private readonly disposables: vscode.Disposable[] = []; + private timer: ReturnType | undefined; + private updateToken = 0; + + constructor(private readonly repos: RepoManager) { + this.item = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + // A small negative priority keeps us just to the right of vscode.git's + // own SCM segment rather than fighting it for the leftmost slot. + -5, + ); + this.item.command = COMMAND_ID; + + this.disposables.push( + this.item, + vscode.commands.registerCommand(COMMAND_ID, () => this.showMenu()), + this.repos.onDidChange(() => this.scheduleUpdate()), + ); + + this.scheduleUpdate(); + } + + private scheduleUpdate(): void { + if (this.timer !== undefined) { + clearTimeout(this.timer); + } + this.timer = setTimeout(() => { + this.timer = undefined; + void this.update(); + }, UPDATE_DEBOUNCE_MS); + } + + private async update(): Promise { + const token = ++this.updateToken; + const active = this.repos.getActive(); + if (!active) { + this.item.hide(); + return; + } + try { + const head = await active.ctx.refs.getHead(); + const branch = head.detached + ? `${head.sha.slice(0, 7)} (detached)` + : head.branch ?? `${head.sha.slice(0, 7)} (detached)`; + const upstream = await active.ctx.sync.currentUpstream(); + const counts = await active.ctx.sync.aheadBehind(); + + if (token !== this.updateToken) { + return; // a newer update superseded this one + } + + const parts = [`$(git-branch) ${branch}`]; + if (upstream) { + if (counts.behind > 0) { + parts.push(`$(arrow-down)${counts.behind}`); + } + if (counts.ahead > 0) { + parts.push(`$(arrow-up)${counts.ahead}`); + } + } else { + parts.push("$(cloud-upload)"); + } + this.item.text = parts.join(" "); + this.item.tooltip = buildTooltip(branch, upstream, counts); + this.item.show(); + } catch { + if (token === this.updateToken) { + this.item.hide(); + } + } + } + + private async showMenu(): Promise { + const active = this.repos.getActive(); + if (!active) { + return; + } + const upstream = await active.ctx.sync.currentUpstream(); + const items: Array = []; + if (upstream) { + items.push( + { id: "sync", label: "$(sync) Sync", description: "pull, then push" }, + { id: "pull", label: "$(arrow-down) Pull" }, + { id: "push", label: "$(arrow-up) Push" }, + ); + } else { + items.push({ + id: "publish", + label: "$(cloud-upload) Publish Branch", + description: "push --set-upstream", + }); + } + items.push({ id: "fetch", label: "$(repo-fetch) Fetch" }); + + const picked = await vscode.window.showQuickPick(items, { + title: "GitStudio Sync", + placeHolder: upstream ? `Upstream: ${upstream}` : "No upstream set", + }); + if (!picked) { + return; + } + await this.runAction(active, picked.id); + this.scheduleUpdate(); + } + + private async runAction(active: RepoEntry, id: string): Promise { + switch (id) { + case "sync": { + const pull = await active.ctx.sync.pull(); + if (!pull.ok) { + reportSync(pull, "Pull"); + return; + } + reportSync(await active.ctx.sync.push(), "Push", "Synced"); + break; + } + case "pull": { + const rebase = await this.askRebase(); + if (rebase === undefined) { + return; + } + reportSync(await active.ctx.sync.pull({ rebase }), "Pull", "Pulled"); + break; + } + case "push": { + const force = await this.askForce(); + if (force === undefined) { + return; + } + reportSync(await active.ctx.sync.push({ force }), "Push", "Pushed"); + break; + } + case "publish": { + const branch = await this.currentBranch(active); + const remote = await this.pickRemote(active); + if (!branch || !remote) { + return; + } + reportSync( + await active.ctx.sync.push({ remote, branch, setUpstream: true }), + "Publish", + `Published ${branch}`, + ); + break; + } + case "fetch": + reportSync( + await active.ctx.sync.fetch({ prune: true }), + "Fetch", + "Fetched", + ); + break; + default: + break; + } + } + + private async askRebase(): Promise { + const choice = await vscode.window.showQuickPick( + [ + { label: "$(arrow-down) Merge", value: false }, + { label: "$(git-merge) Rebase", value: true }, + ], + { title: "Pull strategy", placeHolder: "Merge or rebase local commits?" }, + ); + return choice?.value; + } + + private async askForce(): Promise { + const forceDefault = vscode.workspace + .getConfiguration("gitstudio") + .get("push.forceWithLease", true); + const choice = await vscode.window.showQuickPick( + [ + { label: "$(arrow-up) Push", description: "normal push", value: false }, + { + label: "$(warning) Force push (with lease)", + description: forceDefault ? "--force-with-lease" : "", + value: true, + }, + ], + { title: "Push", placeHolder: "Push or force-push?" }, + ); + return choice?.value; + } + + private async currentBranch(active: RepoEntry): Promise { + const head = await active.ctx.refs.getHead(); + if (head.detached) { + void vscode.window.showInformationMessage( + "GitStudio: cannot publish a detached HEAD — check out a branch first.", + ); + return undefined; + } + return head.branch; + } + + private async pickRemote(active: RepoEntry): Promise { + const remotes = await active.ctx.remotes.list(); + if (remotes.length === 0) { + void vscode.window.showInformationMessage( + "GitStudio: no remotes configured.", + ); + return undefined; + } + if (remotes.length === 1) { + return remotes[0].name; + } + const picked = await vscode.window.showQuickPick( + remotes.map((r) => ({ label: `$(cloud) ${r.name}`, name: r.name })), + { title: "Publish to which remote?" }, + ); + return picked?.name; + } + + dispose(): void { + if (this.timer !== undefined) { + clearTimeout(this.timer); + this.timer = undefined; + } + for (const d of this.disposables) { + d.dispose(); + } + this.disposables.length = 0; + } +} + +function buildTooltip( + branch: string, + upstream: string | null, + counts: { ahead: number; behind: number }, +): vscode.MarkdownString { + const md = new vscode.MarkdownString(undefined, true); + md.supportThemeIcons = true; + md.appendMarkdown(`$(git-branch) **${branch}**\n\n`); + if (upstream) { + md.appendMarkdown(`$(cloud) Upstream: \`${upstream}\`\n\n`); + md.appendMarkdown( + `$(arrow-down) ${counts.behind} behind · $(arrow-up) ${counts.ahead} ahead`, + ); + } else { + md.appendMarkdown("No upstream — click to publish."); + } + return md; +} + +function reportSync( + result: { ok: boolean; stderr: string }, + verb: string, + success?: string, +): void { + if (result.ok) { + void vscode.window.setStatusBarMessage( + `$(check) ${success ?? `${verb} done`}`, + 2500, + ); + return; + } + const stderr = result.stderr.trim(); + if (/conflict/i.test(stderr)) { + void vscode.window.showWarningMessage( + `${verb} hit conflicts. Resolve them, then continue.`, + ); + } else { + void vscode.window.showErrorMessage( + stderr ? `${verb} failed: ${stderr}` : `${verb} failed`, + ); + } +} diff --git a/apps/extension/src/undo/undoLedger.ts b/apps/extension/src/undo/undoLedger.ts new file mode 100644 index 0000000..2a5a88f --- /dev/null +++ b/apps/extension/src/undo/undoLedger.ts @@ -0,0 +1,340 @@ +import * as vscode from "vscode"; +import type { GitContext, Snapshot } from "@gitstudio/git-service/index"; +import type { RepoManager, RepoEntry } from "../git/repoManager"; +import { relativeTime } from "../util/relativeTime"; + +// The universal Undo envelope — GitStudio's flagship trust feature. +// +// Every destructive operation is wrapped by runWithUndo(): we snapshot the +// repo (HEAD + any dirty work) BEFORE the op, run it, then push a ledger entry +// and surface a subtle "Undid?