diff --git a/apps/desktop/build/icon-mac.png b/apps/desktop/build/icon-mac.png new file mode 100644 index 0000000..93d6268 Binary files /dev/null and b/apps/desktop/build/icon-mac.png differ diff --git a/apps/desktop/build/icon.png b/apps/desktop/build/icon.png index ad165e8..b434cef 100644 Binary files a/apps/desktop/build/icon.png and b/apps/desktop/build/icon.png differ diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index bdf463a..2c9320f 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -40,6 +40,10 @@ publish: # converts it to .icns / .ico per platform automatically. mac: category: public.app-category.developer-tools + # macOS needs the padded variant (824px art in a 1024 canvas, Apple's icon + # grid). The full-bleed build/icon.png used for win/linux gets scaled + # edge-to-edge into the dock slot and renders bigger than every other app. + icon: build/icon-mac.png # dmg is the human download; zip is REQUIRED for electron-updater to deliver # macOS auto-updates (it can't update from a .dmg). target: diff --git a/apps/desktop/esbuild.js b/apps/desktop/esbuild.js index 8cd70a1..f4ae707 100644 --- a/apps/desktop/esbuild.js +++ b/apps/desktop/esbuild.js @@ -95,10 +95,12 @@ function copyStaticAssets() { // 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", + // Both tiles are macOS-padded (824px art inset in a 1024 canvas, per Apple's + // icon grid) — a full-bleed square is scaled edge-to-edge into the dock slot + // and renders visibly bigger than every neighbouring app. macOS can't carry + // appearance variants in an .icns, so main swaps these at runtime. + "brand/gitstudio-icon-mac-1024.png": "icon.png", + "brand/gitstudio-icon-light-mac-1024.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", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3a4c66c..736de71 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "gitstudio-desktop", - "version": "1.0.0", + "version": "1.0.1", "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.", diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index f7cf5ad..cef22f7 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -173,19 +173,34 @@ async function createWindow(): Promise { 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. */ +/** + * The dock/window mark for an appearance. + * + * macOS cannot carry light/dark variants inside an `.icns`, so an Electron app + * has to adapt the dock icon itself (`app.dock.setIcon`) — which only works if + * BOTH tiles are properly designed. The light tile used to be the dark mark's + * artwork dropped onto a pale background unchanged: its light-violet lanes and + * near-white node cores disappeared, and the nodes that straddle the cube's + * edge simply vanished. It is now re-tuned (deep brand-violet lanes and rings, + * white cores) so it reads on the pale tile. + * + * Both files are the macOS-padded variant (824px art inset in a 1024 canvas per + * Apple's icon grid) — a full-bleed square gets scaled edge-to-edge into the + * dock slot and renders visibly bigger than every neighbouring app. + */ function iconPath(variant: "dark" | "light"): string { - return join(__dirname, variant === "light" ? "../renderer/icon-light.png" : "../renderer/icon.png"); + 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. */ +/** Window/dev icon for the current OS appearance. */ function appIcon(): string { return iconPath(nativeTheme.shouldUseDarkColors ? "dark" : "light"); } -/** Swap the macOS dock icon to the given brand variant (best-effort). */ +/** Swap the macOS dock icon to the given brand tile (best-effort). */ function setDockIcon(variant: "dark" | "light"): void { try { app.dock?.setIcon(iconPath(variant)); diff --git a/apps/desktop/src/renderer/icon.png b/apps/desktop/src/renderer/icon.png new file mode 100644 index 0000000..4dd22fc Binary files /dev/null and b/apps/desktop/src/renderer/icon.png differ diff --git a/apps/desktop/src/renderer/styles/app.css b/apps/desktop/src/renderer/styles/app.css index 0eeef44..a1d1f01 100644 --- a/apps/desktop/src/renderer/styles/app.css +++ b/apps/desktop/src/renderer/styles/app.css @@ -578,9 +578,13 @@ html.is-mac .topbar { padding-left: 78px; } * themes); the merge-Y lanes + nodes are the violet accent (legible on both). */ .topbar-mark { width: 26px; height: 26px; display: block; color: var(--vscode-foreground); } .topbar-mark svg { display: block; } -.topbar-mark .bm-cube { stroke: currentColor; stroke-opacity: 0.42; } -.topbar-mark .bm-lane { stroke: var(--gs-accent-ink, var(--gs-accent)); } -.topbar-mark .bm-node { fill: var(--gs-accent-ink, var(--gs-accent)); } +/* The mark is painted in the wordmark's violet→magenta gradient, so the glyph + and the logotype read as one brand. Faces carry the same gradient at low + opacity — depth from opacity, never a second hue. */ +.topbar-mark .bm-face { fill: url(#bm-grad); } +.topbar-mark .bm-cube { stroke: url(#bm-grad); stroke-opacity: 0.55; } +.topbar-mark .bm-lane { stroke: url(#bm-grad); } +.topbar-mark .bm-node { fill: url(#bm-grad); } .topbar-switch { display: inline-flex; align-items: center; diff --git a/apps/desktop/src/renderer/ui.ts b/apps/desktop/src/renderer/ui.ts index e63d790..d2c4494 100644 --- a/apps/desktop/src/renderer/ui.ts +++ b/apps/desktop/src/renderer/ui.ts @@ -430,16 +430,30 @@ export function isBenignError(message: string, source?: string): boolean { return false; } -/** The GitStudio brand mark, inline so it tracks the theme with no asset swap. - * The merge-Y lanes terminate in ringed nodes: each node — the three ends and - * the centre — is punched with a real hole (an SVG mask cuts through both the - * node and the lane beneath, so the bar background shows through on any theme), - * giving the lines that "open eyelet" look at every tip, not just the centre. */ +/** + * The GitStudio brand mark, inline so it tracks the theme with no asset swap. + * + * This is the activity-bar glyph's recipe, in colour: three translucent + * isometric FACES give the cube a real 3D body (the flat silhouette-only mark + * it replaced read as a wireframe), a soft silhouette closes it, and the + * merge-Y — two branches converging into one commit, then down — is the bold + * signature on top. Every node (the three tips and the centre) is punched with + * a real hole by an SVG mask that cuts through the node AND the lane beneath, + * so the bar shows through: the "open eyelet" look, on any theme. + * + * The paint is the wordmark's violet→magenta gradient, so the mark and the + * logotype are visibly the same brand. + */ export function brandMark(): HTMLElement { const s = el("span", "topbar-mark"); s.innerHTML = '"; return s; diff --git a/apps/extension/.vscodeignore b/apps/extension/.vscodeignore index 55d7c77..e47f433 100644 --- a/apps/extension/.vscodeignore +++ b/apps/extension/.vscodeignore @@ -14,6 +14,7 @@ tsconfig.json *.vsix media/*.svg !media/activitybar.svg -# Store screenshots are served from GitHub (vsce rewrites relative README image -# links to raw.githubusercontent.com) — keep them out of the vsix. -media/shots/** +# media/shots/** MUST ship: the walkthrough renders these as step media, and +# walkthrough images are loaded from INSIDE the extension (unlike README images, +# which vsce rewrites to absolute GitHub URLs). Excluding them = a walkthrough +# of broken image boxes. diff --git a/apps/extension/CHANGELOG.md b/apps/extension/CHANGELOG.md index 1d0bf06..cd5c0a5 100644 --- a/apps/extension/CHANGELOG.md +++ b/apps/extension/CHANGELOG.md @@ -4,6 +4,44 @@ All notable changes to **GitStudio** are documented here. This project adheres t [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.1] - 2026-07-14 + +The 1.0.0 walkthrough advertised features that silently did nothing. This is the +repair release. + +### Fixed +- **Pull no longer demands a rebase.** A plain `git pull` ran with no + reconciliation strategy; since git 2.34 that is a hard error on divergent + branches (*"Need to specify how to reconcile divergent branches"*), which we + surfaced verbatim. Because the pull aborted, no merge was attempted — so + conflicts, and the 3-pane merge editor, never appeared either. +- **The 3-pane merge editor opens.** Clicking a conflicted file in the Changes + view used to open a plain 2-way diff; the merge editor was only reachable from + VS Code's built-in SCM view. +- **Inline blame no longer stacks** on top of VS Code's built-in blame or + GitLens. The duplicate check tested a setting's *default* instead of its + effective value, so the one annotation that actually overlapped was never + detected — while the only branch that fired silently rewrote your global + settings. GitStudio now asks, and never edits another extension's settings on + its own. +- **File & line history work.** Per-file history had no working UI at all (it + was routed through a *proposed* API that threw at activation). Added + **GitStudio: Show File History**. Line history no longer re-opens its picker on + top of the diff it just opened, and history/blame commands no longer no-op + silently when there is no active editor. +- **Staging is visible and honest.** The Staged group no longer disappears when + empty; committing with nothing staged offers *Stage All & Commit* instead of + failing with "unknown error"; rows no longer snap back between groups; Discard + reads as destructive; and a conflicted file's action says **Mark as Resolved**. +- **The walkthrough works.** Every button now runs a command that does something + from the walkthrough, the AI step points at the real Connect AI panel + (zero-key Copilot/Cursor, OpenAI-compatible, or a local agent CLI), and the + merge step no longer starts a destructive interactive rebase. +- **History, blame, and merge work on Windows** — a path helper never matched a + Windows `\` path, so those features silently did nothing there. +- **The macOS dock icon** is no longer oversized (it now carries Apple's icon + margin) and no longer swaps to a white tile that washed the mark out. + ## [1.0.0] - 2026-07-14 The first stable release: the whole extension loads **instantly**, the commit diff --git a/apps/extension/media/icon.png b/apps/extension/media/icon.png index ad165e8..794950e 100644 Binary files a/apps/extension/media/icon.png and b/apps/extension/media/icon.png differ diff --git a/apps/extension/package.json b/apps/extension/package.json index 3cb62bb..c2a5933 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -2,7 +2,7 @@ "name": "gitstudio", "displayName": "GitStudio", "description": "A free, open-source, JetBrains-grade Git suite for VS Code & Cursor — live commit graph, inline blame, hunk staging, 3-pane merge, interactive rebase with universal Undo, and in-editor GitHub PRs.", - "version": "1.0.0", + "version": "1.0.1", "publisher": "gitstudio", "license": "Apache-2.0", "icon": "media/icon.png", @@ -201,6 +201,12 @@ "category": "GitStudio", "icon": "$(git-commit)" }, + { + "command": "gitstudio.showFileHistory", + "title": "Show File History", + "category": "GitStudio", + "icon": "$(history)" + }, { "command": "gitstudio.showLineHistory", "title": "Show Line History", @@ -693,14 +699,16 @@ "copilot", "anthropic", "openai", - "off" + "off", + "cli" ], "enumDescriptions": [ "Prefer the zero-key VS Code Language Model (GitHub Copilot / Cursor) when available; otherwise Anthropic (if a key is set), then an OpenAI-compatible endpoint (if a model is configured).", "Use the VS Code Language Model API — GitHub Copilot or Cursor's models, with no key. Pick a specific model via “GitStudio: Select AI Model…”.", "Use Anthropic directly (requires an API key set via GitStudio: Set AI API Key).", "Use any OpenAI-compatible endpoint — OpenAI, Codex, OpenRouter, or a local server (Ollama / LM Studio). Configure the base URL and model below; a key is optional for local servers.", - "Disable all GitBrain AI features." + "Disable all GitBrain AI features.", + "Drive a local agent CLI (Claude Code, Codex, or Gemini CLI) using its own login — no API key." ], "default": "auto", "markdownDescription": "Which provider GitBrain uses for optional AI features. AI is off until a provider is available; it never gates or breaks Git operations. Use **GitStudio: Select AI Model…** for a guided picker." @@ -809,13 +817,13 @@ "steps": [ { "id": "gitstudio.walkthrough.graph", - "title": "Browse your history in the commit graph", - "description": "Your history as a live, virtualized commit graph — right in the **Commits** view in the sidebar, or full-screen in an editor tab. Colored lanes, ref chips and authorship (never a flat list); right-click any commit to checkout, cherry-pick, branch, or reset, and compare any two branches.\n[Open Commit Graph](command:gitstudio.showCommitGraph)\nTip: the graph lives in the **Commits** view at the top of the GitStudio sidebar — no need to leave it.", + "title": "See your whole history", + "description": "A real commit graph: colored branch lanes, ref chips, author avatars on the nodes — virtualized, so it stays fast at tens of thousands of commits. Check out, cherry-pick, branch, tag or reset from any commit.\n[Show Commit Graph](command:gitstudio.showCommitGraph)", "media": { "image": { - "light": "media/wordmark-light.png", - "dark": "media/wordmark-dark.png", - "hc": "media/wordmark-dark.png" + "light": "media/shots/graph-panel.png", + "dark": "media/shots/graph-panel.png", + "hc": "media/shots/graph-panel.png" }, "altText": "GitStudio" }, @@ -824,42 +832,59 @@ ] }, { - "id": "gitstudio.walkthrough.blame", - "title": "Turn on inline blame", - "description": "Ambient, native-feeling authorship: who last changed the current line and when, inline and in the status bar. Toggle full-file annotations with a code-age heatmap.\n[Toggle File Blame](command:gitstudio.toggleFileBlame)\nShortcut: __Ctrl/Cmd+Alt+G B__ in any editor.", + "id": "gitstudio.walkthrough.sidebar", + "title": "The sidebar is the workflow", + "description": "Open GitStudio in the Activity Bar and it reads top-to-bottom: __Changes__ (commit box + working tree) → __Commits__ (a sidebar-native log with the branch topology) → __Stashes__ → __Worktrees__ → __Pull Requests__.\n[Open GitStudio](command:workbench.view.extension.gitstudio)", "media": { "image": { - "light": "media/wordmark-light.png", - "dark": "media/wordmark-dark.png", - "hc": "media/wordmark-dark.png" + "light": "media/shots/commits-rail.png", + "dark": "media/shots/commits-rail.png", + "hc": "media/shots/commits-rail.png" }, "altText": "GitStudio" }, "completionEvents": [ - "onCommand:gitstudio.toggleFileBlame" + "onView:gitstudio.commits" ] }, { "id": "gitstudio.walkthrough.stage", - "title": "Stage a hunk & commit", - "description": "Stage by hunk or even by line, then commit from the GitStudio commit box (Amend, Sign-off, author override, Commit & Push). When AI is on, the ✨ button drafts a message from your staged diff.\n[Open the Changes view](command:workbench.view.extension.gitstudio)\nShortcut: select lines and press __Ctrl/Cmd+Alt+G S__ to stage them.", + "title": "Stage exactly what you mean", + "description": "Files move the moment you click — the git call reconciles behind you. Stage by file, folder or group, or select lines in the editor and stage just those (__Ctrl/Cmd+Alt+G S__; __U__ to unstage).\n[Open the Changes view](command:workbench.view.extension.gitstudio)\nThe commit box handles Amend, Sign-off, an author override, and Commit & Push.", "media": { "image": { - "light": "media/wordmark-light.png", - "dark": "media/wordmark-dark.png", - "hc": "media/wordmark-dark.png" + "light": "media/shots/changes-view.png", + "dark": "media/shots/changes-view.png", + "hc": "media/shots/changes-view.png" }, "altText": "GitStudio" }, "completionEvents": [ "onCommand:gitstudio.stageHunk", - "onCommand:gitstudio.stageSelectedLines" + "onCommand:gitstudio.stageSelectedLines", + "onView:gitstudio.commit" + ] + }, + { + "id": "gitstudio.walkthrough.sync", + "title": "Fetch and pull without losing your place", + "description": "The ahead/behind counts in the Changes header are real __Push__ and __Pull__ buttons. In the branch dialog, __Fetch__ runs without closing the menu — every branch's ↑/↓ updates live — and any branch can be pulled __without checking it out__.\n[Open the Changes view](command:workbench.view.extension.gitstudio)", + "media": { + "image": { + "light": "media/shots/branch-dialog.png", + "dark": "media/shots/branch-dialog.png", + "hc": "media/shots/branch-dialog.png" + }, + "altText": "GitStudio" + }, + "completionEvents": [ + "onView:gitstudio.commit" ] }, { "id": "gitstudio.walkthrough.history", - "title": "Explore file & line history", - "description": "Step through how a file — or a single line — evolved over time, with revision navigation and a reflog time-machine for recovery.\n[Show Line History](command:gitstudio.showLineHistory)\nShortcut: __Ctrl/Cmd+Alt+G H__ on the current line.", + "title": "Blame, file history, line history", + "description": "Open a file, then: inline blame at the end of the current line, full-file annotations with a code-age heatmap (__Ctrl/Cmd+Alt+G B__), and the history of the exact lines under your cursor (__Ctrl/Cmd+Alt+G H__).\n[Show File History](command:gitstudio.showFileHistory)\nTip: these act on the file you're looking at — open one first.", "media": { "image": { "light": "media/wordmark-light.png", @@ -869,14 +894,15 @@ "altText": "GitStudio" }, "completionEvents": [ + "onCommand:gitstudio.showFileHistory", "onCommand:gitstudio.showLineHistory", - "onCommand:gitstudio.showReflog" + "onCommand:gitstudio.toggleFileBlame" ] }, { - "id": "gitstudio.walkthrough.merge", - "title": "Resolve conflicts in the 3-pane editor", - "description": "When a merge, rebase, or cherry-pick conflicts, GitStudio opens a JetBrains-style three-pane editor — yours, the result, and theirs — with one-click accept ribbons. Interactive rebase gets the same care, wrapped in a universal, reflog-powered Undo.\n[Start Interactive Rebase](command:gitstudio.startInteractiveRebase)\nMade a mistake? __Undo Last Git Operation__ (Ctrl/Cmd+Alt+G Z) reverses it.", + "id": "gitstudio.walkthrough.safety", + "title": "Rewrite history, safely", + "description": "Drag-to-reorder interactive rebase (pick, reword, squash, fixup, drop), and a three-pane merge editor that opens conflicts automatically — click a conflicted file in __Changes__ to resolve it.\nEvery destructive operation is snapshotted first, so __Undo__ (__Ctrl/Cmd+Alt+G Z__) reverses a bad rebase, reset, or branch delete. It never touches your editor's undo.\n[Show Undo History](command:gitstudio.showUndoHistory)", "media": { "image": { "light": "media/wordmark-light.png", @@ -886,14 +912,15 @@ "altText": "GitStudio" }, "completionEvents": [ - "onCommand:gitstudio.startInteractiveRebase", + "onCommand:gitstudio.showUndoHistory", + "onCommand:gitstudio.undo", "onCommand:gitstudio.resolveInMergeEditor" ] }, { - "id": "gitstudio.walkthrough.connect", - "title": "Connect GitHub & set up GitBrain (optional)", - "description": "Sign in once with VS Code's built-in GitHub account to list, check out, review, merge, and create pull requests in-editor. GitBrain AI is optional and bring-your-own-key — set an Anthropic key (or use Copilot's model) for commit messages, explain-this-diff, and summaries. It's off until you turn it on and never gates a Git operation.\n[Sign in to GitHub](command:gitstudio.pr.signIn)\n[Set AI API Key](command:gitstudio.ai.setApiKey)", + "id": "gitstudio.walkthrough.github", + "title": "Review pull requests in the editor", + "description": "Sign in with VS Code's built-in GitHub account — no extra token. Browse open PRs, check one out, review the diff with inline comments, and merge.\n[Sign in to GitHub](command:gitstudio.pr.signIn)", "media": { "image": { "light": "media/wordmark-light.png", @@ -904,7 +931,24 @@ }, "completionEvents": [ "onCommand:gitstudio.pr.signIn", - "onCommand:gitstudio.ai.setApiKey" + "onView:gitstudio.pullRequests" + ] + }, + { + "id": "gitstudio.walkthrough.ai", + "title": "Connect AI (optional)", + "description": "GitBrain is __off until you connect it__, and it never gates a Git operation. Use __Copilot or Cursor's model with no key at all__, bring an Anthropic or OpenAI-compatible key (OpenRouter, Ollama, LM Studio), or drive a local agent CLI (Claude Code, Codex, Gemini).\nIt writes commit messages from your staged diff, explains a diff, summarizes changes, and reviews your working tree.\n[Connect AI](command:gitstudio.ai.connect)\nIt's the plug icon at the top of the Changes view.", + "media": { + "image": { + "light": "media/wordmark-light.png", + "dark": "media/wordmark-dark.png", + "hc": "media/wordmark-dark.png" + }, + "altText": "GitStudio" + }, + "completionEvents": [ + "onContext:gitstudio.ai.enabled", + "onCommand:gitstudio.ai.connect" ] } ] diff --git a/apps/extension/src/blame/blameController.ts b/apps/extension/src/blame/blameController.ts index 46eb1df..c6be755 100644 --- a/apps/extension/src/blame/blameController.ts +++ b/apps/extension/src/blame/blameController.ts @@ -62,7 +62,7 @@ export class BlameController implements vscode.Disposable { ); this.statusBar.command = "gitstudio.blame.showLineActions"; - void this.maybeDisableNativeBlame(); + void this.checkDuplicateBlame(); this.disposables.push( this.inlineDecoration, @@ -75,10 +75,12 @@ export class BlameController implements vscode.Disposable { this.scheduleInline(e.textEditor), ), vscode.window.onDidChangeActiveTextEditor((editor) => { + // Always clear FIRST: the annotation is only ever painted on the active + // editor, so without this the previous editor keeps its stale line + // annotation in a split view — which looks exactly like duplicate blame. + this.clearInline(); if (editor) { this.scheduleInline(editor); - } else { - this.clearInline(); } }), vscode.workspace.onDidChangeTextDocument((e) => { @@ -265,10 +267,7 @@ export class BlameController implements vscode.Disposable { 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.", - ); + await vscode.commands.executeCommand("gitstudio.showFileHistory"); break; case "toggle": await this.toggleFileBlame(); @@ -420,50 +419,86 @@ export class BlameController implements vscode.Disposable { return promise; } - // --- Native-blame de-duplication (one-time) ------------------------------ - - private async maybeDisableNativeBlame(): Promise { + // --- Duplicate inline-blame detection ------------------------------------ + + /** + * Three extensions can paint an end-of-line blame annotation on the same + * line: GitStudio, VS Code's built-in git, and GitLens. Stacked, they read as + * garbage. + * + * The previous check was broken in both directions. It tested + * `inspect().defaultValue`, not the EFFECTIVE value — and since + * `git.blame.editorDecoration.enabled` ships defaulting to `false`, the + * built-in decoration (the one that actually overlaps us) was never detected. + * Meanwhile `git.blame.statusBarItem.enabled` defaults to `true`, so the only + * branch that ever fired silently rewrote the user's GLOBAL settings to turn + * off the status-bar item — which wasn't overlapping anything. + * + * Now: read effective values, notice GitLens too, and never touch another + * extension's settings without being told to. We ask; the user decides. + */ + private async checkDuplicateBlame(): Promise { if (this.context.globalState.get(NATIVE_BLAME_DISABLED_KEY)) { + return; // asked once already — don't nag on every launch + } + // Nothing can collide with us if we aren't rendering inline blame. + const ours = vscode.workspace + .getConfiguration("gitstudio.blame") + .get("inlineEnabled", true); + if (!ours) { 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); + const git = vscode.workspace.getConfiguration("git"); + const builtInOn = git.get("blame.editorDecoration.enabled", false); + + const gitlens = vscode.extensions.getExtension("eamodio.gitlens"); + const gitlensOn = + gitlens !== undefined && + vscode.workspace + .getConfiguration("gitlens") + .get("currentLine.enabled", true); + + const others: string[] = []; + if (builtInOn) others.push("VS Code's built-in Git"); + if (gitlensOn) others.push("GitLens"); + if (others.length === 0) { + return; + } - // 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; - } + const TURN_OFF_OURS = "Turn off GitStudio's"; + const TURN_OFF_THEIRS = "Turn off the other"; + const KEEP = "Keep both"; + const choice = await vscode.window.showInformationMessage( + `${others.join(" and ")} ${others.length > 1 ? "also show" : "also shows"} ` + + "inline blame, so you'll see the annotation more than once per line.", + TURN_OFF_OURS, + TURN_OFF_THEIRS, + KEEP, + ); + 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, - ); + if (choice === TURN_OFF_OURS) { + await vscode.workspace + .getConfiguration("gitstudio.blame") + .update("inlineEnabled", false, vscode.ConfigurationTarget.Global); + } else if (choice === TURN_OFF_THEIRS) { + // Only ever on an explicit click — this writes settings we don't own. + if (builtInOn) { + await git.update( + "blame.editorDecoration.enabled", + false, + vscode.ConfigurationTarget.Global, + ); + } + if (gitlensOn) { + await vscode.workspace + .getConfiguration("gitlens") + .update("currentLine.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. } diff --git a/apps/extension/src/changes/commitView.ts b/apps/extension/src/changes/commitView.ts index 386f437..ad68f97 100644 --- a/apps/extension/src/changes/commitView.ts +++ b/apps/extension/src/changes/commitView.ts @@ -1,4 +1,5 @@ import * as vscode from "vscode"; +import { log } from "../log"; import type { GitRef } from "@gitstudio/git-service/index"; import type { RepoManager, RepoEntry } from "../git/repoManager"; import type { Change } from "../git/git"; @@ -209,8 +210,15 @@ export class CommitViewProvider } private async onMessage(msg: FromWebview): Promise { + // Temporary tripwire: surface the webview's self-diagnostics in the + // GitStudio output channel (webview console errors don't reach any log). + if ((msg as { type?: string }).type === "__diag") { + log(`[diag] webview: ${JSON.stringify(msg)}`); + return; + } switch (msg.type) { case "ready": + log("[diag] webview 'ready' received → pushState"); await this.pushState(); return; case "amendToggled": @@ -402,6 +410,15 @@ export class CommitViewProvider await this.discardEntries(await this.entriesForPaths(active, [path])); } + /** Is anything actually in the index? (Cheap: `git diff --cached`.) */ + private async hasStagedChanges(entry: RepoEntry): Promise { + try { + return (await entry.ctx.staging.stagedCount()) > 0; + } catch { + return true; // can't tell — let git have the final word + } + } + private doOpenDiff(path: string, staged: boolean): void { const active = this.repos.getActive(); if (!active || !path) { @@ -420,9 +437,19 @@ export class CommitViewProvider const change = findIn(pool, active.root, path); if (change) { const isMerge = pool === state.mergeChanges; - void openChangeDiff( - new ChangeFileNode(isMerge ? "merge" : kind, active.root, change), - ); + if (isMerge) { + // A conflicted file belongs in the 3-pane merge editor — that is the + // whole point of the feature. It used to open a plain 2-way + // "Working Tree vs HEAD" diff, so the merge editor was unreachable + // from GitStudio's own Changes view (its only entry points were VS + // Code's built-in SCM view and the editor title bar). + void vscode.commands.executeCommand( + "gitstudio.resolveInMergeEditor", + change.uri, + ); + return; + } + void openChangeDiff(new ChangeFileNode(kind, active.root, change)); return; } } @@ -547,6 +574,13 @@ export class CommitViewProvider /** Runs the commit (+ optional push), surfacing errors and clearing on success. */ private async doCommit(msg: FromWebview): Promise { + // Guard re-entrancy: doCommit now awaits (staged-check, the Stage-All + // prompt) before setting this.busy, so a rapid double-click could let two + // invocations both reach staging.commit() and the loser toasts a spurious + // "nothing to commit" right after a success. One commit at a time. + if (this.busy) { + return; + } const entry = this.repos.getActive(); if (!entry) { void vscode.window.showInformationMessage( @@ -562,6 +596,29 @@ export class CommitViewProvider return; } + // Nothing staged? git exits 1 and prints "no changes added to commit" to + // STDOUT — but we only ever read stderr, so the user got the immortal + // "commit failed — unknown error" and no hint that staging is even a thing. + // Offer VS Code's own escape hatch instead of failing at them. + if (!msg.amend && !(await this.hasStagedChanges(entry))) { + const STAGE_ALL = "Stage All & Commit"; + const choice = await vscode.window.showWarningMessage( + "There are no staged changes to commit. Stage all your changes and commit them directly?", + { modal: true }, + STAGE_ALL, + ); + if (choice !== STAGE_ALL) { + return; + } + const staged = await entry.ctx.staging.stageAll(); + if (!staged.ok) { + void vscode.window.showErrorMessage( + `GitStudio: couldn't stage your changes — ${describeGitFailure(staged)}`, + ); + return; + } + } + this.busy = true; void this.pushState(); try { @@ -582,7 +639,7 @@ export class CommitViewProvider : await doCommit(); if (!result.ok) { void vscode.window.showErrorMessage( - `GitStudio: commit failed — ${result.stderr.trim() || "unknown error"}`, + `GitStudio: commit failed — ${describeGitFailure(result)}`, ); return; } @@ -893,34 +950,45 @@ export class CommitViewProvider ahead?: number; behind?: number; }> { - if (active.repo) { - const state = active.repo.state; - const toEntries = (changes: Change[] | undefined): FileEntry[] => - (changes ?? []).map((c) => ({ - path: relativePath(active.root, c.uri.fsPath), - status: statusLetter(c.status), - })); - const head = state.HEAD; - // `git.untrackedChanges: separate` moves untracked files out of - // workingTreeChanges into a separate list (not in the pinned API type but - // present at runtime). Fold it back in so untracked files show the same - // as under the default 'mixed' (and as our eager parser shows them). - const untracked = - (state as { untrackedChanges?: Change[] }).untrackedChanges ?? []; - return { - merge: toEntries(state.mergeChanges), - staged: toEntries(state.indexChanges), - unstaged: [ - ...toEntries(state.workingTreeChanges), - ...toEntries(untracked), - ], - branch: head?.name, - upstream: head?.upstream - ? `${head.upstream.remote}/${head.upstream.name}` - : undefined, - ahead: head?.ahead, - behind: head?.behind, - }; + // Prefer vscode.git's live cached state, but ONLY once it actually has a + // HEAD branch — during the window where it has attached the repo but not + // yet computed HEAD, its branch is undefined, which would blank the pill. + // In that window (and on any throw) fall through to our own `git status`, + // which needs no vscode.git. The old code used vscode.git the moment the + // repo attached and let a throw blank the whole Changes view. + if (active.repo?.state?.HEAD?.name) { + try { + const state = active.repo.state; + const toEntries = (changes: Change[] | undefined): FileEntry[] => + (changes ?? []).map((c) => ({ + path: relativePath(active.root, c.uri.fsPath), + status: statusLetter(c.status), + })); + const head = state.HEAD; + // `git.untrackedChanges: separate` moves untracked files out of + // workingTreeChanges into a separate list (not in the pinned API type + // but present at runtime). Fold it back in so untracked files show the + // same as under the default 'mixed' (and as our eager parser shows). + const untracked = + (state as { untrackedChanges?: Change[] }).untrackedChanges ?? []; + return { + merge: toEntries(state.mergeChanges), + staged: toEntries(state.indexChanges), + unstaged: [ + ...toEntries(state.workingTreeChanges), + ...toEntries(untracked), + ], + branch: head?.name, + upstream: head?.upstream + ? `${head.upstream.remote}/${head.upstream.name}` + : undefined, + ahead: head?.ahead, + behind: head?.behind, + }; + } catch (err) { + // Surface it (exthost log) but keep going via the git-service path. + log(`[diag] vscode.git state read failed → git status fallback: ${err instanceof Error ? err.message : String(err)}`); + } } const st = await active.ctx.status.read(); return { @@ -956,8 +1024,27 @@ export class CommitViewProvider ({ merge, staged, unstaged, branch, upstream, ahead, behind } = await this.resolveState(active)); hasRepo = true; - } catch { - hasRepo = false; + } catch (err) { + // A repo IS open — a transient status read failed. Keep hasRepo TRUE so + // the Changes view stays alive instead of collapsing to onboarding. + log(`[diag] resolveState FAILED (keeping view alive): ${err instanceof Error ? err.message : String(err)}`); + hasRepo = true; + } + // GUARANTEE the branch is populated. resolveState can legitimately return + // an empty branch (vscode.git attached but HEAD not computed yet, a race + // other Git extensions can trigger), which showed the pill as "—" with no + // branch to click. Our own git-service always knows the branch — use it as + // the authoritative fallback so the pill can never be empty on a real repo. + if (!branch) { + try { + const head = await active.ctx.refs.getHead(); + if (!head.detached && head.branch) { + branch = head.branch; + log(`[diag] branch was empty → recovered '${branch}' from git-service`); + } + } catch (err) { + log(`[diag] getHead fallback failed: ${err instanceof Error ? err.message : String(err)}`); + } } } @@ -996,6 +1083,7 @@ export class CommitViewProvider layout, busy: this.busy, }; + log(`[diag] pushState → hasRepo=${hasRepo} branch=${JSON.stringify(branch)} staged=${staged.length} unstaged=${unstaged.length} active=${!!active} usedVscodeGit=${!!active?.repo?.state?.HEAD?.name}`); void this.view.webview.postMessage(base); // THEN resolve the slower bits — the AI-availability probe (vscode.lm / @@ -1006,7 +1094,15 @@ export class CommitViewProvider this.generator ? this.generator.isEnabled().catch(() => false) : Promise.resolve(false), - active ? this.collectBranches(active) : Promise.resolve(undefined), + active + ? this.collectBranches(active).catch((err) => { + // A branch-list failure must not sink the whole state push (which + // would leave the branch pill and menu empty). Log and keep the + // last-known list. + log(`[diag] collectBranches failed: ${err instanceof Error ? err.message : String(err)}`); + return this.lastBranches; + }) + : Promise.resolve(undefined), ]); this.lastAiEnabled = aiEnabled; this.lastBranches = branches; @@ -1203,6 +1299,7 @@ export class CommitViewProvider .bm-search input:focus { border-color: var(--gs-accent); box-shadow: var(--gs-glow); } .bm-list { overflow-y: auto; padding: 3px; } .bm-action, .bm-branch { + position: relative; display: flex; align-items: center; gap: 8px; @@ -1216,9 +1313,27 @@ export class CommitViewProvider text-align: left; border-radius: var(--gs-radius-sm); cursor: pointer; + transition: background var(--gs-motion-fast) var(--gs-ease); } .bm-action .codicon, .bm-bicon { font-size: 14px; color: var(--gs-fg-muted); flex: 0 0 auto; } - .bm-action:hover, .bm-branch:hover { background: var(--gs-hover); } + /* A clearer hover: a stronger tint than the near-invisible list-hover, an + accent-tinted border, and a left accent bar so where you're pointing in + the dialog is unmistakable. */ + .bm-action:hover, .bm-branch:hover, + .bm-action:focus-visible, .bm-branch:focus-visible { + background: color-mix(in srgb, var(--gs-accent) 16%, var(--gs-hover)); + outline: none; + } + .bm-action:hover::before, .bm-branch:hover::before, + .bm-action:focus-visible::before, .bm-branch:focus-visible::before { + content: ""; + position: absolute; + left: 0; top: 3px; bottom: 3px; + width: 2px; + border-radius: 2px; + background: var(--gs-accent); + } + .bm-action:hover .codicon, .bm-branch:hover .bm-bicon { color: var(--gs-fg); } /* A collapsible category header: chevron + label + count, full-width button. */ .bm-sep { display: flex; @@ -1673,7 +1788,17 @@ export class CommitViewProvider /* ---- Groups -------------------------------------------------------- */ .groups { margin: 0 0 2px; } .group { margin-top: 4px; } + /* The STAGED group always renders, even at zero: hiding it meant a new user + never saw that staging exists, then hit "commit failed". Other empty + groups still collapse. */ .group.empty { display: none; } + .group.empty.keep-empty { display: block; } + .group-empty-hint { + padding: 6px 8px 10px 26px; + font-size: 11.5px; + color: var(--gs-fg-subtle); + font-style: italic; + } .group-header { display: flex; align-items: center; @@ -1825,6 +1950,13 @@ export class CommitViewProvider read clearly (esp. the single-stroke unstage dash). */ .row .row-actions .icon-btn { color: var(--gs-fg); } .row .row-actions .icon-btn:hover { color: var(--gs-brand); } + /* Discard is IRREVERSIBLE and sits in the same slot where Unstage sits in + the staged group — muscle memory from one group would destroy work in the + other. Make it read as destructive. */ + .row .row-actions .icon-btn.danger:hover { + color: var(--gs-status-deleted); + background: color-mix(in srgb, var(--gs-status-deleted) 16%, transparent); + } .row .row-actions .codicon, .group-actions .icon-btn .codicon { font-size: 17px; } /* Status letter: plain colored monospace, not a filled pill. Each row @@ -1923,7 +2055,15 @@ export class CommitViewProvider .gs-tip { position: fixed; z-index: 99999; pointer-events: none; transform: translate(-50%, -100%); - max-width: 280px; padding: 3px 7px; + /* WRAP long content — a truncated "Merge 'x' into 'y'" or a clipped + branch name is exactly what the tip exists to reveal, so it must never + ellipsize itself. Wide enough for a long ref, then it wraps. */ + max-width: 340px; + /* pre-line: honor the \n we put between a ref name, its upstream, and the + hint, while still wrapping any single long line. */ + white-space: pre-line; + overflow-wrap: anywhere; + padding: 5px 9px; border-radius: var(--gs-radius-sm); border: 1px solid var(--gs-border); /* MUST be opaque — --gs-surface* are color-mix-with-transparent tints @@ -1931,8 +2071,7 @@ export class CommitViewProvider turns see-through with them and reads as a rendering glitch. */ background: var(--vscode-editorHoverWidget-background, var(--vscode-editor-background, #2b2b2b)); color: var(--gs-fg); - font-family: var(--gs-font-ui); font-size: 11.5px; line-height: 1.35; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + font-family: var(--gs-font-ui); font-size: 11.5px; line-height: 1.4; box-shadow: var(--gs-shadow-2); opacity: 0; transition: opacity var(--gs-motion-fast) var(--gs-ease); } @@ -2082,6 +2221,19 @@ export class CommitViewProvider