diff --git a/README.md b/README.md index a544bb3..0c260e4 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,9 @@ the resolved app appearance automatically. - **Guided bisect** — use a commit’s Find regression menu to choose working and broken revisions; test and mark working/broken/skip, inspect remaining candidates and the culprit, resume external sessions, and reset to the original checkout while protecting test edits. +- **Compare branches** — open from the command palette or Branch actions; + choose any two local or remote refs without checkout, with upstream/main/master + defaults and the existing changed-file and full-diff dialog. - **Commit graph** — SVG lanes with branch/tag chips, revealable inline stash nodes with non-mutating diff inspection, a resizable commit detail panel with lazy GPG/SSH/X.509 verification, diff --git a/ROADMAP.md b/ROADMAP.md index cb0ea7b..34cc89d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3024,6 +3024,13 @@ and independent Windows signature/hash/metadata checks passing. Publication started Microsoft Store run `34066047506`; its build and submission remain pending, followed by Partner Center certification. +**Branch comparison discovery shipped (2026-09-07, DAN-67):** Compare branches +is available from the command palette and Branch actions. Shared defaults choose +current vs upstream, then main/master, then the first pair; sidebar ref menus +also work with detached HEAD. All entry points reuse `CompareRefsDialog` and +`repoDiffBetween` / `repoTreeAt` for a full union file tree, changed-file badges, +and per-file comparison, including an identical-file tip for unchanged paths. + ## Cross-cutting tracks (run in parallel with all milestones) **Performance audit kick (2026-09-06):** Rechecked `main` at `8e83c8c` on diff --git a/TASKS.md b/TASKS.md index 0c1d38b..19340f4 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1069,6 +1069,9 @@ community plugins, performance and platform certification from Git feature gaps. creation/deletion headers, with Computer Use stage/unstage verification.) ### Commits view +- ☑ DAN-67: first-class arbitrary branch comparison (`compareBranchDefaults`, + palette `strand:compare-branches`, Branch actions and HEAD menu defaults; + detached-HEAD context menus reuse `CompareRefsDialog` / `repoDiffBetween`). - ☑ Table from `repo_log` - ☑ SVG lane rendering (`ui/src/lib/graph.ts` lane algo + `CommitGraphCell` SVG; multi-color via `--b-1..--b-7`) - ◐ Branch / tag / HEAD chips inline in the message cell (`indexRefs` in `Commits.tsx` + `.ref-chip` CSS; right-side chip column still open) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2f1be00..218828d 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1788,6 +1788,7 @@ export function App() { // open, so don't surface them (the network ones would fail confusingly). if (meta) { base.push( + { id: 'compare-branches', label: 'Compare branches…', group: 'Actions', keywords: 'compare refs branches diff', run: () => { window.requestAnimationFrame(() => window.dispatchEvent(new CustomEvent('strand:compare-branches'))); } }, { id: 'work', label: t('work.paletteShow'), group: 'Actions', shortcut: keyHint('view-work'), keywords: 'files documents embedded terminals shell', run: openWorkbench }, { id: 'work-new-terminal', label: t('work.newTerminal'), group: 'Actions', keywords: 'work embedded shell prompt console', run: () => { addEmbeddedTerminal(meta.path); showWorkbenchWork(); } }, { id: 'work-split-right', label: t('work.splitRight'), group: 'Actions', keywords: 'work pane editor group side by side', run: () => { diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index c0fb2c2..d998f69 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -9,6 +9,7 @@ import { t } from '../lib/i18n'; import { formatBinding } from '../lib/keys'; import { customPanes } from '../lib/customView'; import { pathKey, worktreeName } from '../lib/repoIdentity'; +import { compareBranchDefaults } from '../lib/compareBranchDefaults'; import { providerMergedBranchNames } from '../lib/branchIntegration'; import { errMessage, tauri } from '../lib/tauri'; import { defaultRemote, useRepo } from '../stores/repo'; @@ -255,6 +256,21 @@ export function Sidebar({ onManageSubmodules, onOpenWorkbench, onOpenWorkSurface [refs], ); const [refCompare, setRefCompare] = useState<{ from: string; to: string } | null>(null); + const branchCompareDefaults = useMemo(() => compareBranchDefaults({ + localNames: refs.branches.map((branch) => branch.name), + remoteNames: refs.remote_branches.map((branch) => branch.name), + currentBranch, + upstream: refs.branches.find((branch) => branch.is_head)?.upstream?.name ?? null, + }), [refs, currentBranch]); + useEffect(() => { + const open = () => { + if (!meta) return; + if (branchCompareDefaults) setRefCompare(branchCompareDefaults); + else onToast('At least two branches are needed to compare branches'); + }; + window.addEventListener('strand:compare-branches', open); + return () => window.removeEventListener('strand:compare-branches', open); + }, [meta, branchCompareDefaults, onToast]); // Branches that are HEAD of another worktree — checkout here is guaranteed // to fail, so their rows badge the fact and open that worktree instead. const worktreeByBranch = useMemo( @@ -498,11 +514,8 @@ export function Sidebar({ onManageSubmodules, onOpenWorkbench, onOpenWorkSurface }; const compareAgainst = (target: string) => { - if (!currentBranch) { - onToast('Check out a local branch before comparing refs', 'error'); - return; - } - setRefCompare({ from: target, to: currentBranch }); + const to = currentBranch ?? compareChoices.find((choice) => choice.value !== target)?.value ?? target; + setRefCompare({ from: target, to }); }; const branchMenu = (b: Branch): MenuItem[] => { @@ -565,11 +578,8 @@ export function Sidebar({ onManageSubmodules, onOpenWorkbench, onOpenWorkSurface { label: 'Compare branch…', icon: 'compare', - disabled: compareChoices.length < 2, - onSelect: () => { - const other = compareChoices.find((choice) => choice.value !== b.name); - if (other) setRefCompare({ from: other.value, to: b.name }); - }, + disabled: !branchCompareDefaults, + onSelect: () => setRefCompare(branchCompareDefaults), }, { label: 'Copy branch name', icon: 'file', onSelect: () => { void copyToClipboard(b.name); onToast('Branch name copied'); } }, { label: 'Copy full ref', icon: 'file', onSelect: () => { void copyToClipboard(b.full_name); onToast('Branch ref copied'); } }, @@ -589,8 +599,8 @@ export function Sidebar({ onManageSubmodules, onOpenWorkbench, onOpenWorkSurface newWorktreeItem, renameItem, ]; + items.push({ label: currentBranch ? `Compare ${currentBranch} with this…` : 'Compare branch…', icon: 'compare', onSelect: () => compareAgainst(b.name) }); if (currentBranch) { - items.push({ label: `Compare ${currentBranch} with this…`, icon: 'compare', onSelect: () => compareAgainst(b.name) }); items.push({ label: `Review ${currentBranch} vs this`, icon: 'eye', onSelect: () => reviewAgainst(b.name) }); items.push({ label: `Merge into ${currentBranch}`, icon: 'branch', onSelect: () => onMerge(b.name, currentBranch) }); items.push({ label: `Rebase ${currentBranch} onto this`, icon: 'rebase', confirm: true, onSelect: () => runRebase(b.name) }); @@ -609,12 +619,12 @@ export function Sidebar({ onManageSubmodules, onOpenWorkbench, onOpenWorkSurface const local = localByUpstream.get(rb.name); const items: MenuItem[] = [userActionMenu({ path: meta!.path, target: { kind: 'ref', reference: rb.full_name, oid: rb.target } })]; items.push({ label: 'Fetch this branch', icon: 'arrow-down', onSelect: () => onFetchBranch(rb) }); + items.push({ + label: currentBranch ? `Compare ${currentBranch} with this…` : 'Compare branch…', + icon: 'compare', + onSelect: () => compareAgainst(rb.name), + }); if (currentBranch) { - items.push({ - label: `Compare ${currentBranch} with this…`, - icon: 'compare', - onSelect: () => compareAgainst(rb.name), - }); items.push({ label: `Pull into ${currentBranch}`, icon: 'arrow-down', @@ -757,9 +767,7 @@ export function Sidebar({ onManageSubmodules, onOpenWorkbench, onOpenWorkSurface { label: 'New branch from here…', icon: 'plus', onSelect: () => onCreateBranch(tg.full_name, tg.name) }, { label: 'New worktree from here…', icon: 'worktree', onSelect: () => onCreateWorktree({ ref: tg.full_name, label: tg.name }) }, ]; - if (currentBranch) { - items.push({ label: `Compare ${currentBranch} with this tag…`, icon: 'compare', onSelect: () => compareAgainst(tg.full_name) }); - } + items.push({ label: currentBranch ? `Compare ${currentBranch} with this tag…` : 'Compare tag…', icon: 'compare', onSelect: () => compareAgainst(tg.full_name) }); if (tagRemote) { items.push({ label: `Push to ${tagRemote}`, icon: 'arrow-up', onSelect: () => runTagPush(tg.name) }); // Gray out remote-delete when we know the remote doesn't have this tag. @@ -1120,6 +1128,7 @@ export function Sidebar({ onManageSubmodules, onOpenWorkbench, onOpenWorkSurface const rect = event.currentTarget.getBoundingClientRect(); openMenu(rect.left, rect.bottom, [ { label: 'New branch…', icon: 'plus', onSelect: () => onCreateBranch(null, 'HEAD') }, + { label: 'Compare branches…', icon: 'compare', disabled: !branchCompareDefaults, onSelect: () => setRefCompare(branchCompareDefaults) }, { label: 'Git-flow…', onSelect: () => openRepositoryTool({ path: meta.path, tool: 'gitflow' }) }, ]); } }} diff --git a/ui/src/lib/compareBranchDefaults.test.ts b/ui/src/lib/compareBranchDefaults.test.ts new file mode 100644 index 0000000..0f62bae --- /dev/null +++ b/ui/src/lib/compareBranchDefaults.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { compareBranchDefaults } from './compareBranchDefaults'; + +describe('compareBranchDefaults', () => { + const defaults = { + localNames: ['feature', 'master', 'main'], + remoteNames: ['origin/feature'], + currentBranch: 'feature', + upstream: 'origin/feature', + }; + + it('prefers upstream and keeps current on the To side', () => { + expect(compareBranchDefaults(defaults)).toEqual({ from: 'origin/feature', to: 'feature' }); + }); + + it.each([null, 'origin/deleted', 'feature'])('prefers main when upstream is unusable (%s)', (upstream) => { + expect(compareBranchDefaults({ ...defaults, upstream })).toEqual({ from: 'main', to: 'feature' }); + }); + + it('falls back to master', () => { + expect(compareBranchDefaults({ ...defaults, localNames: ['feature', 'master'], upstream: null })) + .toEqual({ from: 'master', to: 'feature' }); + }); + + it('never compares current with itself when current is main', () => { + expect(compareBranchDefaults({ ...defaults, currentBranch: 'main', upstream: null })) + .toEqual({ from: 'master', to: 'main' }); + }); + + it('uses the first other branch while preserving current direction', () => { + expect(compareBranchDefaults({ ...defaults, localNames: ['other', 'feature'], upstream: null })) + .toEqual({ from: 'other', to: 'feature' }); + }); + + it.each([null, 'missing'])('uses the first pair without an available current branch (%s)', (currentBranch) => { + expect(compareBranchDefaults({ ...defaults, currentBranch })) + .toEqual({ from: 'feature', to: 'master' }); + }); + + it('supports remote-only branches while detached', () => { + expect(compareBranchDefaults({ localNames: [], remoteNames: ['origin/a', 'origin/b'], currentBranch: null, upstream: null })) + .toEqual({ from: 'origin/a', to: 'origin/b' }); + }); + + it.each([[], ['main'], ['main', 'main']].map((localNames) => ({ localNames })))('requires two distinct available branches: $localNames', ({ localNames }) => { + expect(compareBranchDefaults({ localNames, remoteNames: [], currentBranch: null, upstream: null })).toBeNull(); + }); +}); diff --git a/ui/src/lib/compareBranchDefaults.ts b/ui/src/lib/compareBranchDefaults.ts new file mode 100644 index 0000000..8cce1a5 --- /dev/null +++ b/ui/src/lib/compareBranchDefaults.ts @@ -0,0 +1,16 @@ +/** Pick existing, distinct refs in stable local-then-remote order. */ +export function compareBranchDefaults({ localNames, remoteNames, currentBranch, upstream }: { + localNames: readonly string[]; + remoteNames: readonly string[]; + currentBranch: string | null; + upstream: string | null; +}): { from: string; to: string } | null { + const names = [...new Set([...localNames, ...remoteNames])]; + if (names.length < 2) return null; + if (currentBranch && names.includes(currentBranch)) { + const other = [upstream, 'main', 'master', ...names] + .find((name): name is string => !!name && name !== currentBranch && names.includes(name))!; + return { from: other, to: currentBranch }; + } + return { from: names[0], to: names[1] }; +} diff --git a/ui/src/lib/compareRefsTree.test.ts b/ui/src/lib/compareRefsTree.test.ts new file mode 100644 index 0000000..2ee1a9c --- /dev/null +++ b/ui/src/lib/compareRefsTree.test.ts @@ -0,0 +1,60 @@ +import { afterAll, describe, expect, it, vi } from 'vitest'; +import type { DiffStatus } from './types'; + +// Pierre reads navigator at import time, including on Node 20. +vi.stubGlobal('navigator', { userAgent: 'node' }); +const { compareRefsTree } = await import('./compareRefsTree'); +afterAll(() => vi.unstubAllGlobals()); + +const entries = (...paths: string[]) => paths.map((path) => ({ path })); +const diff = (path: string, status: DiffStatus, old_path: string | null = null) => ({ path, status, old_path }); + +describe('compareRefsTree', () => { + it('sorts and deduplicates the full union, retaining unchanged files without badges', () => { + expect(compareRefsTree( + entries('src/same.ts', 'deleted.txt', 'README.md'), + entries('added.txt', 'src/same.ts', 'README.md'), + [diff('added.txt', 'added'), diff('deleted.txt', 'deleted')], + )).toEqual({ + paths: ['README.md', 'added.txt', 'deleted.txt', 'src/same.ts'], + gitStatus: [{ path: 'added.txt', status: 'added' }, { path: 'deleted.txt', status: 'deleted' }], + }); + }); + + it('keeps identical nonempty trees visible with no status entries', () => { + expect(compareRefsTree(entries('a', 'dir/b'), entries('dir/b', 'a'), [])) + .toEqual({ paths: ['a', 'dir/b'], gitStatus: [] }); + }); + + it('supports empty revisions', () => { + expect(compareRefsTree([], [], [])).toEqual({ paths: [], gitStatus: [] }); + }); + + it('includes missing diff paths and both sides of a rename', () => { + expect(compareRefsTree([], entries('new.txt'), [diff('new.txt', 'renamed', 'old.txt'), diff('gone.txt', 'deleted')])) + .toEqual({ + paths: ['gone.txt', 'new.txt', 'old.txt'], + gitStatus: [ + { path: 'old.txt', status: 'renamed' }, + { path: 'new.txt', status: 'renamed' }, + { path: 'gone.txt', status: 'deleted' }, + ], + }); + }); + + it('prefers a path’s own status over a rename alias regardless of diff order', () => { + for (const diffs of [ + [diff('old', 'added'), diff('new', 'renamed', 'old')], + [diff('new', 'renamed', 'old'), diff('old', 'added')], + ]) { + expect(compareRefsTree([], [], diffs).gitStatus).toContainEqual({ path: 'old', status: 'added' }); + } + }); + + it.each([ + ['modified', 'modified'], ['typechange', 'modified'], ['copied', 'added'], + ] as const)('uses the shared status mapping for %s', (status, expected) => { + const tree = compareRefsTree(entries('source'), entries('target'), [diff('target', status, 'source')]); + expect(tree.gitStatus).toEqual([{ path: 'target', status: expected }]); + }); +}); diff --git a/ui/src/lib/compareRefsTree.ts b/ui/src/lib/compareRefsTree.ts new file mode 100644 index 0000000..e4a1ee2 --- /dev/null +++ b/ui/src/lib/compareRefsTree.ts @@ -0,0 +1,26 @@ +import type { GitStatusEntry } from '@pierre/trees'; +import { diffStatusToGit } from '../components/PierreTree'; +import type { FileDiff, WorkTreeEntry } from './types'; + +/** Full revision inventory, with badges only for paths changed by the comparison. */ +export function compareRefsTree( + from: readonly Pick[], + to: readonly Pick[], + diffs: readonly Pick[], +): { paths: string[]; gitStatus: GitStatusEntry[] } { + const paths = new Set([...from, ...to].map((entry) => entry.path)); + const statuses = new Map(); + for (const diff of diffs) { + paths.add(diff.path); + if (diff.status === 'renamed' && diff.old_path) { + paths.add(diff.old_path); + statuses.set(diff.old_path, diffStatusToGit(diff.status)); + } + } + // A path's own diff takes precedence over a rename's old-path alias. + for (const diff of diffs) statuses.set(diff.path, diffStatusToGit(diff.status)); + return { + paths: [...paths].sort(), + gitStatus: [...statuses].map(([path, status]) => ({ path, status })), + }; +} diff --git a/ui/src/lib/diffLayout.test.ts b/ui/src/lib/diffLayout.test.ts new file mode 100644 index 0000000..85d0cc8 --- /dev/null +++ b/ui/src/lib/diffLayout.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest'; +import { toPierreLayout } from './diffLayout'; + +describe('toPierreLayout', () => { + it('maps stacked to unified and split to split', () => { + expect(toPierreLayout('stacked')).toBe('unified'); + expect(toPierreLayout('split')).toBe('split'); + }); +}); diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index 4bd4c2b..3b99ee7 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -3184,18 +3184,31 @@ button { /* ─── Commit / branch comparison ───────────────────────────────────────── */ .compare-refs-dialog { - width: min(1120px, calc(100vw - 48px)); - height: min(760px, calc(100vh - 48px)); display: grid; grid-template-rows: auto auto auto minmax(0, 1fr); + align-self: center; +} +.compare-refs-toolbar { + display: flex; + align-items: center; + gap: 8px; + margin: 6px 12px 0; + min-height: 28px; } .compare-refs-summary { - margin-left: auto; + margin-left: 0; + margin-right: auto; color: var(--text-dim); font-size: 11px; } +.compare-refs-layout { + display: inline-flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} .compare-refs-message .compare-refs-summary { - margin: 6px 12px 0; + margin: 0; } .compare-refs-dialog .cd-close { margin-left: 4px; @@ -3246,6 +3259,12 @@ button { border-right: 0.5px solid var(--border); padding: 4px; } +.compare-refs-dialog .compare-refs-files { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} .compare-refs-file { width: 100%; min-height: 30px; @@ -3481,6 +3500,12 @@ button { height: min(680px, 88vh); } .dialog-wide { width: min(1120px, 96vw); } +.clone-dialog.compare-refs-dialog { + width: calc(100vw - 24px); + max-width: none; + height: calc(100vh - 24px); + align-self: center; +} .clone-head { display: flex; align-items: center; diff --git a/ui/src/views/CompareRefsDialog.tsx b/ui/src/views/CompareRefsDialog.tsx index 3b6e2cc..39baf98 100644 --- a/ui/src/views/CompareRefsDialog.tsx +++ b/ui/src/views/CompareRefsDialog.tsx @@ -2,11 +2,14 @@ import { useEffect, useMemo, useState } from 'react'; import { Dialog } from '../components/Dialog'; import { Diff } from '../components/Diff'; +import { DiffLayoutToggle, toPierreLayout } from '../components/DiffChrome'; import { ImageDiff } from '../components/ImageDiff'; +import { PierreTree } from '../components/PierreTree'; import { Select } from '../components/Select'; +import { compareRefsTree } from '../lib/compareRefsTree'; import { isImagePath } from '../lib/image'; import { errMessage, tauri } from '../lib/tauri'; -import type { DiffStatus, FileDiff } from '../lib/types'; +import type { FileDiff } from '../lib/types'; import { useSettings } from '../stores/settings'; export interface CompareChoice { @@ -14,7 +17,7 @@ export interface CompareChoice { label: string; } -/** First-class commit-ish comparison with a changed-file list and full diff. */ +/** First-class commit-ish comparison with a full file tree and per-file diff. */ export function CompareRefsDialog({ repoPath, choices, @@ -33,11 +36,12 @@ export function CompareRefsDialog({ const [from, setFrom] = useState(initialFrom); const [to, setTo] = useState(initialTo); const [diffs, setDiffs] = useState([]); + const [tree, setTree] = useState(() => compareRefsTree([], [], [])); const [selectedFile, setSelectedFile] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const diffMode = useSettings((state) => state.diffMode); - const layout = diffMode === 'split' ? 'split' : 'unified'; + const layout = toPierreLayout(diffMode); const uniqueChoices = useMemo(() => { const seen = new Set(); @@ -52,18 +56,25 @@ export function CompareRefsDialog({ let cancelled = false; setLoading(true); setError(null); - void tauri.repoDiffBetween(repoPath, from, to).then( - (next) => { + void Promise.all([ + tauri.repoDiffBetween(repoPath, from, to), + tauri.repoTreeAt(repoPath, from), + tauri.repoTreeAt(repoPath, to), + ]).then( + ([next, fromTree, toTree]) => { if (cancelled) return; + const nextTree = compareRefsTree(fromTree, toTree, next); setDiffs(next); + setTree(nextTree); setSelectedFile((current) => - current && next.some((diff) => diff.path === current) ? current : (next[0]?.path ?? null), + current && nextTree.paths.includes(current) ? current : (next[0]?.path ?? nextTree.paths[0] ?? null), ); setLoading(false); }, (caught) => { if (cancelled) return; setDiffs([]); + setTree(compareRefsTree([], [], [])); setSelectedFile(null); setError(errMessage(caught)); setLoading(false); @@ -72,7 +83,9 @@ export function CompareRefsDialog({ return () => { cancelled = true; }; }, [repoPath, from, to]); - const focused = diffs.find((diff) => diff.path === selectedFile) ?? null; + const focused = diffs.find((diff) => diff.path === selectedFile) + ?? diffs.find((diff) => diff.status === 'renamed' && diff.old_path === selectedFile) + ?? null; const adds = diffs.reduce((total, diff) => total + diff.adds, 0); const dels = diffs.reduce((total, diff) => total + diff.dels, 0); @@ -107,46 +120,28 @@ export function CompareRefsDialog({
-
- {loading ? 'Diffing…' : `${diffs.length} files · +${adds} −${dels}`} +
+
+ {loading ? 'Diffing…' : `${tree.paths.length} files · ${diffs.length} changed · +${adds} −${dels}`} +
+
+ +
{error ?
{error}
: null}
-
- {diffs.map((diff) => ( - - ))} - {!loading && !error && diffs.length === 0 ? ( -
No changes between these revisions.
+ /> ) : null}
@@ -168,19 +163,16 @@ export function CompareRefsDialog({ ) ) : ( -
Select a changed file to inspect its diff.
+
+ {selectedFile + ? 'No change to this file between the selected revisions.' + : !error && tree.paths.length === 0 && diffs.length === 0 + ? 'No changes between these revisions.' + : 'Select a file to compare between the selected revisions.'} +
)}
); } - -function statusLetter(status: DiffStatus): string { - if (status === 'added') return 'A'; - if (status === 'deleted') return 'D'; - if (status === 'renamed') return 'R'; - if (status === 'copied') return 'C'; - if (status === 'typechange') return 'T'; - return 'M'; -} diff --git a/website/docs/everyday-git.md b/website/docs/everyday-git.md index 533036e..0084617 100644 --- a/website/docs/everyday-git.md +++ b/website/docs/everyday-git.md @@ -259,10 +259,17 @@ objects. - **Merge** ("Merge into " on a branch) opens a dialog with three modes: fast-forward when possible, always create a merge commit (no-FF), or squash — a squash merge leaves the result staged so you write the commit yourself. - A plain **rebase** ("Rebase onto this") is available from the branch context menu, behind a confirm step. +Open **Compare branches…** from the command palette (⌘K / Ctrl+K) or the +**Branches → Branch actions** menu to compare any two local or remote branches. +It starts with the current branch in **To** and its upstream in **From**, falling +back to `main`, then `master`, then the first other branch. With detached HEAD, +it uses the first available pair. Neither branch needs to be checked out. + Local branches, remote branches, and tags also offer **Compare … with this…** in their sidebar menus. The comparison dialog lets you swap or change either -ref, navigate the changed-file list with the arrow keys, and inspect text and -image diffs without checking anything out. +ref, navigate the full file tree from both revisions with the arrow keys, and +inspect text and image diffs without checking anything out. Changed files have +status badges; selecting an unchanged file shows that it is identical. If any of these operations hit conflicts, they pause rather than fail — see [Paused operations and conflicts](#paused-operations-and-conflicts) below. diff --git a/website/docs/keyboard-and-palette.md b/website/docs/keyboard-and-palette.md index ce3da1e..9ddd58d 100644 --- a/website/docs/keyboard-and-palette.md +++ b/website/docs/keyboard-and-palette.md @@ -249,7 +249,7 @@ ordered full SHAs, subjects, and complete messages. A single commit's menu and detail panel expose subject/body copy and native-dialog patch export. In commit/ref comparison dialogs, `↑` / `↓`, `Home`, and `End` navigate -the changed-file list; `Escape` closes the dialog. Merge cherry-pick/revert +the full file tree; `←` / `→` collapse or expand folders, and `Escape` closes the dialog. Merge cherry-pick/revert dialogs use the native radio-group arrow keys to choose the mainline parent. In commit detail, each changed-file row is focusable; `Enter` / `Space` opens