Show committed files in the "source control" tab (#181)

* refactor: auto-review fixes and UI improvements

- Remove dead code and fix type assertion in getBranchDiff
- Tighten GitDiffBinaryResult type constraint
- Fix race condition in CombinedDiffViewer with generationRef
- Fix reference stability in setGitBranchCompareResult
- Extract LazySection component to fix max-lines lint
- Update filesystem IPC to match updated signatures
- Adjust file explorer item spacing and font size

* fix: correct test expectation for staged diff header label
This commit is contained in:
Jinjing 2026-03-28 17:00:40 -07:00 committed by GitHub
parent dbab855611
commit 3644aa8f29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 2956 additions and 670 deletions

View File

@ -0,0 +1,837 @@
# Source Control Branch Diff Design
## Problem
The current Source Control view only reflects `git status` data:
- staged changes
- unstaged changes
- untracked files
When a branch has committed changes relative to its base branch, but no uncommitted changes, the UI incorrectly appears empty and shows "No changes detected."
This is misleading. Users need to understand both:
1. what is currently uncommitted in the worktree
2. what has changed on this branch relative to the repo base ref
## Goals
- Show all files changed on the current branch, not just uncommitted files.
- Preserve fast local-edit workflows for staging, unstaging, and discarding.
- Make the active compare target explicit.
- Keep File Explorer decorations legible and low-noise.
- Avoid conflating "working tree state" with "branch compare state."
- Ship in one implementation pass without requiring a follow-up architecture rewrite.
## Non-Goals
- Reproducing the full GitHub compare page inside the sidebar
- Replacing the existing PR or Checks surfaces
- Adding dense per-commit browsing in the initial version
- Changing File Explorer to decorate every branch-diff file
## Core Model
The UI should treat these as separate data sources.
### 1. Uncommitted Changes
Derived from local SCM state, equivalent to `git status`.
Includes:
- staged
- unstaged
- untracked
- conflicts if later added
This answers: "What have I changed locally that is not fully committed yet?"
### 2. Branch Changes
Derived from branch-vs-base comparison, equivalent to `git diff <baseRef>...HEAD`.
Includes all files changed on the current branch relative to the configured base ref, even when the worktree is clean.
This answers: "What is different on this branch compared with the configured base ref?"
These two models must remain distinct in state, UI labels, badge semantics, and diff behavior.
## Base Ref
Branch compare should use the repo's configured base ref:
- `repo.worktreeBaseRef` when set
- otherwise the detected default base ref, typically `origin/main` or `origin/master`
The active base ref must be visible in the Source Control UI so the compare scope is never ambiguous.
### Base Ref Validation
Before running any branch compare query, the app must verify that `<baseRef>` resolves in the current repo.
If the configured or detected base ref does not resolve:
- do not treat that as "no branch changes"
- keep uncommitted changes fully functional
- show the branch compare surface in an unavailable state
- surface a clear recovery action to change the base ref
Recommended copy:
- heading: `Branch compare unavailable`
- supporting text: `Base ref <baseRef> could not be resolved in this repository.`
- actions: `Change Base Ref`, `Retry`
This is required because default-base fallback may produce a syntactically valid ref name that does not actually exist locally.
## Source Control Layout
This design applies inside the existing right sidebar tab named `Source Control`.
It does not introduce new top-level sidebar tabs.
The existing top-level app navigation remains:
- `Explorer`
- `Search`
- `Source Control`
- `Checks`
Within the `Source Control` panel, add a scope selector:
- `All`
- `Uncommitted`
- `Branch`
Default selection: `All`
Rationale:
- `All` best matches user intent when opening Source Control on a branch
- it prevents the false-empty case when committed branch changes exist
- it preserves the current Source Control entry point instead of inventing a parallel navigation model
## Compare Summary Bar
At the top of the `Source Control` panel, show a compact compare summary when branch compare data is available:
- `base: origin/main`
- `compare: <current branch>`
- `<n> files changed`
- `<m> commits ahead` when available
- PR pill if PR metadata is already available for the branch
This should be visible in `All` and `Branch` modes.
When branch compare is unavailable because the base ref is invalid, replace the normal summary with the unavailable state described above.
### Ahead / Behind Semantics
Phase 1 branch compare is primarily an "ahead of base" view derived from `git diff <baseRef>...HEAD`.
That means:
- changed-file results represent changes reachable from `HEAD` since the merge base
- `commits ahead` is the required branch-topology metric in v1
- `behind` or `diverged` indicators are optional in v1 and must not block landing
Because of this, the UI must not claim the branch "matches `<baseRef>`" unless the implementation has explicitly computed that stronger condition.
## Changes View Behavior
### All
Show two top-level sections:
- `Uncommitted`
- `Committed on Branch`
`Uncommitted` contains:
- `Staged Changes`
- `Changes`
- `Untracked Files`
`Committed on Branch` contains:
- all files changed in `baseRef...HEAD`
Ordering:
1. Uncommitted section first
2. Committed on Branch second
Rationale:
- local in-progress work is usually more actionable
- branch-level history remains visible even when local state is clean
### Uncommitted
Show only working tree/index state.
Keep existing actions:
- stage
- unstage
- discard
### Branch
Show only `baseRef...HEAD` changed files.
Actions are compare-oriented, not working-tree-oriented:
- open file diff against base
- open combined branch diff
- change base ref
- retry branch compare when unavailable
Do not show stage, unstage, or discard actions in `Branch`.
## Empty State Rules
Do not show "No changes detected" unless both conditions are true:
- there are no uncommitted changes
- branch compare is available and there are no branch changes relative to base
### Empty State Copy
If no uncommitted changes exist but branch changes do exist:
- heading: `No uncommitted changes`
- supporting text: `<n> files changed on this branch since <baseRef>`
If neither kind of change exists:
- heading: `No changes on this branch`
- supporting text: `This worktree is clean and this branch has no changes ahead of <baseRef>`
If branch compare is unavailable:
- keep the uncommitted section visible if it has entries
- do not collapse the whole panel to a generic empty state
## Diff Semantics
The doc must define exact left and right sides so the same path can appear in multiple sections without ambiguity.
### Unstaged Diff
Used when opening an entry from `Changes`.
- left: index if present, otherwise `HEAD`
- right: working tree
Required v1 behavior:
- do not reuse a `HEAD -> working tree` diff for unstaged entries when an index version exists
- if a file has staged and unstaged changes, the `Changes` entry must show only the unstaged delta
- implement this with an explicit `index -> working tree` loader path in main-process git code
### Staged Diff
Used when opening an entry from `Staged Changes`.
- left: `HEAD`
- right: index
### Branch Diff
Used when opening an entry from `Committed on Branch` or `Branch`.
- left: merge-base of `<baseRef>` and `HEAD`
- right: `HEAD`
This is the per-file interpretation of `git diff <baseRef>...HEAD`.
Branch diff must load content from the resolved compare snapshot, not from symbolic refs at render time.
Required v1 behavior:
- branch diff content queries use the resolved `mergeBase` oid and `headOid` captured in `GitBranchCompareSummary`
- do not re-resolve `HEAD` while loading a branch diff tab
- if `HEAD` moves later, an existing branch diff tab may remain open, but its identity and content must continue to reflect the snapshot it was opened from until the user refreshes or reopens against the newer snapshot
#### Branch Diff File Resolution
Branch diff cannot reuse the working-tree diff loader as-is.
For branch compare entries:
- `modified` / `added`: read left content from the merge-base tree and right content from the resolved `headOid` tree
- `deleted`: read left content from the merge-base tree and use empty content on the right
- `renamed`: read left content from `oldPath` in the merge-base tree and right content from `path` in the resolved `headOid` tree
- `copied`: read left content from `oldPath` in the merge-base tree and right content from `path` in the resolved `headOid` tree
If a file also has local uncommitted edits, branch diff must still render the committed branch comparison only. It must not silently substitute working-tree content on the right side.
### Combined Uncommitted Diff
Used by `View All Changes` in `Uncommitted`.
- includes staged and unstaged entries
- may continue to omit untracked files in v1 if the existing combined diff viewer does so
### Combined Branch Diff
Used by `View All Changes` in `Branch`.
- includes files from `git diff --name-status <baseRef>...HEAD`
- each section uses branch diff semantics
- is read-only in v1
### Combined All Diff
Used by `View All Changes` in `All`.
v1 behavior:
- if uncommitted entries exist, open the combined uncommitted diff by default
- if no uncommitted entries exist and branch compare is available, open the combined branch diff by default
- provide a visible secondary action to switch to the other combined diff when both data sets are available
This is intentionally the v1 contract. A true mixed combined view is deferred.
## File Status Semantics
The same status letters should not mean different things in different parts of the app without a label.
### Uncommitted Statuses
These keep the existing meanings:
- `M` modified
- `A` added
- `D` deleted
- `R` renamed
- `?` untracked
These are working tree or index states.
### Branch Statuses
These represent compare-to-base states, not local edit state.
They may reuse the same letters in the Source Control branch section if clearly labeled under `Committed on Branch` or `Branch`, because the section title provides the necessary context.
They must not silently replace Explorer decorations.
## Precedence Rules
When a file appears in both uncommitted and branch-compare results:
- in Source Control `All`, show it in both relevant sections
- in Explorer, show only uncommitted decoration by default
- when opened from an uncommitted section, open uncommitted diff semantics
- when opened from a branch section, open branch diff semantics
- when opened from generic file navigation, prefer working tree edit or uncommitted diff over branch diff
Branch and uncommitted diff tabs must have distinct tab identities. Do not key both off only `filePath + staged/unstaged`.
### Tab Identity Requirement
This must be explicit in the implementation contract because the current editor model keys diffs too loosely for this feature.
Minimum tab identity dimensions:
- diff source: `unstaged` | `staged` | `branch` | `combined-uncommitted` | `combined-branch`
- worktree id
- file path
- base ref for branch compare tabs
- compare version for branch compare tabs, derived from the resolved compare snapshot
Examples:
- uncommitted file diff: `<worktreeId>::diff::unstaged::<path>`
- staged file diff: `<worktreeId>::diff::staged::<path>`
- branch file diff: `<worktreeId>::diff::branch::<baseRef>::<compareVersion>::<path>`
- combined uncommitted diff: `<worktreeId>::all-diffs::uncommitted`
- combined branch diff: `<worktreeId>::all-diffs::branch::<baseRef>::<compareVersion>`
Without this, opening the same file from different sections will collide and produce incorrect editor reuse.
`compareVersion` must change whenever the branch compare snapshot changes in a way that affects diff content.
Minimum required inputs:
- `baseRef`
- resolved base oid
- resolved `HEAD` oid
- resolved `mergeBase` oid
This may be implemented either by:
- including `HEAD` and/or `mergeBase` in the tab id directly, or
- invalidating and regenerating all open branch compare tabs whenever a refreshed compare snapshot changes either value
Phase 1 must choose one of these approaches explicitly. Reusing a branch diff tab keyed only by `baseRef` is not correct.
## File Explorer Rules
### Principle
File Explorer should remain conservative and readable.
Per-file Explorer badges should represent local SCM state by default, not all files changed on the branch.
This matches the useful part of VS Code's behavior: Explorer decorations come from SCM resource groups for current working state, not generic branch compare.
### Default Explorer Behavior
Show per-file decorations only for:
- staged
- unstaged
- untracked
- conflicts
Do not show branch-diff-only files with normal `M/A/D/R` Explorer badges when the worktree is clean.
Reason:
- users read Explorer badges as "this file is currently dirty"
- branch compare files are a different concept
- reusing the same badges for both creates ambiguity and noise
### Explorer Branch Awareness
Branch compare may still influence the Explorer only at higher-level summary surfaces:
- Source Control tab header badge
- worktree header pill
- compare summary row inside Source Control
Examples:
- `12 changed`
- `3 commits ahead`
- `Diff vs origin/main`
### Explorer Interaction Design
When the selected file has uncommitted changes:
- primary action should open working tree diff
When the selected file has no uncommitted changes but is changed on the branch:
- primary file-open behavior should remain normal edit open
- context menu may offer `Open Branch Diff`
When the file has both:
- primary action should open working tree diff from Source Control
- secondary action should allow compare vs base
Priority rule:
1. working tree diff
2. staged diff if explicitly selected from staged section
3. branch compare diff when explicitly requested
### Optional Future Setting
If branch compare decorations are added to Explorer later, they should be:
- off by default
- visually distinct from uncommitted badges
- clearly labeled as branch compare state
Do not reuse the exact same badge style as local SCM state.
## Folder Aggregation Rules
Folder styling in Explorer should aggregate uncommitted state only in v1.
Do not turn entire directory trees into "changed" folders just because files differ from base.
If desired, branch-level aggregation can appear in a separate summary surface:
- Source Control section counts
- worktree header pill
- branch compare summary row
## Data Model Recommendation
Keep the state separate.
Recommended types:
```ts
type GitUncommittedEntry = {
path: string
status: 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked' | 'copied'
area: 'staged' | 'unstaged' | 'untracked'
oldPath?: string
}
type GitBranchChangeEntry = {
path: string
status: 'modified' | 'added' | 'deleted' | 'renamed' | 'copied'
oldPath?: string
}
type GitBranchCompareSummary = {
baseRef: string
baseOid: string
compareRef: string
headOid: string
mergeBase: string
changedFiles: number
commitsAhead?: number
status: 'ready' | 'invalid-base' | 'unborn-head' | 'no-merge-base' | 'loading' | 'error'
errorMessage?: string
}
```
Recommended store shape:
```ts
gitStatusByWorktree: Record<string, GitUncommittedEntry[]>
gitBranchChangesByWorktree: Record<string, GitBranchChangeEntry[]>
gitBranchCompareSummaryByWorktree: Record<string, GitBranchCompareSummary>
```
Do not overload a single `GitStatusEntry[]` to represent both concepts.
### Async Consistency Requirement
Branch compare refresh is asynchronous and may be triggered repeatedly while the user changes worktrees, changes base refs, or moves `HEAD`.
Phase 1 must prevent stale compare results from overwriting newer ones.
Required implementation contract:
- each branch-compare request carries a request token or snapshot key
- reducer/store writes only apply if the response still matches the latest in-flight request for that worktree
- the snapshot key must include at least `worktreeId` and requested `baseRef`
- if the implementation already knows the triggering `baseOid` and/or `HEAD` oid, include them as well
This is required so a slower response for an old base ref or old branch state cannot replace a newer compare result.
## Git Queries
### Uncommitted
Keep the current status query:
```sh
git status --porcelain=v2 --untracked-files=all
```
### Branch Compare
Recommended query sequence:
1. Resolve `HEAD`
```sh
git rev-parse HEAD
```
2. Resolve and validate base ref
```sh
git rev-parse --verify <baseRef>
```
3. Resolve merge base from the pinned oids
```sh
git merge-base <baseOid> <headOid>
```
4. Load changed files from the pinned snapshot
```sh
git diff --name-status -M -C <mergeBase> <headOid>
```
5. Load ahead count from the pinned snapshot
```sh
git rev-list --count <baseOid>..<headOid>
```
Notes:
- `...` is important because it compares from merge base
- use repo-configured base ref, not a hardcoded branch name
- use `-M -C` so the query can actually produce the rename/copy statuses promised by the data model
- execute git with argv via `execFile` or equivalent, not shell-interpolated strings, because `<baseRef>` is user-configurable input
- store the resolved `baseOid`, `HEAD` oid, and `mergeBase` in the compare summary so UI identity and invalidation can key off the actual compare snapshot
- if step 1 fails because `HEAD` is unborn, branch compare enters the unavailable `unborn-head` state instead of generic error
- if step 2 fails, branch compare enters the unavailable `invalid-base` state instead of pretending there are zero changes
- if step 3 fails because the refs have no merge base, branch compare enters the unavailable `no-merge-base` state instead of generic error
- the changed-files query, per-file branch diff, and combined branch diff must all use the same resolved snapshot inputs: `baseRef`, `baseOid`, `headOid`, and `mergeBase`
- do not mix a file list produced from symbolic refs with per-file content produced from pinned oids; the summary, list, and file content must describe the same snapshot
### Snapshot Contract
Branch compare v1 must be snapshot-based, not "latest ref at render time."
Required contract:
- first resolve `headOid`
- then resolve `baseOid`
- then resolve `mergeBase` against those exact pinned oids
- derive the changed-file list, ahead count, per-file branch diff content, and combined branch diff content from those pinned values
- persist `baseRef`, `baseOid`, `headOid`, and `mergeBase` together as the compare snapshot
If `HEAD` or `<baseRef>` moves while the query is in flight:
- the in-flight result may be discarded as stale
- the UI must not combine old file-list data with new per-file content or vice versa
### Unborn HEAD Handling
Branch compare depends on a resolvable `HEAD`.
If the repository or worktree has no commits yet, or `HEAD` otherwise cannot be resolved:
- do not treat that as "no branch changes"
- keep uncommitted changes fully functional
- show the branch compare surface in an unavailable state distinct from invalid-base
- preserve the same recovery affordances as other unavailable states where applicable
Recommended copy:
- heading: `Branch compare unavailable`
- supporting text: `This branch does not have a committed HEAD yet, so compare-to-base is unavailable.`
- actions: `Retry`
### No Merge Base Handling
Branch compare also depends on `HEAD` and `<baseRef>` sharing a merge base.
If both refs resolve but `git merge-base <baseOid> <headOid>` fails because the histories are unrelated:
- do not treat that as "no branch changes"
- keep uncommitted changes fully functional
- show the branch compare surface in an unavailable state distinct from invalid-base and unborn-head
- preserve the same recovery affordances as other unavailable states where applicable
Recommended copy:
- heading: `Branch compare unavailable`
- supporting text: `This branch and <baseRef> do not share a merge base, so compare-to-base is unavailable.`
- actions: `Change Base Ref`, `Retry`
### Branch Diff Content Query
Per-file branch diff content needs a dedicated path-aware query path in main-process git code.
Recommended primitives:
```sh
git show <mergeBase>:<path>
git show <headOid>:<path>
```
Use `oldPath` on the merge-base side for renames and copies. Missing blobs should resolve to empty content rather than hard failure so added/deleted files render correctly.
### Unstaged Diff Content Query
Per-file unstaged diff content should also use dedicated git primitives rather than assuming `HEAD` on the left side.
Recommended primitives:
```sh
git show :<path>
git show HEAD:<path>
```
Rules:
- for unstaged entries, prefer index content on the left side
- if the path is not present in the index, fall back to `HEAD`
- read working-tree content from disk for the right side
- if the file is deleted in the working tree, the right side is empty
- for renamed unstaged entries, use `oldPath` for the left-side lookup when required by the parsed status entry
## Refresh Rules
Branch compare data should not be recomputed on the same fixed loop as `git status`.
Instead, branch compare should refresh on explicit invalidation events:
- active worktree changes
- Source Control tab becomes visible for the active worktree
- app startup hydration for the active worktree
- repo base ref changes
- explicit user action: `Retry`
- after fetch or any other operation that updates the resolved compare base ref, even if `HEAD` does not move
- after operations that may change `HEAD` or branch topology:
- commit
- amend
- checkout / switch
- merge
- rebase
- cherry-pick
- pull
- reset that changes `HEAD`
Refresh does not need to run after pure working-tree mutations such as:
- stage
- unstage
- discard
- editing files without creating a commit
because those operations do not change `baseRef...HEAD`.
Refreshing after fetch is required because `baseRef...HEAD` changes when the base ref moves, even if `HEAD` stays on the same commit.
### Runtime Freshness Requirement
The app cannot rely only on app-owned git operations for freshness because users may commit, rebase, fetch, or switch branches from the embedded terminal or other external tools.
Required v1 contract:
- branch compare refresh remains primarily event-driven
- when the `Source Control` panel is visible for the active worktree, the app must also run a lightweight compare-snapshot freshness check on an interval
- that check may be cheaper than a full branch compare refresh; it only needs to detect whether `headOid` or `baseOid` has changed
- if the freshness check detects a change, trigger a full branch compare refresh
- polling may stop when `Source Control` is not the visible right-sidebar tab
This keeps the visible branch compare state from going stale during terminal-driven git activity without paying the full compare cost continuously in the background.
The implementation may debounce or coalesce refresh triggers fired in quick succession. The important contract is: visible branch compare state must converge automatically after external git activity, not only after explicit user actions.
## Base Ref Recovery Path
`Change Base Ref` should reuse the existing repo base-ref management surface instead of inventing a second editor for the same setting.
Required v1 behavior:
- activating `Change Base Ref` opens a modal or sheet that reuses the existing repository base-ref search-and-select UI logic
- the control logic should be shared with the repository settings implementation rather than duplicated
- it must not navigate the user away from `Source Control` into the full Settings screen just to recover from an invalid base ref
- after the user picks a new base ref, branch compare refreshes immediately for the active worktree
- if the user cancels, keep the current unavailable state visible
This keeps base-ref editing in one canonical implementation while still making the recovery path direct from Source Control.
## Binary File Handling
Branch compare and combined diff must define non-text behavior explicitly.
This requires a diff payload contract richer than the current text-only `{ originalContent, modifiedContent }` shape.
Recommended payload shape:
```ts
type GitDiffTextResult = {
kind: 'text'
originalContent: string
modifiedContent: string
}
type GitDiffBinaryResult = {
kind: 'binary'
originalIsBinary: boolean
modifiedIsBinary: boolean
}
type GitDiffResult = GitDiffTextResult | GitDiffBinaryResult
```
The same union may be reused for uncommitted and branch diff loaders. Branch diff metadata such as file status and compare context should travel separately in the branch entry / compare summary models rather than being embedded in the diff payload.
For per-file branch diff and combined branch diff:
- if either side resolves to binary content, do not attempt to render a text diff in Monaco
- show a binary-file placeholder row instead
- include:
- file path
- branch compare status (`added`, `modified`, `deleted`, `renamed`, or `copied`)
- compare context (`<baseRef>...HEAD`)
Recommended copy:
- title: `Binary file changed`
- supporting text: `Text diff is unavailable for this file in branch compare.`
For mixed repositories, binary files should still count toward changed-file totals and remain visible in section lists.
v1 refresh behavior:
- refresh uncommitted status on the existing poll loop
- refresh branch compare on worktree switch
- refresh branch compare when the Source Control panel first mounts for that worktree
- refresh branch compare after any operation that may move `HEAD`
- refresh branch compare after base-ref change
- while the Source Control panel is visible, run the lightweight freshness check described above so terminal-driven git activity is detected automatically
- provide a manual `Retry` or refresh action in the compare summary area
This keeps branch compare reasonably fresh without forcing a costly `git diff <baseRef>...HEAD` loop every few seconds.
## Loading And Error States
The panel must distinguish these states explicitly:
- `loading`: branch compare summary shows loading treatment; `All` still shows uncommitted sections if present
- `invalid-base`: use the unavailable state defined above
- `unborn-head`: use the unavailable state defined above for missing committed `HEAD`
- `no-merge-base`: use the unavailable state defined above for unrelated histories
- `error`: show `Branch compare failed` with retry action and preserve uncommitted sections
Do not reuse the generic empty state for `loading` or `error`.
## Visual Design Guidance
### Source Control
- Keep section headers compact and count-based
- Make the compare summary always visible in `All` and `Branch`
- Use explicit labels like `Committed on Branch` instead of vague labels like `Other Changes`
- Keep unavailable and loading states distinct from empty states
### Explorer
- Keep badges sparse
- Favor summary pills over duplicative per-file branch markers
- Avoid a second noisy alphabet of overlapping status badges
## Implementation Plan
Ship this in two phases, but make Phase 1 complete and shippable on its own.
### Phase 1
- Add branch compare data model and IPC surface
- Add base-ref validation and unavailable-state UI
- Update Source Control to show `All`, `Uncommitted`, `Branch`
- Add compare summary bar
- Add per-file branch diff open behavior
- Add read-only combined branch diff viewer
- Make `View All Changes` scope-aware
- Fix empty state logic
- Keep Explorer decorations uncommitted-only
- Pin branch compare to resolved snapshot oids and use the same snapshot for summary, list, and diff content
- Add visible-tab freshness detection so external terminal git activity refreshes branch compare automatically
- Reuse the repo base-ref picker logic in a Source Control recovery modal/sheet
- Upgrade diff IPC/result types so binary branch diffs are representable without ad hoc UI guesses
- Treat PR pill as optional enrichment only when branch PR data is already available; do not make GitHub lookup a phase-1 blocker
- Include explicit compare-snapshot invalidation/versioning so branch diff tabs cannot go stale across base-ref or `HEAD` changes
Phase 1 is the required landing scope.
### Phase 2
- Add a true mixed combined diff that renders both branch and uncommitted sections in one viewer
- Add commit summary or commit dropdown
- Optionally add a distinct Explorer branch-compare mode behind a setting
## Decisions
- Source Control should show both uncommitted and branch-level changes.
- The feature lives inside the existing `Source Control` sidebar tab.
- File Explorer should continue to show local SCM state only by default.
- Branch compare should be visible in summary surfaces, not normal per-file Explorer badges.
- Invalid base refs must produce an explicit unavailable state, not an empty state.
- Phase 1 includes minimal but complete branch diff viewing so the feature can land in one go.

View File

@ -20,7 +20,7 @@ vi.mock('fs/promises', () => ({
rm: rmMock
}))
import { discardChanges, getDiff, isWithinWorktree } from './status'
import { discardChanges, getBranchCompare, getDiff, isWithinWorktree } from './status'
describe('discardChanges', () => {
beforeEach(() => {
@ -88,39 +88,135 @@ describe('getDiff', () => {
readFileMock.mockReset()
})
it('returns base64 payloads for unstaged image diffs', async () => {
execFileAsyncMock.mockResolvedValueOnce({
stdout: Buffer.from([0x89, 0x50, 0x4e, 0x47])
})
readFileMock.mockResolvedValueOnce(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]))
it('uses the index as the left side for unstaged diffs when present', async () => {
execFileAsyncMock.mockResolvedValueOnce({ stdout: Buffer.from('index-content\n') })
readFileMock.mockResolvedValue(Buffer.from('working-tree-content'))
await expect(getDiff('/repo', 'image.png', false)).resolves.toEqual({
originalContent: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64'),
modifiedContent: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]).toString('base64'),
isImage: true,
mimeType: 'image/png'
})
const result = await getDiff('/repo', 'src/file.ts', false)
expect(execFileAsyncMock).toHaveBeenCalledWith('git', ['show', 'HEAD:image.png'], {
cwd: '/repo',
encoding: 'buffer',
maxBuffer: 10 * 1024 * 1024
expect(execFileAsyncMock).toHaveBeenCalledWith(
'git',
['show', ':src/file.ts'],
expect.objectContaining({
cwd: '/repo',
encoding: 'buffer',
maxBuffer: 10 * 1024 * 1024
})
)
expect(readFileMock).toHaveBeenCalledWith('/repo/src/file.ts')
expect(result).toEqual({
kind: 'text',
originalContent: 'index-content\n',
modifiedContent: 'working-tree-content',
originalIsBinary: false,
modifiedIsBinary: false
})
expect(readFileMock).toHaveBeenCalledWith('/repo/image.png')
})
it('returns base64 payloads for staged image diffs', async () => {
it('falls back to HEAD for unstaged diffs when the file is not in the index', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: Buffer.from([0x01, 0x02]) })
.mockResolvedValueOnce({ stdout: Buffer.from([0x03, 0x04]) })
.mockRejectedValueOnce(new Error('missing index'))
.mockResolvedValueOnce({ stdout: Buffer.from('head-content\n') })
readFileMock.mockResolvedValue(Buffer.from('working-tree-content'))
await expect(getDiff('/repo', 'image.png', true)).resolves.toEqual({
originalContent: Buffer.from([0x01, 0x02]).toString('base64'),
modifiedContent: Buffer.from([0x03, 0x04]).toString('base64'),
isImage: true,
mimeType: 'image/png'
})
const result = await getDiff('/repo', 'src/file.ts', false)
expect(readFileMock).not.toHaveBeenCalled()
expect(execFileAsyncMock).toHaveBeenNthCalledWith(
2,
'git',
['show', 'HEAD:src/file.ts'],
expect.objectContaining({
cwd: '/repo',
encoding: 'buffer',
maxBuffer: 10 * 1024 * 1024
})
)
expect(result.originalContent).toBe('head-content\n')
expect(result.modifiedContent).toBe('working-tree-content')
})
it('marks binary content in the diff payload', async () => {
execFileAsyncMock.mockResolvedValueOnce({ stdout: Buffer.from([0x00, 0x61, 0x62]) })
readFileMock.mockResolvedValue(Buffer.from('working-tree-content'))
const result = await getDiff('/repo', 'src/file.bin', false)
expect(result.kind).toBe('binary')
expect(result.originalIsBinary).toBe(true)
expect(result.modifiedIsBinary).toBe(false)
})
})
describe('getBranchCompare', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
readFileMock.mockReset()
})
it('returns a pinned branch compare snapshot and parsed branch entries', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockResolvedValueOnce({ stdout: 'head-oid\n' })
.mockResolvedValueOnce({ stdout: 'base-oid\n' })
.mockResolvedValueOnce({ stdout: 'merge-base-oid\n' })
.mockResolvedValueOnce({
stdout: 'M\tfile-a.ts\nR100\told-name.ts\tnew-name.ts\nC100\told-copy.ts\tnew-copy.ts\n'
})
.mockResolvedValueOnce({ stdout: '7\n' })
const result = await getBranchCompare('/repo', 'origin/main')
expect(result.summary).toEqual({
baseRef: 'origin/main',
baseOid: 'base-oid',
compareRef: 'main',
headOid: 'head-oid',
mergeBase: 'merge-base-oid',
changedFiles: 3,
commitsAhead: 7,
status: 'ready'
})
expect(result.entries).toEqual([
{ path: 'file-a.ts', status: 'modified' },
{ path: 'new-name.ts', oldPath: 'old-name.ts', status: 'renamed' },
{ path: 'new-copy.ts', oldPath: 'old-copy.ts', status: 'copied' }
])
})
it('returns invalid-base when the compare ref does not resolve', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockResolvedValueOnce({ stdout: 'head-oid\n' })
.mockRejectedValueOnce(new Error('missing base'))
const result = await getBranchCompare('/repo', 'origin/missing')
expect(result.summary.status).toBe('invalid-base')
expect(result.summary.errorMessage).toContain('origin/missing')
expect(result.entries).toEqual([])
})
it('returns unborn-head when HEAD cannot be resolved', async () => {
execFileAsyncMock.mockRejectedValueOnce(new Error('unborn'))
const result = await getBranchCompare('/repo', 'origin/main')
expect(result.summary.status).toBe('unborn-head')
expect(result.summary.errorMessage).toContain('committed HEAD')
expect(result.entries).toEqual([])
})
it('returns no-merge-base when histories do not intersect', async () => {
execFileAsyncMock
.mockResolvedValueOnce({ stdout: 'main\n' })
.mockResolvedValueOnce({ stdout: 'head-oid\n' })
.mockResolvedValueOnce({ stdout: 'base-oid\n' })
.mockRejectedValueOnce(new Error('no merge base'))
const result = await getBranchCompare('/repo', 'origin/main')
expect(result.summary.status).toBe('no-merge-base')
expect(result.summary.errorMessage).toContain('merge base')
expect(result.entries).toEqual([])
})
})

View File

@ -1,20 +1,20 @@
/* eslint-disable max-lines */
import { execFile } from 'child_process'
import { readFile, rm } from 'fs/promises'
import { promisify } from 'util'
import * as path from 'path'
import type { GitStatusEntry, GitFileStatus, GitDiffResult } from '../../shared/types'
import type {
GitBranchChangeEntry,
GitBranchChangeStatus,
GitBranchCompareResult,
GitBranchCompareSummary,
GitDiffResult,
GitFileStatus,
GitStatusEntry
} from '../../shared/types'
const execFileAsync = promisify(execFile)
const IMAGE_MIME_TYPES: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon'
}
const MAX_GIT_SHOW_BYTES = 10 * 1024 * 1024
/**
* Parse `git status --porcelain=v2` output into structured entries.
@ -97,6 +97,23 @@ function parseStatusChar(char: string): GitFileStatus {
}
}
function parseBranchStatusChar(char: string): GitBranchChangeStatus {
switch (char) {
case 'M':
return 'modified'
case 'A':
return 'added'
case 'D':
return 'deleted'
case 'R':
return 'renamed'
case 'C':
return 'copied'
default:
return 'modified'
}
}
/**
* Get original and modified content for diffing a file.
*/
@ -105,91 +122,328 @@ export async function getDiff(
filePath: string,
staged: boolean
): Promise<GitDiffResult> {
const mimeType = IMAGE_MIME_TYPES[path.extname(filePath).toLowerCase()]
if (mimeType) {
const originalBuffer = await readGitFileVersion(worktreePath, filePath, 'head')
const modifiedBuffer = staged
? await readGitFileVersion(worktreePath, filePath, 'index')
: await readWorkingTreeFile(worktreePath, filePath)
return {
originalContent: originalBuffer?.toString('base64') ?? '',
modifiedContent: modifiedBuffer?.toString('base64') ?? '',
isImage: true,
mimeType
}
}
let originalContent = ''
let modifiedContent = ''
let originalIsBinary = false
let modifiedIsBinary = false
try {
// Get original content (HEAD version)
try {
const { stdout } = await execFileAsync('git', ['show', `HEAD:${filePath}`], {
cwd: worktreePath,
encoding: 'utf-8',
maxBuffer: 10 * 1024 * 1024
})
originalContent = stdout
} catch {
// File is new (no HEAD version)
originalContent = ''
}
const leftBlob = staged
? await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath)
: await readUnstagedLeftBlob(worktreePath, filePath)
originalContent = leftBlob.content
originalIsBinary = leftBlob.isBinary
if (staged) {
// Staged: modified is the index version
try {
const { stdout } = await execFileAsync('git', ['show', `:${filePath}`], {
cwd: worktreePath,
encoding: 'utf-8',
maxBuffer: 10 * 1024 * 1024
})
modifiedContent = stdout
} catch {
modifiedContent = ''
}
const rightBlob = await readGitBlobAtIndexPath(worktreePath, filePath)
modifiedContent = rightBlob.content
modifiedIsBinary = rightBlob.isBinary
} else {
// Unstaged: modified is the working tree version
try {
modifiedContent = await readFile(path.join(worktreePath, filePath), 'utf-8')
} catch {
modifiedContent = ''
}
const workingTreeBlob = await readWorkingTreeFile(path.join(worktreePath, filePath))
modifiedContent = workingTreeBlob.content
modifiedIsBinary = workingTreeBlob.isBinary
}
} catch {
// Fallback
}
return { originalContent, modifiedContent }
return buildDiffResult(originalContent, modifiedContent, originalIsBinary, modifiedIsBinary)
}
async function readGitFileVersion(
export async function getBranchCompare(
worktreePath: string,
filePath: string,
source: 'head' | 'index'
): Promise<Buffer | null> {
baseRef: string
): Promise<GitBranchCompareResult> {
const summary: GitBranchCompareSummary = {
baseRef,
baseOid: null,
compareRef: 'HEAD',
headOid: null,
mergeBase: null,
changedFiles: 0,
status: 'loading'
}
const compareRef = await resolveCompareRef(worktreePath)
summary.compareRef = compareRef
let headOid = ''
try {
const objectSpec = source === 'head' ? `HEAD:${filePath}` : `:${filePath}`
const { stdout } = await execFileAsync('git', ['show', objectSpec], {
headOid = await resolveRefOid(worktreePath, 'HEAD')
summary.headOid = headOid
} catch {
summary.status = 'unborn-head'
summary.errorMessage =
'This branch does not have a committed HEAD yet, so compare-to-base is unavailable.'
return { summary, entries: [] }
}
let baseOid = ''
try {
baseOid = await resolveRefOid(worktreePath, baseRef)
summary.baseOid = baseOid
} catch {
summary.status = 'invalid-base'
summary.errorMessage = `Base ref ${baseRef} could not be resolved in this repository.`
return { summary, entries: [] }
}
let mergeBase = ''
try {
mergeBase = await resolveMergeBase(worktreePath, baseOid, headOid)
summary.mergeBase = mergeBase
} catch {
summary.status = 'no-merge-base'
summary.errorMessage = `This branch and ${baseRef} do not share a merge base, so compare-to-base is unavailable.`
return { summary, entries: [] }
}
try {
const entries = await loadBranchChanges(worktreePath, mergeBase, headOid)
const commitsAhead = await countAheadCommits(worktreePath, baseOid, headOid)
summary.changedFiles = entries.length
summary.commitsAhead = commitsAhead
summary.status = 'ready'
return { summary, entries }
} catch (error) {
summary.status = 'error'
summary.errorMessage = error instanceof Error ? error.message : 'Failed to load branch compare'
return { summary, entries: [] }
}
}
export async function getBranchDiff(
worktreePath: string,
args: {
headOid: string
mergeBase: string
filePath: string
oldPath?: string
}
): Promise<GitDiffResult> {
try {
const leftPath = args.oldPath ?? args.filePath
const leftBlob = await readGitBlobAtOidPath(worktreePath, args.mergeBase, leftPath)
const rightBlob = await readGitBlobAtOidPath(worktreePath, args.headOid, args.filePath)
return buildDiffResult(
leftBlob.content,
rightBlob.content,
leftBlob.isBinary,
rightBlob.isBinary
)
} catch {
return {
kind: 'text',
originalContent: '',
modifiedContent: '',
originalIsBinary: false,
modifiedIsBinary: false
}
}
}
async function loadBranchChanges(
worktreePath: string,
mergeBase: string,
headOid: string
): Promise<GitBranchChangeEntry[]> {
const { stdout } = await execFileAsync(
'git',
['diff', '--name-status', '-M', '-C', mergeBase, headOid],
{
cwd: worktreePath,
encoding: 'utf-8',
maxBuffer: MAX_GIT_SHOW_BYTES
}
)
const entries: GitBranchChangeEntry[] = []
for (const line of stdout.split('\n')) {
if (!line) {
continue
}
const entry = parseBranchChangeLine(line)
if (entry) {
entries.push(entry)
}
}
return entries
}
function parseBranchChangeLine(line: string): GitBranchChangeEntry | null {
const parts = line.split('\t')
const rawStatus = parts[0] ?? ''
const status = parseBranchStatusChar(rawStatus[0] ?? 'M')
if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) {
const oldPath = parts[1]
const path = parts[2]
if (!path) {
return null
}
return { path, oldPath, status }
}
const path = parts[1]
if (!path) {
return null
}
return { path, status }
}
async function resolveCompareRef(worktreePath: string): Promise<string> {
try {
const { stdout } = await execFileAsync('git', ['branch', '--show-current'], {
cwd: worktreePath,
encoding: 'utf-8'
})
const branch = stdout.trim()
return branch || 'HEAD'
} catch {
return 'HEAD'
}
}
async function resolveRefOid(worktreePath: string, ref: string): Promise<string> {
const { stdout } = await execFileAsync('git', ['rev-parse', '--verify', ref], {
cwd: worktreePath,
encoding: 'utf-8'
})
return stdout.trim()
}
async function resolveMergeBase(
worktreePath: string,
baseOid: string,
headOid: string
): Promise<string> {
const { stdout } = await execFileAsync('git', ['merge-base', baseOid, headOid], {
cwd: worktreePath,
encoding: 'utf-8'
})
return stdout.trim()
}
async function countAheadCommits(
worktreePath: string,
baseOid: string,
headOid: string
): Promise<number> {
const { stdout } = await execFileAsync('git', ['rev-list', '--count', `${baseOid}..${headOid}`], {
cwd: worktreePath,
encoding: 'utf-8'
})
return Number.parseInt(stdout.trim(), 10) || 0
}
async function readUnstagedLeftBlob(
worktreePath: string,
filePath: string
): Promise<GitBlobReadResult> {
const indexBlob = await readGitBlobAtIndexPath(worktreePath, filePath)
if (indexBlob.exists) {
return indexBlob
}
return readGitBlobAtOidPath(worktreePath, 'HEAD', filePath)
}
async function readGitBlobAtIndexPath(
worktreePath: string,
filePath: string
): Promise<GitBlobReadResult> {
try {
const { stdout } = (await execFileAsync('git', ['show', `:${filePath}`], {
cwd: worktreePath,
encoding: 'buffer',
maxBuffer: 10 * 1024 * 1024
})
return stdout
maxBuffer: MAX_GIT_SHOW_BYTES
})) as { stdout: Buffer }
return { ...bufferToBlob(stdout), exists: true }
} catch {
return null
return { content: '', isBinary: false, exists: false }
}
}
async function readWorkingTreeFile(worktreePath: string, filePath: string): Promise<Buffer | null> {
async function readGitBlobAtOidPath(
worktreePath: string,
oid: string,
filePath: string
): Promise<GitBlobReadResult> {
try {
return await readFile(path.join(worktreePath, filePath))
const { stdout } = (await execFileAsync('git', ['show', `${oid}:${filePath}`], {
cwd: worktreePath,
encoding: 'buffer',
maxBuffer: MAX_GIT_SHOW_BYTES
})) as { stdout: Buffer }
return { ...bufferToBlob(stdout), exists: true }
} catch {
return null
return { content: '', isBinary: false, exists: false }
}
}
async function readWorkingTreeFile(filePath: string): Promise<GitBlobReadResult> {
try {
const buffer = await readFile(filePath)
return bufferToBlob(buffer)
} catch {
return { content: '', isBinary: false, exists: false }
}
}
function bufferToBlob(buffer: Buffer): GitBlobReadResult {
const isBinary = isBinaryBuffer(buffer)
return {
content: isBinary ? '' : buffer.toString('utf-8'),
isBinary,
exists: true
}
}
function isBinaryBuffer(buffer: Buffer): boolean {
const len = Math.min(buffer.length, 8192)
for (let i = 0; i < len; i += 1) {
if (buffer[i] === 0) {
return true
}
}
return false
}
function buildDiffResult(
originalContent: string,
modifiedContent: string,
originalIsBinary: boolean,
modifiedIsBinary: boolean
): GitDiffResult {
if (originalIsBinary || modifiedIsBinary) {
return {
kind: 'binary',
originalContent,
modifiedContent,
originalIsBinary,
modifiedIsBinary
} as GitDiffResult
}
return {
kind: 'text',
originalContent,
modifiedContent,
originalIsBinary: false,
modifiedIsBinary: false
}
}
type GitBlobReadResult = {
content: string
isBinary: boolean
exists: boolean
}
/**
* Stage a file.
*/

View File

@ -11,6 +11,8 @@ const {
lstatMock,
getStatusMock,
getDiffMock,
getBranchCompareMock,
getBranchDiffMock,
stageFileMock,
unstageFileMock,
discardChangesMock,
@ -25,6 +27,8 @@ const {
lstatMock: vi.fn(),
getStatusMock: vi.fn(),
getDiffMock: vi.fn(),
getBranchCompareMock: vi.fn(),
getBranchDiffMock: vi.fn(),
stageFileMock: vi.fn(),
unstageFileMock: vi.fn(),
discardChangesMock: vi.fn(),
@ -49,6 +53,8 @@ vi.mock('fs/promises', () => ({
vi.mock('../git/status', () => ({
getStatus: getStatusMock,
getDiff: getDiffMock,
getBranchCompare: getBranchCompareMock,
getBranchDiff: getBranchDiffMock,
stageFile: stageFileMock,
unstageFile: unstageFileMock,
discardChanges: discardChangesMock
@ -87,6 +93,8 @@ describe('registerFilesystemHandlers', () => {
lstatMock.mockReset()
getStatusMock.mockReset()
getDiffMock.mockReset()
getBranchCompareMock.mockReset()
getBranchDiffMock.mockReset()
stageFileMock.mockReset()
unstageFileMock.mockReset()
discardChangesMock.mockReset()
@ -221,4 +229,59 @@ describe('registerFilesystemHandlers', () => {
expect(getStatusMock).not.toHaveBeenCalled()
})
it('routes branch compare queries through the git compare helper', async () => {
getBranchCompareMock.mockResolvedValue({
summary: {
baseRef: 'origin/main',
baseOid: 'base-oid',
compareRef: 'main',
headOid: 'head-oid',
mergeBase: 'merge-base-oid',
changedFiles: 1,
status: 'ready'
},
entries: [{ path: 'src/file.ts', status: 'modified' }]
})
registerFilesystemHandlers(store as never)
await handlers.get('git:branchCompare')!(null, {
worktreePath: '/workspace/repo-feature',
baseRef: 'origin/main'
})
expect(getBranchCompareMock).toHaveBeenCalledWith('/workspace/repo-feature', 'origin/main')
})
it('routes branch diff queries through the pinned branch diff helper', async () => {
getBranchDiffMock.mockResolvedValue({
kind: 'text',
originalContent: 'left',
modifiedContent: 'right',
originalIsBinary: false,
modifiedIsBinary: false
})
registerFilesystemHandlers(store as never)
await handlers.get('git:branchDiff')!(null, {
worktreePath: '/workspace/repo-feature',
compare: {
baseRef: 'origin/main',
baseOid: 'base-oid',
headOid: 'head-oid',
mergeBase: 'merge-base-oid'
},
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts'
})
expect(getBranchDiffMock).toHaveBeenCalledWith('/workspace/repo-feature', {
headOid: 'head-oid',
mergeBase: 'merge-base-oid',
filePath: 'src/file.ts',
oldPath: 'src/old-file.ts'
})
})
})

View File

@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import { ipcMain } from 'electron'
import { readdir, readFile, writeFile, stat, lstat } from 'fs/promises'
import { extname, relative } from 'path'
@ -5,13 +6,22 @@ import { spawn } from 'child_process'
import type { Store } from '../persistence'
import type {
DirEntry,
GitBranchCompareResult,
GitStatusEntry,
GitDiffResult,
SearchOptions,
SearchResult,
SearchFileResult
} from '../../shared/types'
import { getStatus, getDiff, stageFile, unstageFile, discardChanges } from '../git/status'
import {
getStatus,
getDiff,
stageFile,
unstageFile,
discardChanges,
getBranchCompare,
getBranchDiff
} from '../git/status'
import {
resolveAuthorizedPath,
resolveRegisteredWorktreePath,
@ -302,6 +312,47 @@ export function registerFilesystemHandlers(store: Store): void {
}
)
ipcMain.handle(
'git:branchCompare',
async (
_event,
args: { worktreePath: string; baseRef: string }
): Promise<GitBranchCompareResult> => {
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
return getBranchCompare(worktreePath, args.baseRef)
}
)
ipcMain.handle(
'git:branchDiff',
async (
_event,
args: {
worktreePath: string
compare: {
baseRef: string
baseOid: string
headOid: string
mergeBase: string
}
filePath: string
oldPath?: string
}
): Promise<GitDiffResult> => {
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
const filePath = validateGitRelativeFilePath(worktreePath, args.filePath)
const oldPath = args.oldPath
? validateGitRelativeFilePath(worktreePath, args.oldPath)
: undefined
return getBranchDiff(worktreePath, {
mergeBase: args.compare.mergeBase,
headOid: args.compare.headOid,
filePath,
oldPath
})
}
)
ipcMain.handle(
'git:stage',
async (_event, args: { worktreePath: string; filePath: string }): Promise<void> => {

View File

@ -12,6 +12,7 @@ import type {
WorkspaceSessionState,
UpdateStatus,
DirEntry,
GitBranchCompareResult,
GitStatusEntry,
GitDiffResult,
SearchOptions,
@ -145,6 +146,21 @@ type GitApi = {
filePath: string
staged: boolean
}) => Promise<GitDiffResult>
branchCompare: (args: {
worktreePath: string
baseRef: string
}) => Promise<GitBranchCompareResult>
branchDiff: (args: {
worktreePath: string
compare: {
baseRef: string
baseOid: string
headOid: string
mergeBase: string
}
filePath: string
oldPath?: string
}) => Promise<GitDiffResult>
stage: (args: { worktreePath: string; filePath: string }) => Promise<void>
unstage: (args: { worktreePath: string; filePath: string }) => Promise<void>
discard: (args: { worktreePath: string; filePath: string }) => Promise<void>

View File

@ -248,12 +248,16 @@ const api = {
git: {
status: (args: { worktreePath: string }): Promise<unknown[]> =>
ipcRenderer.invoke('git:status', args),
diff: (args: {
worktreePath: string
filePath: string
staged: boolean
}): Promise<{ originalContent: string; modifiedContent: string }> =>
diff: (args: { worktreePath: string; filePath: string; staged: boolean }): Promise<unknown> =>
ipcRenderer.invoke('git:diff', args),
branchCompare: (args: { worktreePath: string; baseRef: string }): Promise<unknown> =>
ipcRenderer.invoke('git:branchCompare', args),
branchDiff: (args: {
worktreePath: string
compare: { baseRef: string; baseOid: string; headOid: string; mergeBase: string }
filePath: string
oldPath?: string
}): Promise<unknown> => ipcRenderer.invoke('git:branchDiff', args),
stage: (args: { worktreePath: string; filePath: string }): Promise<void> =>
ipcRenderer.invoke('git:stage', args),
unstage: (args: { worktreePath: string; filePath: string }): Promise<void> =>

View File

@ -1,99 +1,165 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { LazySection } from './LazySection'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { DiffEditor, type DiffOnMount } from '@monaco-editor/react'
import type { editor as monacoEditor } from 'monaco-editor'
import { useAppStore } from '@/store'
import { basename, dirname, joinPath } from '@/lib/path'
import { detectLanguage } from '@/lib/language-detect'
import '@/lib/monaco-setup'
import { cn } from '@/lib/utils'
import type { GitStatusEntry } from '../../../../shared/types'
import type { OpenFile } from '@/store/slices/editor'
import type { GitDiffResult, GitStatusEntry } from '../../../../shared/types'
type DiffSection = {
entry: GitStatusEntry
key: string
path: string
status: string
area?: GitStatusEntry['area']
oldPath?: string
originalContent: string
modifiedContent: string
collapsed: boolean
loading: boolean
dirty: boolean
diffResult: GitDiffResult | null
}
export default function CombinedDiffViewer({
worktreePath
}: {
worktreePath: string
}): React.JSX.Element {
export default function CombinedDiffViewer({ file }: { file: OpenFile }): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
const gitBranchChangesByWorktree = useAppStore((s) => s.gitBranchChangesByWorktree)
const gitBranchCompareSummaryByWorktree = useAppStore((s) => s.gitBranchCompareSummaryByWorktree)
const openAllDiffs = useAppStore((s) => s.openAllDiffs)
const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs)
const isDark =
settings?.theme === 'dark' ||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const [sections, setSections] = useState<DiffSection[]>([])
const [sideBySide, setSideBySide] = useState(true)
const [sectionHeights, setSectionHeights] = useState<Record<number, number>>({})
// Load all changed files
useEffect(() => {
let cancelled = false
void (async () => {
try {
const entries = (await window.api.git.status({ worktreePath })) as GitStatusEntry[]
// Filter to only staged and unstaged (not untracked)
const changed = entries.filter((e) => e.area !== 'untracked')
if (cancelled) {
return
const branchCompare =
file.branchCompare?.baseOid && file.branchCompare.headOid && file.branchCompare.mergeBase
? file.branchCompare
: null
const branchSummary = gitBranchCompareSummaryByWorktree[file.worktreeId]
const isBranchMode = file.diffSource === 'combined-branch'
const uncommittedEntries = React.useMemo(
() =>
(gitStatusByWorktree[file.worktreeId] ?? []).filter((entry) => {
if (file.combinedAreaFilter) {
return entry.area === file.combinedAreaFilter
}
return entry.area !== 'untracked'
}),
[file.worktreeId, file.combinedAreaFilter, gitStatusByWorktree]
)
const branchEntries = React.useMemo(
() => gitBranchChangesByWorktree[file.worktreeId] ?? [],
[file.worktreeId, gitBranchChangesByWorktree]
)
// Initialize sections
const initialSections: DiffSection[] = changed.map((entry) => ({
entry,
// Initialize sections from entries without loading diff content
useEffect(() => {
const entries = isBranchMode ? branchEntries : uncommittedEntries
setSections(
entries.map((entry) => ({
key: `${'area' in entry ? entry.area : 'branch'}:${entry.path}`,
path: entry.path,
status: entry.status,
area: 'area' in entry ? entry.area : undefined,
oldPath: entry.oldPath,
originalContent: '',
modifiedContent: '',
collapsed: false,
loading: true,
dirty: false,
diffResult: null
}))
)
setSectionHeights({})
loadedIndicesRef.current.clear()
generationRef.current += 1
}, [branchEntries, isBranchMode, uncommittedEntries])
// Progressive loading: load diff content when a section becomes visible
const loadedIndicesRef = useRef<Set<number>>(new Set())
const generationRef = useRef(0)
const loadSection = useCallback(
async (index: number) => {
if (loadedIndicesRef.current.has(index)) {
return
}
loadedIndicesRef.current.add(index)
const gen = generationRef.current
const entries = isBranchMode ? branchEntries : uncommittedEntries
const entry = entries[index]
if (!entry) {
return
}
let result: GitDiffResult
try {
result =
isBranchMode && branchCompare
? ((await window.api.git.branchDiff({
worktreePath: file.filePath,
compare: {
baseRef: branchCompare.baseRef,
baseOid: branchCompare.baseOid!,
headOid: branchCompare.headOid!,
mergeBase: branchCompare.mergeBase!
},
filePath: entry.path,
oldPath: entry.oldPath
})) as GitDiffResult)
: ((await window.api.git.diff({
worktreePath: file.filePath,
filePath: entry.path,
staged: 'area' in entry && entry.area === 'staged'
})) as GitDiffResult)
} catch {
result = {
kind: 'text',
originalContent: '',
modifiedContent: '',
collapsed: false,
loading: true,
dirty: false
}))
setSections(initialSections)
// Load diffs in parallel
const results = await Promise.all(
changed.map(async (entry) => {
try {
const diff = (await window.api.git.diff({
worktreePath,
filePath: entry.path,
staged: entry.area === 'staged'
})) as { originalContent: string; modifiedContent: string }
return diff
} catch {
return { originalContent: '', modifiedContent: '' }
}
})
)
if (cancelled) {
return
}
setSections((prev) =>
prev.map((section, i) => ({
...section,
originalContent: results[i].originalContent,
modifiedContent: results[i].modifiedContent,
loading: false
}))
)
} catch {
// ignore
originalIsBinary: false,
modifiedIsBinary: false
} as GitDiffResult
}
})()
return () => {
cancelled = true
}
}, [worktreePath])
setSections((prev) => {
if (generationRef.current !== gen) {
return prev
}
return prev.map((s, i) =>
i === index
? {
...s,
diffResult: result,
originalContent: result.kind === 'text' ? result.originalContent : '',
modifiedContent: result.kind === 'text' ? result.modifiedContent : '',
loading: false
}
: s
)
})
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
branchCompare?.baseOid,
branchCompare?.headOid,
branchCompare?.mergeBase,
branchEntries,
file.filePath,
isBranchMode,
uncommittedEntries
]
)
// Track modified editors for each section so we can read their current value on save
const modifiedEditorsRef = useRef<Map<number, monacoEditor.IStandaloneCodeEditor>>(new Map())
const toggleSection = useCallback((index: number) => {
@ -112,7 +178,7 @@ export default function CombinedDiffViewer({
}
const content = modifiedEditor.getValue()
const absolutePath = `${worktreePath}/${section.entry.path}`
const absolutePath = joinPath(file.filePath, section.path)
try {
await window.api.fs.writeFile({ filePath: absolutePath, content })
setSections((prev) =>
@ -122,13 +188,29 @@ export default function CombinedDiffViewer({
console.error('Save failed:', err)
}
},
[sections, worktreePath]
[file.filePath, sections]
)
// Keep a ref so mounted editors always call the latest save
const handleSectionSaveRef = useRef(handleSectionSave)
handleSectionSaveRef.current = handleSectionSave
const openAlternateDiff = useCallback(() => {
if (!file.combinedAlternate) {
return
}
if (file.combinedAlternate.source === 'combined-uncommitted') {
openAllDiffs(file.worktreeId, file.filePath)
return
}
if (branchSummary && branchSummary.status === 'ready') {
openBranchAllDiffs(file.worktreeId, file.filePath, branchSummary, {
source: 'combined-uncommitted'
})
}
}, [branchSummary, file, openAllDiffs, openBranchAllDiffs])
if (sections.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
@ -139,10 +221,22 @@ export default function CombinedDiffViewer({
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-background/50 shrink-0">
<span className="text-xs text-muted-foreground">{sections.length} changed files</span>
<span className="text-xs text-muted-foreground">
{sections.length} changed files
{isBranchMode && branchCompare ? ` vs ${branchCompare.baseRef}` : ''}
</span>
<div className="flex items-center gap-2">
{file.combinedAlternate && (
<button
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={openAlternateDiff}
>
{file.combinedAlternate.source === 'combined-branch'
? 'Open Branch Diff'
: 'Open Uncommitted Diff'}
</button>
)}
<button
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setSections((prev) => prev.map((s) => ({ ...s, collapsed: true })))}
@ -164,42 +258,50 @@ export default function CombinedDiffViewer({
</div>
</div>
{/* Scrollable diff sections */}
<div className="flex-1 overflow-auto scrollbar-editor">
{sections.map((section, index) => {
const language = detectLanguage(section.entry.path)
const fileName = section.entry.path.split('/').pop() ?? section.entry.path
const dirPath = section.entry.path.includes('/')
? section.entry.path.slice(0, section.entry.path.lastIndexOf('/'))
: ''
const isEditable = section.entry.area === 'unstaged'
const language = detectLanguage(section.path)
const fileName = basename(section.path)
const parentDir = dirname(section.path)
const dirPath = parentDir === '.' ? '' : parentDir
const isEditable = section.area === 'unstaged'
const handleMount: DiffOnMount = (editor, monaco) => {
if (isEditable) {
const modifiedEditor = editor.getModifiedEditor()
modifiedEditorsRef.current.set(index, modifiedEditor)
const modifiedEditor = editor.getModifiedEditor()
modifiedEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () =>
handleSectionSaveRef.current(index)
)
modifiedEditor.onDidChangeModelContent(() => {
const current = modifiedEditor.getValue()
setSections((prev) =>
prev.map((s, i) =>
i === index ? { ...s, dirty: current !== s.modifiedContent } : s
)
)
// Track content size to dynamically resize the container
const updateHeight = (): void => {
const contentHeight = editor.getModifiedEditor().getContentHeight()
setSectionHeights((prev) => {
if (prev[index] === contentHeight) {
return prev
}
return { ...prev, [index]: contentHeight }
})
}
modifiedEditor.onDidContentSizeChange(updateHeight)
updateHeight()
if (!isEditable) {
return
}
modifiedEditorsRef.current.set(index, modifiedEditor)
modifiedEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () =>
handleSectionSaveRef.current(index)
)
modifiedEditor.onDidChangeModelContent(() => {
const current = modifiedEditor.getValue()
setSections((prev) =>
prev.map((s, i) =>
i === index ? { ...s, dirty: current !== s.modifiedContent } : s
)
)
})
}
return (
<div
key={`${section.entry.path}:${section.entry.area}`}
className="border-b border-border"
>
{/* Section header */}
<LazySection key={section.key} index={index} onVisible={loadSection}>
<button
className="flex items-center gap-2 w-full px-3 py-2 text-left text-sm hover:bg-accent/30 transition-colors"
onClick={() => toggleSection(index)}
@ -217,29 +319,54 @@ export default function CombinedDiffViewer({
<span
className={cn(
'text-xs font-bold ml-auto',
section.entry.status === 'modified' && 'text-amber-500',
section.entry.status === 'added' && 'text-green-500',
section.entry.status === 'deleted' && 'text-red-500'
section.status === 'modified' && 'text-amber-500',
section.status === 'added' && 'text-green-500',
section.status === 'deleted' && 'text-red-500'
)}
>
{section.entry.area === 'staged' ? 'Staged' : 'Modified'}
{section.area === 'staged'
? 'Staged'
: section.area === 'unstaged'
? 'Modified'
: isBranchMode
? 'Branch'
: ''}
</span>
</button>
{/* Diff content */}
{!section.collapsed && (
<div
style={{
height: Math.min(
400,
Math.max(150, (section.modifiedContent.split('\n').length + 2) * 19)
)
height: sectionHeights[index]
? sectionHeights[index] + 19
: Math.max(
60,
Math.max(
section.originalContent.split('\n').length,
section.modifiedContent.split('\n').length
) *
19 +
19
)
}}
>
{section.loading ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
Loading...
</div>
) : section.diffResult?.kind === 'binary' ? (
<div className="flex h-full items-center justify-center px-6 text-center">
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">
Binary file changed
</div>
<div className="text-xs text-muted-foreground">
{isBranchMode
? 'Text diff is unavailable for this file in branch compare.'
: 'Text diff is unavailable for this file.'}
</div>
</div>
</div>
) : (
<DiffEditor
height="100%"
@ -259,13 +386,14 @@ export default function CombinedDiffViewer({
lineNumbers: 'on',
automaticLayout: true,
renderOverviewRuler: false,
scrollbar: { vertical: 'hidden' }
scrollbar: { vertical: 'hidden', handleMouseWheel: false },
hideUnchangedRegions: { enabled: true }
}}
/>
)}
</div>
)}
</div>
</LazySection>
)
})}
</div>

View File

@ -6,8 +6,7 @@ import { getEditorHeaderCopyState } from './editor-header'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import type { MarkdownViewMode } from '@/store/slices/editor'
import MarkdownViewToggle from './MarkdownViewToggle'
import ImageDiffViewer from './ImageDiffViewer'
import ImageViewer from './ImageViewer'
import type { GitDiffResult } from '../../../../shared/types'
const MonacoEditor = lazy(() => import('./MonacoEditor'))
const DiffViewer = lazy(() => import('./DiffViewer'))
@ -17,16 +16,9 @@ const MarkdownPreview = lazy(() => import('./MarkdownPreview'))
type FileContent = {
content: string
isBinary: boolean
isImage?: boolean
mimeType?: string
}
type DiffContent = {
originalContent: string
modifiedContent: string
isImage?: boolean
mimeType?: string
}
type DiffContent = GitDiffResult
export default function EditorPanel(): React.JSX.Element | null {
const openFiles = useAppStore((s) => s.openFiles)
@ -57,7 +49,12 @@ export default function EditorPanel(): React.JSX.Element | null {
return
}
void loadFileContent(activeFile.filePath, activeFile.id)
} else if (activeFile.mode === 'diff' && activeFile.diffStaged !== undefined) {
} else if (
activeFile.mode === 'diff' &&
activeFile.diffSource !== undefined &&
activeFile.diffSource !== 'combined-uncommitted' &&
activeFile.diffSource !== 'combined-branch'
) {
if (diffContents[activeFile.id]) {
return
}
@ -95,16 +92,39 @@ export default function EditorPanel(): React.JSX.Element | null {
0,
file.filePath.length - file.relativePath.length - 1
)
const result = (await window.api.git.diff({
worktreePath,
filePath: file.relativePath,
staged: file.diffStaged ?? false
})) as DiffContent
const branchCompare =
file.branchCompare?.baseOid && file.branchCompare.headOid && file.branchCompare.mergeBase
? file.branchCompare
: null
const result =
file.diffSource === 'branch' && branchCompare
? ((await window.api.git.branchDiff({
worktreePath,
compare: {
baseRef: branchCompare.baseRef,
baseOid: branchCompare.baseOid!,
headOid: branchCompare.headOid!,
mergeBase: branchCompare.mergeBase!
},
filePath: file.relativePath,
oldPath: file.branchOldPath
})) as DiffContent)
: ((await window.api.git.diff({
worktreePath,
filePath: file.relativePath,
staged: file.diffSource === 'staged'
})) as DiffContent)
setDiffContents((prev) => ({ ...prev, [file.id]: result }))
} catch (err) {
setDiffContents((prev) => ({
...prev,
[file.id]: { originalContent: '', modifiedContent: `Error loading diff: ${err}` }
[file.id]: {
kind: 'text',
originalContent: '',
modifiedContent: `Error loading diff: ${err}`,
originalIsBinary: false,
modifiedIsBinary: false
}
}))
}
}
@ -122,7 +142,7 @@ export default function EditorPanel(): React.JSX.Element | null {
} else {
// Diff mode: compare against the original modified content from git
const dc = diffContents[activeFile.id]
const original = dc?.modifiedContent ?? ''
const original = dc?.kind === 'text' ? dc.modifiedContent : ''
markFileDirty(activeFile.id, content !== original)
}
},
@ -146,7 +166,7 @@ export default function EditorPanel(): React.JSX.Element | null {
// Update the diff's modified content baseline so dirty tracking stays correct
setDiffContents((prev) => {
const existing = prev[activeFile.id]
if (!existing) {
if (!existing || existing.kind !== 'text') {
return prev
}
return {
@ -248,8 +268,15 @@ export default function EditorPanel(): React.JSX.Element | null {
return null
}
const isSingleDiff = activeFile.mode === 'diff' && activeFile.diffStaged !== undefined
const isCombinedDiff = activeFile.mode === 'diff' && activeFile.diffStaged === undefined
const isSingleDiff =
activeFile.mode === 'diff' &&
activeFile.diffSource !== undefined &&
activeFile.diffSource !== 'combined-uncommitted' &&
activeFile.diffSource !== 'combined-branch'
const isCombinedDiff =
activeFile.mode === 'diff' &&
(activeFile.diffSource === 'combined-uncommitted' ||
activeFile.diffSource === 'combined-branch')
const headerCopyState = getEditorHeaderCopyState(activeFile)
const resolvedLanguage =
activeFile.mode === 'diff'
@ -294,52 +321,54 @@ export default function EditorPanel(): React.JSX.Element | null {
return (
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<div className="editor-header">
<div className="editor-header-text">
<div className="editor-header-path-row">
<button
type="button"
className="editor-header-path"
onClick={() => void handleCopyPath()}
title={headerCopyState.pathTitle}
>
{headerCopyState.pathLabel}
</button>
<span
className={`editor-header-copy-toast${copiedPathToast?.fileId === activeFile.id ? ' is-visible' : ''}`}
aria-live="polite"
>
{headerCopyState.copyToastLabel}
</span>
{!isCombinedDiff && (
<div className="editor-header">
<div className="editor-header-text">
<div className="editor-header-path-row">
<button
type="button"
className="editor-header-path"
onClick={() => void handleCopyPath()}
title={headerCopyState.pathTitle}
>
{headerCopyState.pathLabel}
</button>
<span
className={`editor-header-copy-toast${copiedPathToast?.fileId === activeFile.id ? ' is-visible' : ''}`}
aria-live="polite"
>
{headerCopyState.copyToastLabel}
</span>
</div>
</div>
{isSingleDiff && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
onClick={() => setSideBySide((prev) => !prev)}
>
{sideBySide ? <Rows2 size={14} /> : <Columns2 size={14} />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{sideBySide ? 'Switch to inline diff' : 'Switch to side-by-side diff'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isMarkdown && activeFile.mode === 'edit' && (
<MarkdownViewToggle
mode={mdViewMode}
onChange={(mode) => setMarkdownViewMode(activeFile.id, mode)}
/>
)}
</div>
{isSingleDiff && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
onClick={() => setSideBySide((prev) => !prev)}
>
{sideBySide ? <Rows2 size={14} /> : <Columns2 size={14} />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{sideBySide ? 'Switch to inline diff' : 'Switch to side-by-side diff'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isMarkdown && activeFile.mode === 'edit' && (
<MarkdownViewToggle
mode={mdViewMode}
onChange={(mode) => setMarkdownViewMode(activeFile.id, mode)}
/>
)}
</div>
)}
<Suspense fallback={loadingFallback}>
{isCombinedDiff ? (
<CombinedDiffViewer worktreePath={activeFile.filePath} />
<CombinedDiffViewer file={activeFile} />
) : activeFile.mode === 'edit' ? (
(() => {
const fc = fileContents[activeFile.id]
@ -351,15 +380,6 @@ export default function EditorPanel(): React.JSX.Element | null {
)
}
if (fc.isBinary) {
if (fc.isImage) {
return (
<ImageViewer
content={fc.content}
filePath={activeFile.filePath}
mimeType={fc.mimeType}
/>
)
}
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Binary file cannot display
@ -378,17 +398,19 @@ export default function EditorPanel(): React.JSX.Element | null {
</div>
)
}
// Unstaged diffs are editable (right side = working tree file)
const isEditable = activeFile.diffStaged === false
if (dc.isImage) {
const isEditable = activeFile.diffSource === 'unstaged'
if (dc.kind === 'binary') {
return (
<ImageDiffViewer
originalContent={dc.originalContent}
modifiedContent={editBuffers[activeFile.id] ?? dc.modifiedContent}
filePath={activeFile.filePath}
mimeType={dc.mimeType}
sideBySide={sideBySide}
/>
<div className="flex h-full items-center justify-center px-6 text-center">
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">Binary file changed</div>
<div className="text-xs text-muted-foreground">
{activeFile.diffSource === 'branch'
? 'Text diff is unavailable for this file in branch compare.'
: 'Text diff is unavailable for this file.'}
</div>
</div>
</div>
)
}
return (

View File

@ -0,0 +1,40 @@
import React, { useEffect, useRef } from 'react'
export function LazySection({
index,
onVisible,
children
}: {
index: number
onVisible: (index: number) => void
children: React.ReactNode
}): React.JSX.Element {
const ref = useRef<HTMLDivElement>(null)
const triggered = useRef(false)
useEffect(() => {
const el = ref.current
if (!el || triggered.current) {
return
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !triggered.current) {
triggered.current = true
onVisible(index)
observer.disconnect()
}
},
{ rootMargin: '200px' }
)
observer.observe(el)
return () => observer.disconnect()
}, [index, onVisible])
return (
<div ref={ref} className="border-b border-border">
{children}
</div>
)
}

View File

@ -31,7 +31,7 @@ describe('getEditorHeaderCopyState', () => {
makeOpenFile({
id: '/repo/file.ts::unstaged',
mode: 'diff',
diffStaged: false
diffSource: 'unstaged'
})
)
).toEqual({
@ -48,14 +48,14 @@ describe('getEditorHeaderCopyState', () => {
makeOpenFile({
id: '/repo/file.ts::staged',
mode: 'diff',
diffStaged: true
diffSource: 'staged'
})
)
).toEqual({
copyText: '/repo/file.ts',
copyToastLabel: 'File path copied',
pathLabel: '/repo/file.ts (diff staged)',
pathTitle: '/repo/file.ts (diff staged)'
pathLabel: '/repo/file.ts (staged diff)',
pathTitle: '/repo/file.ts (staged diff)'
})
})
@ -63,11 +63,11 @@ describe('getEditorHeaderCopyState', () => {
expect(
getEditorHeaderCopyState(
makeOpenFile({
id: 'wt-1::all-diffs',
id: 'wt-1::all-diffs::uncommitted',
filePath: '/repo/worktree',
relativePath: 'All Changes',
mode: 'diff',
diffStaged: undefined
diffSource: 'combined-uncommitted'
})
)
).toEqual({

View File

@ -9,7 +9,9 @@ export type EditorHeaderCopyState = {
}
export function getEditorHeaderCopyState(file: OpenFile): EditorHeaderCopyState {
const isCombinedDiff = file.mode === 'diff' && file.diffStaged === undefined
const isCombinedDiff =
file.mode === 'diff' &&
(file.diffSource === 'combined-uncommitted' || file.diffSource === 'combined-branch')
if (isCombinedDiff) {
return {

View File

@ -4,10 +4,6 @@ import { basename } from '@/lib/path'
type EditorLabelVariant = 'fileName' | 'relativePath' | 'fullPath'
function getBaseLabel(file: OpenFile, variant: EditorLabelVariant): string {
if (file.mode === 'diff' && file.diffStaged === undefined) {
return file.relativePath
}
switch (variant) {
case 'fullPath':
return file.filePath
@ -18,20 +14,29 @@ function getBaseLabel(file: OpenFile, variant: EditorLabelVariant): string {
}
}
function getDiffSuffix(file: OpenFile): string | null {
if (file.mode !== 'diff' || file.diffStaged === undefined) {
return null
}
return file.diffStaged ? 'diff staged' : 'diff'
const DIFF_SOURCE_LABELS: Record<string, string> = {
staged: 'staged diff',
unstaged: 'diff',
branch: 'branch diff'
}
export function getEditorDisplayLabel(
file: OpenFile,
variant: EditorLabelVariant = 'fileName'
): string {
const baseLabel = getBaseLabel(file, variant)
const diffSuffix = getDiffSuffix(file)
if (file.mode !== 'diff') {
return getBaseLabel(file, variant)
}
return diffSuffix ? `${baseLabel} (${diffSuffix})` : baseLabel
const source = file.diffSource
if (source === 'combined-uncommitted') {
return 'All Changes'
}
if (source === 'combined-branch') {
return `Branch Changes (${file.branchCompare?.baseRef ?? 'base'})`
}
const baseLabel = getBaseLabel(file, variant)
const suffix = (source && DIFF_SOURCE_LABELS[source]) ?? 'diff'
return `${baseLabel} (${suffix})`
}

View File

@ -272,7 +272,7 @@ export default function FileExplorer(): React.JSX.Element {
>
<button
className={cn(
'flex items-center w-full h-[26px] px-2 gap-1 text-left text-[12px] transition-colors hover:bg-accent/60 rounded-sm',
'flex items-center w-full py-1 px-2 gap-1.5 text-left text-[13px] transition-colors hover:bg-accent/60 rounded-sm',
isActive && !node.isDirectory && 'bg-accent text-accent-foreground'
)}
style={{ paddingLeft: `${node.depth * 16 + 8}px` }}

View File

@ -1,25 +1,44 @@
import React, { useCallback, useMemo, useState } from 'react'
/* eslint-disable max-lines */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ChevronDown,
Layers,
Minus,
Plus,
RefreshCw,
Settings2,
Undo2,
FileEdit,
FilePlus,
FileMinus,
FilePlus,
FileQuestion,
ArrowRightLeft,
GitCompareArrows
ArrowRightLeft
} from 'lucide-react'
import { useAppStore } from '@/store'
import { detectLanguage } from '@/lib/language-detect'
import { basename, dirname, joinPath } from '@/lib/path'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
import type { GitStatusEntry, GitStagingArea } from '../../../../shared/types'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { BaseRefPicker } from '@/components/settings/BaseRefPicker'
import type {
GitBranchChangeEntry,
GitBranchCompareSummary,
GitStatusEntry,
PRInfo
} from '../../../../shared/types'
import { getSourceControlActions } from './source-control-actions'
import { STATUS_COLORS, STATUS_LABELS } from './status-display'
type SourceControlScope = 'all' | 'uncommitted'
const STATUS_ICONS: Record<
string,
React.ComponentType<{ className?: string; style?: React.CSSProperties }>
@ -32,59 +51,105 @@ const STATUS_ICONS: Record<
copied: FilePlus
}
const SECTION_ORDER: GitStagingArea[] = ['staged', 'unstaged', 'untracked']
const SECTION_LABELS: Record<GitStagingArea, string> = {
const SECTION_ORDER = ['staged', 'unstaged', 'untracked'] as const
const SECTION_LABELS: Record<(typeof SECTION_ORDER)[number], string> = {
staged: 'Staged Changes',
unstaged: 'Changes',
untracked: 'Untracked Files'
}
const BRANCH_REFRESH_INTERVAL_MS = 5000
export default function SourceControl(): React.JSX.Element {
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
const repos = useAppStore((s) => s.repos)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
const setGitStatus = useAppStore((s) => s.setGitStatus)
const gitBranchChangesByWorktree = useAppStore((s) => s.gitBranchChangesByWorktree)
const gitBranchCompareSummaryByWorktree = useAppStore((s) => s.gitBranchCompareSummaryByWorktree)
const prCache = useAppStore((s) => s.prCache)
const updateRepo = useAppStore((s) => s.updateRepo)
const beginGitBranchCompareRequest = useAppStore((s) => s.beginGitBranchCompareRequest)
const setGitBranchCompareResult = useAppStore((s) => s.setGitBranchCompareResult)
const openDiff = useAppStore((s) => s.openDiff)
const openBranchDiff = useAppStore((s) => s.openBranchDiff)
const openAllDiffs = useAppStore((s) => s.openAllDiffs)
const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs)
const [scope, setScope] = useState<SourceControlScope>('all')
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(new Set())
const [baseRefDialogOpen, setBaseRefDialogOpen] = useState(false)
const [defaultBaseRef, setDefaultBaseRef] = useState('origin/main')
// Find active worktree path
const worktreePath = useMemo(() => {
const activeWorktree = useMemo(() => {
if (!activeWorktreeId) {
return null
}
for (const worktrees of Object.values(worktreesByRepo)) {
const wt = worktrees.find((w) => w.id === activeWorktreeId)
if (wt) {
return wt.path
const worktree = worktrees.find((entry) => entry.id === activeWorktreeId)
if (worktree) {
return worktree
}
}
return null
}, [activeWorktreeId, worktreesByRepo])
const fetchStatus = useCallback(async () => {
if (!activeWorktreeId || !worktreePath) {
return
}
try {
const entries = (await window.api.git.status({ worktreePath })) as GitStatusEntry[]
setGitStatus(activeWorktreeId, entries)
} catch {
// ignore
}
}, [activeWorktreeId, worktreePath, setGitStatus])
const activeRepo = useMemo(
() => repos.find((repo) => repo.id === activeWorktree?.repoId) ?? null,
[activeWorktree?.repoId, repos]
)
const worktreePath = activeWorktree?.path ?? null
const entries = useMemo(
() => (activeWorktreeId ? (gitStatusByWorktree[activeWorktreeId] ?? []) : []),
[activeWorktreeId, gitStatusByWorktree]
)
const branchEntries = useMemo(
() => (activeWorktreeId ? (gitBranchChangesByWorktree[activeWorktreeId] ?? []) : []),
[activeWorktreeId, gitBranchChangesByWorktree]
)
const branchSummary = activeWorktreeId
? (gitBranchCompareSummaryByWorktree[activeWorktreeId] ?? null)
: null
const isBranchVisible = rightSidebarTab === 'source-control'
useEffect(() => {
if (!activeRepo) {
return
}
let stale = false
void window.api.repos
.getBaseRefDefault({ repoId: activeRepo.id })
.then((result) => {
if (!stale) {
setDefaultBaseRef(result)
}
})
.catch(() => {
if (!stale) {
setDefaultBaseRef('origin/main')
}
})
return () => {
stale = true
}
}, [activeRepo])
const effectiveBaseRef = activeRepo?.worktreeBaseRef ?? defaultBaseRef
const hasUncommittedEntries = entries.length > 0
const branchCompareAvailable = branchSummary?.status === 'ready'
const hasBranchEntries = branchCompareAvailable && branchEntries.length > 0
const branchName = activeWorktree?.branch.replace(/^refs\/heads\//, '') ?? 'HEAD'
const prCacheKey = activeRepo ? `${activeRepo.path}::${branchName}` : null
const prInfo: PRInfo | null = prCacheKey ? (prCache[prCacheKey]?.data ?? null) : null
const grouped = useMemo(() => {
const groups: Record<GitStagingArea, GitStatusEntry[]> = {
staged: [],
unstaged: [],
untracked: []
const groups = {
staged: [] as GitStatusEntry[],
unstaged: [] as GitStatusEntry[],
untracked: [] as GitStatusEntry[]
}
for (const entry of entries) {
groups[entry.area].push(entry)
@ -92,6 +157,73 @@ export default function SourceControl(): React.JSX.Element {
return groups
}, [entries])
const refreshBranchCompare = useCallback(async () => {
if (!activeWorktreeId || !worktreePath || !effectiveBaseRef) {
return
}
const requestKey = `${activeWorktreeId}:${effectiveBaseRef}:${Date.now()}`
const existingSummary =
useAppStore.getState().gitBranchCompareSummaryByWorktree[activeWorktreeId]
const isBackgroundRefresh = existingSummary && existingSummary.status === 'ready'
if (isBackgroundRefresh) {
// Update the request key without resetting to loading state
useAppStore.setState((s) => ({
gitBranchCompareRequestKeyByWorktree: {
...s.gitBranchCompareRequestKeyByWorktree,
[activeWorktreeId]: requestKey
}
}))
} else {
beginGitBranchCompareRequest(activeWorktreeId, requestKey, effectiveBaseRef)
}
try {
const result = await window.api.git.branchCompare({
worktreePath,
baseRef: effectiveBaseRef
})
setGitBranchCompareResult(activeWorktreeId, requestKey, result)
} catch (error) {
setGitBranchCompareResult(activeWorktreeId, requestKey, {
summary: {
baseRef: effectiveBaseRef,
baseOid: null,
compareRef: branchName,
headOid: null,
mergeBase: null,
changedFiles: 0,
status: 'error',
errorMessage: error instanceof Error ? error.message : 'Branch compare failed'
},
entries: []
})
}
}, [
activeWorktreeId,
beginGitBranchCompareRequest,
branchName,
effectiveBaseRef,
setGitBranchCompareResult,
worktreePath
])
const refreshBranchCompareRef = useRef(refreshBranchCompare)
refreshBranchCompareRef.current = refreshBranchCompare
useEffect(() => {
if (!activeWorktreeId || !worktreePath || !isBranchVisible || !effectiveBaseRef) {
return
}
void refreshBranchCompareRef.current()
const intervalId = window.setInterval(
() => void refreshBranchCompareRef.current(),
BRANCH_REFRESH_INTERVAL_MS
)
return () => window.clearInterval(intervalId)
}, [activeWorktreeId, effectiveBaseRef, isBranchVisible, worktreePath])
const toggleSection = useCallback((section: string) => {
setCollapsedSections((prev) => {
const next = new Set(prev)
@ -104,6 +236,43 @@ export default function SourceControl(): React.JSX.Element {
})
}, [])
const openUncommittedDiff = useCallback(
(entry: GitStatusEntry) => {
if (!activeWorktreeId || !worktreePath) {
return
}
openDiff(
activeWorktreeId,
joinPath(worktreePath, entry.path),
entry.path,
detectLanguage(entry.path),
entry.area === 'staged'
)
},
[activeWorktreeId, openDiff, worktreePath]
)
const openCommittedDiff = useCallback(
(entry: GitBranchChangeEntry) => {
if (
!activeWorktreeId ||
!worktreePath ||
!branchSummary ||
branchSummary.status !== 'ready'
) {
return
}
openBranchDiff(
activeWorktreeId,
worktreePath,
entry,
branchSummary,
detectLanguage(entry.path)
)
},
[activeWorktreeId, branchSummary, openBranchDiff, worktreePath]
)
const handleStage = useCallback(
async (filePath: string) => {
if (!worktreePath) {
@ -111,12 +280,11 @@ export default function SourceControl(): React.JSX.Element {
}
try {
await window.api.git.stage({ worktreePath, filePath })
void fetchStatus()
} catch {
// ignore
// git operation failed silently
}
},
[worktreePath, fetchStatus]
[worktreePath]
)
const handleUnstage = useCallback(
@ -126,12 +294,11 @@ export default function SourceControl(): React.JSX.Element {
}
try {
await window.api.git.unstage({ worktreePath, filePath })
void fetchStatus()
} catch {
// ignore
// git operation failed silently
}
},
[worktreePath, fetchStatus]
[worktreePath]
)
const handleDiscard = useCallback(
@ -141,176 +308,474 @@ export default function SourceControl(): React.JSX.Element {
}
try {
await window.api.git.discard({ worktreePath, filePath })
void fetchStatus()
} catch {
// ignore
// git operation failed silently
}
},
[worktreePath, fetchStatus]
[worktreePath]
)
const handleViewAllChanges = useCallback(() => {
if (!activeWorktreeId || !worktreePath) {
return
}
openAllDiffs(activeWorktreeId, worktreePath)
}, [activeWorktreeId, worktreePath, openAllDiffs])
const handleOpenDiff = useCallback(
(entry: GitStatusEntry) => {
if (!activeWorktreeId) {
return
}
const language = detectLanguage(entry.path)
const absolutePath = worktreePath ? joinPath(worktreePath, entry.path) : entry.path
openDiff(activeWorktreeId, absolutePath, entry.path, language, entry.area === 'staged')
},
[activeWorktreeId, worktreePath, openDiff]
)
if (!worktreePath) {
if (!activeWorktree || !activeRepo || !worktreePath) {
return (
<div className="flex items-center justify-center h-full text-[11px] text-muted-foreground px-4 text-center">
<div className="flex items-center justify-center h-full text-xs text-muted-foreground px-4 text-center">
Select a worktree to view changes
</div>
)
}
if (entries.length === 0) {
const showGenericEmptyState =
!hasUncommittedEntries && branchSummary?.status === 'ready' && branchEntries.length === 0
return (
<>
<div className="flex h-full flex-col overflow-hidden">
<div className="flex px-3 pt-2 border-b border-border">
{(['all', 'uncommitted'] as const).map((value) => (
<button
key={value}
type="button"
className={cn(
'px-3 pb-2 text-xs font-medium transition-colors border-b-2 -mb-px',
scope === value
? 'border-foreground text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
)}
onClick={() => setScope(value)}
>
{value === 'all' ? 'All' : 'Uncommitted'}
</button>
))}
</div>
{scope === 'all' && (
<div className="border-b border-border px-3 py-2">
<CompareSummary
summary={branchSummary}
uncommittedCount={entries.length}
prInfo={prInfo}
onChangeBaseRef={() => setBaseRefDialogOpen(true)}
onRetry={() => void refreshBranchCompare()}
/>
</div>
)}
<div className="flex-1 overflow-auto scrollbar-sleek py-1">
{showGenericEmptyState ? (
<EmptyState
heading="No changes on this branch"
supportingText={`This worktree is clean and this branch has no changes ahead of ${branchSummary.baseRef}`}
/>
) : null}
{scope === 'uncommitted' && !hasUncommittedEntries && (
<EmptyState
heading="No uncommitted changes"
supportingText="All changes have been committed"
/>
)}
{(scope === 'all' || scope === 'uncommitted') && hasUncommittedEntries && (
<>
{SECTION_ORDER.map((area) => {
const items = grouped[area]
if (items.length === 0) {
return null
}
const isCollapsed = collapsedSections.has(area)
return (
<div key={area}>
<SectionHeader
label={SECTION_LABELS[area]}
count={items.length}
isCollapsed={isCollapsed}
onToggle={() => toggleSection(area)}
actions={
<ActionButton
icon={Layers}
title="Open all diffs in this section"
onClick={(e) => {
e.stopPropagation()
if (activeWorktreeId && worktreePath) {
openAllDiffs(activeWorktreeId, worktreePath, undefined, area)
}
}}
/>
}
/>
{!isCollapsed &&
items.map((entry) => (
<UncommittedEntryRow
key={`${entry.area}:${entry.path}`}
entry={entry}
worktreePath={worktreePath}
onOpen={() => openUncommittedDiff(entry)}
onStage={() => void handleStage(entry.path)}
onUnstage={() => void handleUnstage(entry.path)}
onDiscard={() => void handleDiscard(entry.path)}
/>
))}
</div>
)
})}
</>
)}
{scope === 'all' &&
branchSummary &&
branchSummary.status !== 'ready' &&
branchSummary.status !== 'loading' ? (
<CompareUnavailable
summary={branchSummary}
onChangeBaseRef={() => setBaseRefDialogOpen(true)}
onRetry={() => void refreshBranchCompare()}
/>
) : null}
{scope === 'all' && branchSummary?.status === 'ready' && hasBranchEntries && (
<div>
<SectionHeader
label="Committed on Branch"
count={branchEntries.length}
isCollapsed={collapsedSections.has('branch')}
onToggle={() => toggleSection('branch')}
actions={
<ActionButton
icon={Layers}
title="Open all branch diffs"
onClick={(e) => {
e.stopPropagation()
if (activeWorktreeId && worktreePath && branchSummary) {
openBranchAllDiffs(activeWorktreeId, worktreePath, branchSummary)
}
}}
/>
}
/>
{!collapsedSections.has('branch') &&
branchEntries.map((entry) => (
<BranchEntryRow
key={`branch:${entry.path}`}
entry={entry}
onOpen={() => openCommittedDiff(entry)}
/>
))}
</div>
)}
</div>
</div>
<Dialog open={baseRefDialogOpen} onOpenChange={setBaseRefDialogOpen}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="text-sm">Change Base Ref</DialogTitle>
<DialogDescription className="text-xs">
Pick the branch compare target for this repository.
</DialogDescription>
</DialogHeader>
<BaseRefPicker
repoId={activeRepo.id}
currentBaseRef={activeRepo.worktreeBaseRef}
onSelect={(ref) => {
void updateRepo(activeRepo.id, { worktreeBaseRef: ref })
setBaseRefDialogOpen(false)
window.setTimeout(() => void refreshBranchCompare(), 0)
}}
onUsePrimary={() => {
void updateRepo(activeRepo.id, { worktreeBaseRef: undefined })
setBaseRefDialogOpen(false)
window.setTimeout(() => void refreshBranchCompare(), 0)
}}
/>
</DialogContent>
</Dialog>
</>
)
}
function CompareSummary({
summary,
uncommittedCount,
prInfo,
onChangeBaseRef,
onRetry
}: {
summary: GitBranchCompareSummary | null
uncommittedCount: number
prInfo: PRInfo | null
onChangeBaseRef: () => void
onRetry: () => void
}): React.JSX.Element {
if (!summary || summary.status === 'loading') {
return (
<div className="flex items-center justify-center h-full text-[11px] text-muted-foreground">
No changes detected
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<RefreshCw className="size-3.5 animate-spin" />
<span>Comparing against {summary?.baseRef ?? '…'}</span>
</div>
)
}
if (summary.status !== 'ready') {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{summary.errorMessage ?? 'Branch compare unavailable'}</span>
<button
className="shrink-0 hover:text-foreground"
onClick={onChangeBaseRef}
title="Change base ref"
>
<Settings2 className="size-3.5" />
</button>
<button className="shrink-0 hover:text-foreground" onClick={onRetry} title="Retry">
<RefreshCw className="size-3.5" />
</button>
</div>
)
}
return (
<div className="flex-1 overflow-auto scrollbar-sleek">
{/* View All Changes button */}
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start rounded-none border-b border-border px-3 py-2 text-left text-[12px] font-medium"
onClick={handleViewAllChanges}
>
<GitCompareArrows className="size-3.5 text-muted-foreground" />
View All Changes
<span className="text-[10px] font-medium bg-muted/60 rounded-full px-1.5 py-0.5 ml-auto text-muted-foreground">
{entries.length}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{summary.changedFiles + uncommittedCount} files changed</span>
{summary.commitsAhead !== undefined && (
<span title={`Comparing against ${summary.baseRef}`}>
{summary.commitsAhead} commits ahead
</span>
</Button>
)}
{prInfo && (
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-foreground">
PR #{prInfo.number}
</span>
)}
<TooltipProvider delayDuration={400}>
<div className="ml-auto flex items-center gap-2 shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<button className="hover:text-foreground p-0.5 rounded" onClick={onChangeBaseRef}>
<Settings2 className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Change base ref
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button className="hover:text-foreground p-0.5 rounded" onClick={onRetry}>
<RefreshCw className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Refresh branch compare
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
)
}
{SECTION_ORDER.map((area) => {
const items = grouped[area]
if (items.length === 0) {
return null
}
const isCollapsed = collapsedSections.has(area)
function CompareUnavailable({
summary,
onChangeBaseRef,
onRetry
}: {
summary: GitBranchCompareSummary
onChangeBaseRef: () => void
onRetry: () => void
}): React.JSX.Element {
const changeBaseRefAllowed =
summary.status === 'invalid-base' ||
summary.status === 'no-merge-base' ||
summary.status === 'error'
return (
<div key={area}>
{/* Section header */}
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start px-3 py-1.5 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
onClick={() => toggleSection(area)}
>
<ChevronDown
className={cn('size-3 transition-transform', isCollapsed && '-rotate-90')}
/>
<span className="flex-1">{SECTION_LABELS[area]}</span>
<span className="text-[10px] font-medium bg-muted/60 rounded-full px-1.5 py-0.5">
{items.length}
</span>
</Button>
return (
<div className="m-3 rounded-md border border-border/60 bg-muted/20 px-3 py-3 text-xs">
<div className="font-medium text-foreground">
{summary.status === 'error' ? 'Branch compare failed' : 'Branch compare unavailable'}
</div>
<div className="mt-1 text-muted-foreground">
{summary.errorMessage ?? 'Unable to load branch compare.'}
</div>
<div className="mt-3 flex items-center gap-2">
{changeBaseRefAllowed && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={onChangeBaseRef}
>
<Settings2 className="size-3.5" />
Change Base Ref
</Button>
)}
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={onRetry}>
<RefreshCw className="size-3.5" />
Retry
</Button>
</div>
</div>
)
}
{/* File entries */}
{!isCollapsed &&
items.map((entry) => {
const StatusIcon = STATUS_ICONS[entry.status] ?? FileQuestion
const fileName = basename(entry.path)
const parentDir = dirname(entry.path)
const dirPath = parentDir === '.' ? '' : parentDir
const actions = getSourceControlActions(area)
function SectionHeader({
label,
count,
isCollapsed,
onToggle,
actions
}: {
label: string
count: number
isCollapsed: boolean
onToggle: () => void
actions?: React.ReactNode
}): React.JSX.Element {
return (
<div className="group/section flex items-center pl-1 pr-3 py-1">
<button
type="button"
className="flex flex-1 items-center gap-1 rounded-md px-0.5 py-0.5 text-left text-xs font-semibold uppercase tracking-wider text-foreground/70 hover:bg-accent hover:text-accent-foreground"
onClick={onToggle}
>
<ChevronDown
className={cn('size-3.5 shrink-0 transition-transform', isCollapsed && '-rotate-90')}
/>
<span>{label}</span>
<span className="text-[11px] font-medium tabular-nums">{count}</span>
</button>
<div className="shrink-0 flex items-center">{actions}</div>
</div>
)
}
return (
<div
key={`${area}:${entry.path}`}
className="group flex items-center gap-1 px-3 py-0.5 hover:bg-accent/40 transition-colors cursor-pointer"
draggable
onDragStart={(e) => {
const absolutePath = joinPath(worktreePath, entry.path)
e.dataTransfer.setData('text/x-orca-file-path', absolutePath)
e.dataTransfer.effectAllowed = 'copy'
}}
onClick={() => handleOpenDiff(entry)}
>
<StatusIcon
className="size-3.5 shrink-0"
style={{ color: STATUS_COLORS[entry.status] }}
/>
<span className="truncate text-[12px] flex-1 min-w-0">
<span className="text-foreground">{fileName}</span>
{dirPath && (
<span className="text-muted-foreground ml-1.5 text-[11px]">{dirPath}</span>
)}
</span>
<span
className="text-[10px] font-bold shrink-0 w-4 text-center"
style={{ color: STATUS_COLORS[entry.status] }}
>
{STATUS_LABELS[entry.status]}
</span>
function UncommittedEntryRow({
entry,
worktreePath,
onOpen,
onStage,
onUnstage,
onDiscard
}: {
entry: GitStatusEntry
worktreePath: string
onOpen: () => void
onStage: () => void
onUnstage: () => void
onDiscard: () => void
}): React.JSX.Element {
const StatusIcon = STATUS_ICONS[entry.status] ?? FileQuestion
const fileName = basename(entry.path)
const parentDir = dirname(entry.path)
const dirPath = parentDir === '.' ? '' : parentDir
const actions = getSourceControlActions(entry.area)
{/* Action buttons */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
{actions.includes('discard') && (
<ActionButton
icon={Undo2}
title={area === 'untracked' ? 'Revert untracked file' : 'Discard changes'}
onClick={(e) => {
e.stopPropagation()
if (area === 'untracked') {
if (
!window.confirm(
`Delete untracked file "${entry.path}"? This cannot be undone.`
)
) {
return
}
}
void handleDiscard(entry.path)
}}
/>
)}
{actions.includes('stage') && (
<ActionButton
icon={Plus}
title="Stage"
onClick={(e) => {
e.stopPropagation()
void handleStage(entry.path)
}}
/>
)}
{actions.includes('unstage') && (
<ActionButton
icon={Minus}
title="Unstage"
onClick={(e) => {
e.stopPropagation()
void handleUnstage(entry.path)
}}
/>
)}
</div>
</div>
)
})}
</div>
)
})}
return (
<div
className="group flex cursor-pointer items-center gap-1.5 pl-7 pr-3 py-1 transition-colors hover:bg-accent/40"
draggable
onDragStart={(e) => {
const absolutePath = joinPath(worktreePath, entry.path)
e.dataTransfer.setData('text/x-orca-file-path', absolutePath)
e.dataTransfer.effectAllowed = 'copy'
}}
onClick={onOpen}
>
<StatusIcon className="size-4 shrink-0" style={{ color: STATUS_COLORS[entry.status] }} />
<span className="min-w-0 flex-1 truncate text-[13px]">
<span className="text-foreground">{fileName}</span>
{dirPath && <span className="ml-1.5 text-xs text-muted-foreground">{dirPath}</span>}
</span>
<span
className="w-4 shrink-0 text-center text-[11px] font-bold"
style={{ color: STATUS_COLORS[entry.status] }}
>
{STATUS_LABELS[entry.status]}
</span>
<div className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 flex items-center gap-0.5">
{actions.includes('discard') && (
<ActionButton
icon={Undo2}
title={entry.area === 'untracked' ? 'Revert untracked file' : 'Discard changes'}
onClick={(event) => {
event.stopPropagation()
if (
entry.area === 'untracked' &&
!window.confirm(`Delete untracked file "${entry.path}"? This cannot be undone.`)
) {
return
}
void onDiscard()
}}
/>
)}
{actions.includes('stage') && (
<ActionButton
icon={Plus}
title="Stage"
onClick={(event) => {
event.stopPropagation()
void onStage()
}}
/>
)}
{actions.includes('unstage') && (
<ActionButton
icon={Minus}
title="Unstage"
onClick={(event) => {
event.stopPropagation()
void onUnstage()
}}
/>
)}
</div>
</div>
)
}
function BranchEntryRow({
entry,
onOpen
}: {
entry: GitBranchChangeEntry
onOpen: () => void
}): React.JSX.Element {
const StatusIcon = STATUS_ICONS[entry.status] ?? FileQuestion
const fileName = basename(entry.path)
const parentDir = dirname(entry.path)
const dirPath = parentDir === '.' ? '' : parentDir
return (
<div
className="group flex cursor-pointer items-center gap-1.5 pl-7 pr-3 py-1 transition-colors hover:bg-accent/40"
onClick={onOpen}
>
<StatusIcon className="size-4 shrink-0" style={{ color: STATUS_COLORS[entry.status] }} />
<span className="min-w-0 flex-1 truncate text-[13px]">
<span className="text-foreground">{fileName}</span>
{dirPath && <span className="ml-1.5 text-xs text-muted-foreground">{dirPath}</span>}
</span>
<span
className="w-4 shrink-0 text-center text-[11px] font-bold"
style={{ color: STATUS_COLORS[entry.status] }}
>
{STATUS_LABELS[entry.status]}
</span>
</div>
)
}
function EmptyState({
heading,
supportingText
}: {
heading: string
supportingText: string
}): React.JSX.Element {
return (
<div className="px-4 py-6">
<div className="text-sm font-medium text-foreground">{heading}</div>
<div className="mt-1 text-xs text-muted-foreground">{supportingText}</div>
</div>
)
}
@ -322,7 +787,7 @@ function ActionButton({
}: {
icon: React.ComponentType<{ className?: string }>
title: string
onClick: (e: React.MouseEvent) => void
onClick: (event: React.MouseEvent) => void
}): React.JSX.Element {
return (
<Button
@ -333,7 +798,7 @@ function ActionButton({
title={title}
onClick={onClick}
>
<Icon className="size-3" />
<Icon className="size-3.5" />
</Button>
)
}

View File

@ -0,0 +1,156 @@
import { useEffect, useState } from 'react'
import { ScrollArea } from '../ui/scroll-area'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
type BaseRefPickerProps = {
repoId: string
currentBaseRef?: string
onSelect: (ref: string) => void
onUsePrimary?: () => void
}
export function BaseRefPicker({
repoId,
currentBaseRef,
onSelect,
onUsePrimary
}: BaseRefPickerProps): React.JSX.Element {
const [defaultBaseRef, setDefaultBaseRef] = useState('origin/main')
const [baseRefQuery, setBaseRefQuery] = useState('')
const [baseRefResults, setBaseRefResults] = useState<string[]>([])
const [isSearchingBaseRefs, setIsSearchingBaseRefs] = useState(false)
useEffect(() => {
let stale = false
const loadDefaultBaseRef = async (): Promise<void> => {
try {
const result = await window.api.repos.getBaseRefDefault({ repoId })
if (!stale) {
setDefaultBaseRef(result)
}
} catch {
if (!stale) {
setDefaultBaseRef('origin/main')
}
}
}
setBaseRefQuery('')
setBaseRefResults([])
void loadDefaultBaseRef()
return () => {
stale = true
}
}, [repoId])
useEffect(() => {
const trimmedQuery = baseRefQuery.trim()
if (trimmedQuery.length < 2) {
setBaseRefResults([])
setIsSearchingBaseRefs(false)
return
}
let stale = false
setIsSearchingBaseRefs(true)
const timer = window.setTimeout(() => {
void window.api.repos
.searchBaseRefs({
repoId,
query: trimmedQuery,
limit: 20
})
.then((results) => {
if (!stale) {
setBaseRefResults(results)
}
})
.catch(() => {
if (!stale) {
setBaseRefResults([])
}
})
.finally(() => {
if (!stale) {
setIsSearchingBaseRefs(false)
}
})
}, 200)
return () => {
stale = true
window.clearTimeout(timer)
}
}, [baseRefQuery, repoId])
const effectiveBaseRef = currentBaseRef ?? defaultBaseRef
return (
<div className="min-h-[280px] space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-sm font-medium text-foreground">{effectiveBaseRef}</div>
<p className="text-xs text-muted-foreground">
{currentBaseRef
? 'Pinned for this repo'
: `Following primary branch (${defaultBaseRef})`}
</p>
</div>
{onUsePrimary && (
<Button variant="outline" size="sm" onClick={onUsePrimary} disabled={!currentBaseRef}>
Use Primary
</Button>
)}
</div>
<div className="space-y-2">
<Input
value={baseRefQuery}
onChange={(e) => setBaseRefQuery(e.target.value)}
placeholder="Search branches by name..."
className="max-w-md"
/>
<p className="text-xs text-muted-foreground">Type at least 2 characters.</p>
</div>
{isSearchingBaseRefs ? (
<p className="text-xs text-muted-foreground">Searching branches...</p>
) : null}
{!isSearchingBaseRefs && baseRefQuery.trim().length >= 2 ? (
baseRefResults.length > 0 ? (
<ScrollArea className="h-48 rounded-md border border-border/50">
<div className="p-1">
{baseRefResults.map((ref) => (
<button
key={ref}
onClick={() => {
setBaseRefQuery(ref)
setBaseRefResults([])
onSelect(ref)
}}
className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm transition-colors hover:bg-muted/60 ${
effectiveBaseRef === ref
? 'bg-accent text-accent-foreground'
: 'text-foreground'
}`}
>
<span className="truncate">{ref}</span>
{effectiveBaseRef === ref ? (
<span className="text-[10px] uppercase tracking-[0.18em]">Current</span>
) : null}
</button>
))}
</div>
</ScrollArea>
) : (
<p className="text-xs text-muted-foreground">No matching branches found.</p>
)
) : null}
</div>
)
}

View File

@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import type { OrcaHooks, Repo, RepoHookSettings } from '../../../../shared/types'
import { REPO_COLORS } from '../../../../shared/constants'
import { ScrollArea } from '../ui/scroll-area'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
@ -10,6 +9,7 @@ import { Trash2 } from 'lucide-react'
import { HookEditor } from './HookEditor'
import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants'
import type { HookName } from './SettingsConstants'
import { BaseRefPicker } from './BaseRefPicker'
type RepositoryPaneProps = {
repo: Repo
@ -25,80 +25,6 @@ export function RepositoryPane({
removeRepo
}: RepositoryPaneProps): React.JSX.Element {
const [confirmingRemove, setConfirmingRemove] = useState<string | null>(null)
const [defaultBaseRef, setDefaultBaseRef] = useState('origin/main')
const [baseRefQuery, setBaseRefQuery] = useState('')
const [baseRefResults, setBaseRefResults] = useState<string[]>([])
const [isSearchingBaseRefs, setIsSearchingBaseRefs] = useState(false)
useEffect(() => {
let stale = false
const loadDefaultBaseRef = async (repoId: string) => {
try {
const result = await window.api.repos.getBaseRefDefault({ repoId })
if (stale) {
return
}
setDefaultBaseRef(result)
} catch {
if (stale) {
return
}
setDefaultBaseRef('origin/main')
}
}
setBaseRefQuery('')
setBaseRefResults([])
void loadDefaultBaseRef(repo.id)
return () => {
stale = true
}
}, [repo.id])
useEffect(() => {
const trimmedQuery = baseRefQuery.trim()
if (trimmedQuery.length < 2) {
setBaseRefResults([])
setIsSearchingBaseRefs(false)
return
}
let stale = false
setIsSearchingBaseRefs(true)
const timer = window.setTimeout(() => {
void window.api.repos
.searchBaseRefs({
repoId: repo.id,
query: trimmedQuery,
limit: 20
})
.then((results) => {
if (!stale) {
setBaseRefResults(results)
}
})
.catch(() => {
if (!stale) {
setBaseRefResults([])
}
})
.finally(() => {
if (!stale) {
setIsSearchingBaseRefs(false)
}
})
}, 200)
return () => {
stale = true
window.clearTimeout(timer)
}
}, [repo.id, baseRefQuery])
const effectiveBaseRef = repo.worktreeBaseRef ?? defaultBaseRef
const handleRemoveRepo = (repoId: string) => {
if (confirmingRemove === repoId) {
@ -188,77 +114,12 @@ export function RepositoryPane({
<div className="space-y-3">
<Label>Default Worktree Base</Label>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-sm font-medium text-foreground">{effectiveBaseRef}</div>
<p className="text-xs text-muted-foreground">
{repo.worktreeBaseRef
? 'Pinned for this repo'
: `Following primary branch (${defaultBaseRef})`}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setBaseRefQuery('')
setBaseRefResults([])
updateRepo(repo.id, {
worktreeBaseRef: undefined
})
}}
disabled={!repo.worktreeBaseRef}
>
Use Primary
</Button>
</div>
<div className="space-y-2">
<Input
value={baseRefQuery}
onChange={(e) => setBaseRefQuery(e.target.value)}
placeholder="Search branches by name..."
className="max-w-md"
/>
<p className="text-xs text-muted-foreground">Type at least 2 characters.</p>
</div>
{isSearchingBaseRefs ? (
<p className="text-xs text-muted-foreground">Searching branches...</p>
) : null}
{!isSearchingBaseRefs && baseRefQuery.trim().length >= 2 ? (
baseRefResults.length > 0 ? (
<ScrollArea className="h-48 rounded-md border border-border/50">
<div className="p-1">
{baseRefResults.map((ref) => (
<button
key={ref}
onClick={() => {
setBaseRefQuery(ref)
setBaseRefResults([])
updateRepo(repo.id, {
worktreeBaseRef: ref
})
}}
className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm transition-colors hover:bg-muted/60 ${
repo.worktreeBaseRef === ref
? 'bg-accent text-accent-foreground'
: 'text-foreground'
}`}
>
<span className="truncate">{ref}</span>
{repo.worktreeBaseRef === ref ? (
<span className="text-[10px] uppercase tracking-[0.18em]">Current</span>
) : null}
</button>
))}
</div>
</ScrollArea>
) : (
<p className="text-xs text-muted-foreground">No matching branches found.</p>
)
) : null}
<BaseRefPicker
repoId={repo.id}
currentBaseRef={repo.worktreeBaseRef}
onSelect={(ref) => updateRepo(repo.id, { worktreeBaseRef: ref })}
onUsePrimary={() => updateRepo(repo.id, { worktreeBaseRef: undefined })}
/>
</div>
</section>

View File

@ -163,7 +163,7 @@ export default function EditorFileTab({
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
window.api.ui.writeClipboardText(file.filePath)
void window.api.ui.writeClipboardText(file.filePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
@ -171,7 +171,7 @@ export default function EditorFileTab({
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
window.api.ui.writeClipboardText(file.relativePath)
void window.api.ui.writeClipboardText(file.relativePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />

View File

@ -49,8 +49,8 @@ describe('createEditorSlice openDiff', () => {
store.getState().openDiff('wt-1', '/repo/file.ts', 'file.ts', 'typescript', true)
expect(store.getState().openFiles.map((file) => file.id)).toEqual([
'/repo/file.ts::unstaged',
'/repo/file.ts::staged'
'wt-1::diff::unstaged::file.ts',
'wt-1::diff::staged::file.ts'
])
})
@ -60,7 +60,7 @@ describe('createEditorSlice openDiff', () => {
store.setState({
openFiles: [
{
id: '/repo/file.ts::staged',
id: 'wt-1::diff::staged::file.ts',
filePath: '/repo/file.ts',
relativePath: 'file.ts',
worktreeId: 'wt-1',
@ -79,12 +79,12 @@ describe('createEditorSlice openDiff', () => {
expect(store.getState().openFiles).toEqual([
expect.objectContaining({
id: '/repo/file.ts::staged',
id: 'wt-1::diff::staged::file.ts',
mode: 'diff',
diffStaged: true
diffSource: 'staged'
})
])
expect(store.getState().activeFileId).toBe('/repo/file.ts::staged')
expect(store.getState().activeFileId).toBe('wt-1::diff::staged::file.ts')
})
})

View File

@ -1,7 +1,31 @@
/* eslint-disable max-lines */
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type { GitStatusEntry, SearchResult } from '../../../../shared/types'
import type {
GitBranchChangeEntry,
GitBranchCompareSummary,
GitStatusEntry,
SearchResult
} from '../../../../shared/types'
export type DiffSource =
| 'unstaged'
| 'staged'
| 'branch'
| 'combined-uncommitted'
| 'combined-branch'
export type BranchCompareSnapshot = Pick<
GitBranchCompareSummary,
'baseRef' | 'baseOid' | 'compareRef' | 'headOid' | 'mergeBase'
> & {
compareVersion: string
}
type CombinedDiffAlternate = {
source: 'combined-uncommitted' | 'combined-branch'
branchCompare?: BranchCompareSnapshot
}
export type OpenFile = {
id: string // use filePath as unique key
@ -11,7 +35,11 @@ export type OpenFile = {
language: string
isDirty: boolean
mode: 'edit' | 'diff'
diffStaged?: boolean
diffSource?: DiffSource
branchCompare?: BranchCompareSnapshot
branchOldPath?: string
combinedAlternate?: CombinedDiffAlternate
combinedAreaFilter?: string // filter combined diff to a specific area (e.g. 'staged', 'unstaged', 'untracked')
isPreview?: boolean // preview tabs are replaced when another file is single-clicked
}
@ -61,7 +89,25 @@ export type EditorSlice = {
language: string,
staged: boolean
) => void
openAllDiffs: (worktreeId: string, worktreePath: string) => void
openBranchDiff: (
worktreeId: string,
worktreePath: string,
entry: GitBranchChangeEntry,
compare: GitBranchCompareSummary,
language: string
) => void
openAllDiffs: (
worktreeId: string,
worktreePath: string,
alternate?: CombinedDiffAlternate,
areaFilter?: string
) => void
openBranchAllDiffs: (
worktreeId: string,
worktreePath: string,
compare: GitBranchCompareSummary,
alternate?: CombinedDiffAlternate
) => void
// Cursor line tracking per file
editorCursorLine: Record<string, number>
@ -70,6 +116,15 @@ export type EditorSlice = {
// Git status cache
gitStatusByWorktree: Record<string, GitStatusEntry[]>
setGitStatus: (worktreeId: string, entries: GitStatusEntry[]) => void
gitBranchChangesByWorktree: Record<string, GitBranchChangeEntry[]>
gitBranchCompareSummaryByWorktree: Record<string, GitBranchCompareSummary | null>
gitBranchCompareRequestKeyByWorktree: Record<string, string>
beginGitBranchCompareRequest: (worktreeId: string, requestKey: string, baseRef: string) => void
setGitBranchCompareResult: (
worktreeId: string,
requestKey: string,
result: { summary: GitBranchCompareSummary; entries: GitBranchChangeEntry[] }
) => void
// File search state
fileSearchQuery: string
@ -172,7 +227,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const updatedPreview = isPreview ? existing.isPreview : false
if (
existing.mode === file.mode &&
existing.diffStaged === file.diffStaged &&
existing.diffSource === file.diffSource &&
existing.branchCompare?.compareVersion === file.branchCompare?.compareVersion &&
existing.isPreview === updatedPreview
) {
return activeResult
@ -180,7 +236,15 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
return {
openFiles: s.openFiles.map((f) =>
f.id === id
? { ...f, mode: file.mode, diffStaged: file.diffStaged, isPreview: updatedPreview }
? {
...f,
mode: file.mode,
diffSource: file.diffSource,
branchCompare: file.branchCompare,
branchOldPath: file.branchOldPath,
combinedAlternate: file.combinedAlternate,
isPreview: updatedPreview
}
: f
),
...activeResult
@ -364,16 +428,15 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
openDiff: (worktreeId, filePath, relativePath, language, staged) =>
set((s) => {
const id = `${filePath}::${staged ? 'staged' : 'unstaged'}`
const diffSource: DiffSource = staged ? 'staged' : 'unstaged'
const id = `${worktreeId}::diff::${diffSource}::${relativePath}`
const existing = s.openFiles.find((f) => f.id === id)
if (existing) {
// Ensure mode and diffStaged are up-to-date (e.g. if a plain edit tab
// previously occupied this id before the suffix scheme changed).
const needsUpdate = existing.mode !== 'diff' || existing.diffStaged !== staged
const needsUpdate = existing.mode !== 'diff' || existing.diffSource !== diffSource
return {
openFiles: needsUpdate
? s.openFiles.map((f) =>
f.id === id ? { ...f, mode: 'diff' as const, diffStaged: staged } : f
f.id === id ? { ...f, mode: 'diff' as const, diffSource } : f
)
: s.openFiles,
activeFileId: id,
@ -390,7 +453,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
language,
isDirty: false,
mode: 'diff',
diffStaged: staged
diffSource
}
return {
openFiles: [...s.openFiles, newFile],
@ -401,12 +464,67 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
}
}),
openAllDiffs: (worktreeId, worktreePath) =>
openBranchDiff: (worktreeId, worktreePath, entry, compare, language) =>
set((s) => {
const id = `${worktreeId}::all-diffs`
const branchCompare = toBranchCompareSnapshot(compare)
const id = `${worktreeId}::diff::branch::${compare.baseRef}::${branchCompare.compareVersion}::${entry.path}`
const existing = s.openFiles.find((f) => f.id === id)
if (existing) {
return {
openFiles: s.openFiles.map((f) =>
f.id === id
? {
...f,
mode: 'diff' as const,
diffSource: 'branch' as const,
branchCompare,
branchOldPath: entry.oldPath
}
: f
),
activeFileId: id,
activeTabType: 'editor',
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
}
}
const newFile: OpenFile = {
id,
filePath: `${worktreePath}/${entry.path}`,
relativePath: entry.path,
worktreeId,
language,
isDirty: false,
mode: 'diff',
diffSource: 'branch',
branchCompare,
branchOldPath: entry.oldPath
}
return {
openFiles: [...s.openFiles, newFile],
activeFileId: id,
activeTabType: 'editor',
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
}
}),
openAllDiffs: (worktreeId, worktreePath, alternate, areaFilter) =>
set((s) => {
const id = areaFilter
? `${worktreeId}::all-diffs::uncommitted::${areaFilter}`
: `${worktreeId}::all-diffs::uncommitted`
const label = areaFilter
? ({ staged: 'Staged Changes', unstaged: 'Changes', untracked: 'Untracked Files' }[
areaFilter
] ?? 'All Changes')
: 'All Changes'
const existing = s.openFiles.find((f) => f.id === id)
if (existing) {
return {
openFiles: s.openFiles.map((f) =>
f.id === id ? { ...f, combinedAlternate: alternate, combinedAreaFilter: areaFilter } : f
),
activeFileId: id,
activeTabType: 'editor',
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
@ -416,12 +534,51 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const newFile: OpenFile = {
id,
filePath: worktreePath,
relativePath: 'All Changes',
relativePath: label,
worktreeId,
language: 'plaintext',
isDirty: false,
mode: 'diff',
diffStaged: undefined
diffSource: 'combined-uncommitted',
combinedAlternate: alternate,
combinedAreaFilter: areaFilter
}
return {
openFiles: [...s.openFiles, newFile],
activeFileId: id,
activeTabType: 'editor',
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
}
}),
openBranchAllDiffs: (worktreeId, worktreePath, compare, alternate) =>
set((s) => {
const branchCompare = toBranchCompareSnapshot(compare)
const id = `${worktreeId}::all-diffs::branch::${compare.baseRef}::${branchCompare.compareVersion}`
const existing = s.openFiles.find((f) => f.id === id)
if (existing) {
return {
openFiles: s.openFiles.map((f) =>
f.id === id ? { ...f, branchCompare, combinedAlternate: alternate } : f
),
activeFileId: id,
activeTabType: 'editor',
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' }
}
}
const newFile: OpenFile = {
id,
filePath: worktreePath,
relativePath: `Branch Changes (${compare.baseRef})`,
worktreeId,
language: 'plaintext',
isDirty: false,
mode: 'diff',
diffSource: 'combined-branch',
branchCompare,
combinedAlternate: alternate
}
return {
openFiles: [...s.openFiles, newFile],
@ -442,9 +599,78 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
// Git status
gitStatusByWorktree: {},
setGitStatus: (worktreeId, entries) =>
set((s) => {
const prev = s.gitStatusByWorktree[worktreeId]
if (
prev &&
prev.length === entries.length &&
prev.every(
(e, i) =>
e.path === entries[i].path &&
e.status === entries[i].status &&
e.area === entries[i].area
)
) {
return s
}
return { gitStatusByWorktree: { ...s.gitStatusByWorktree, [worktreeId]: entries } }
}),
gitBranchChangesByWorktree: {},
gitBranchCompareSummaryByWorktree: {},
gitBranchCompareRequestKeyByWorktree: {},
beginGitBranchCompareRequest: (worktreeId, requestKey, baseRef) =>
set((s) => ({
gitStatusByWorktree: { ...s.gitStatusByWorktree, [worktreeId]: entries }
gitBranchCompareRequestKeyByWorktree: {
...s.gitBranchCompareRequestKeyByWorktree,
[worktreeId]: requestKey
},
gitBranchCompareSummaryByWorktree: {
...s.gitBranchCompareSummaryByWorktree,
[worktreeId]: {
baseRef,
baseOid: null,
compareRef: 'HEAD',
headOid: null,
mergeBase: null,
changedFiles: 0,
status: 'loading'
}
}
})),
setGitBranchCompareResult: (worktreeId, requestKey, result) =>
set((s) => {
if (s.gitBranchCompareRequestKeyByWorktree[worktreeId] !== requestKey) {
return s
}
const prevEntries = s.gitBranchChangesByWorktree[worktreeId]
const prevSummary = s.gitBranchCompareSummaryByWorktree[worktreeId]
const entriesUnchanged =
prevEntries &&
prevEntries.length === result.entries.length &&
prevEntries.every(
(e, i) =>
e.path === result.entries[i].path &&
e.status === result.entries[i].status &&
e.oldPath === result.entries[i].oldPath
)
const summaryUnchanged =
prevSummary &&
prevSummary.status === result.summary.status &&
prevSummary.baseOid === result.summary.baseOid &&
prevSummary.headOid === result.summary.headOid &&
prevSummary.changedFiles === result.summary.changedFiles
if (entriesUnchanged && summaryUnchanged) {
return s
}
return {
gitBranchChangesByWorktree: entriesUnchanged
? s.gitBranchChangesByWorktree
: { ...s.gitBranchChangesByWorktree, [worktreeId]: result.entries },
gitBranchCompareSummaryByWorktree: summaryUnchanged
? s.gitBranchCompareSummaryByWorktree
: { ...s.gitBranchCompareSummaryByWorktree, [worktreeId]: result.summary }
}
}),
// File search
fileSearchQuery: '',
@ -490,3 +716,24 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
quickOpenVisible: false,
setQuickOpenVisible: (visible) => set({ quickOpenVisible: visible })
})
function getCompareVersion(
compare: Pick<GitBranchCompareSummary, 'baseOid' | 'headOid' | 'mergeBase'>
): string {
return [
compare.baseOid ?? 'no-base',
compare.headOid ?? 'no-head',
compare.mergeBase ?? 'no-merge-base'
].join(':')
}
function toBranchCompareSnapshot(compare: GitBranchCompareSummary): BranchCompareSnapshot {
return {
baseRef: compare.baseRef,
baseOid: compare.baseOid,
compareRef: compare.compareRef,
headOid: compare.headOid,
mergeBase: compare.mergeBase,
compareVersion: getCompareVersion(compare)
}
}

View File

@ -211,20 +211,59 @@ export type DirEntry = {
export type GitFileStatus = 'modified' | 'added' | 'deleted' | 'renamed' | 'untracked' | 'copied'
export type GitStagingArea = 'staged' | 'unstaged' | 'untracked'
export type GitStatusEntry = {
export type GitUncommittedEntry = {
path: string
status: GitFileStatus
area: GitStagingArea
oldPath?: string
}
export type GitDiffResult = {
export type GitStatusEntry = GitUncommittedEntry
export type GitBranchChangeStatus = 'modified' | 'added' | 'deleted' | 'renamed' | 'copied'
export type GitBranchChangeEntry = {
path: string
status: GitBranchChangeStatus
oldPath?: string
}
export type GitBranchCompareSummary = {
baseRef: string
baseOid: string | null
compareRef: string
headOid: string | null
mergeBase: string | null
changedFiles: number
commitsAhead?: number
status: 'ready' | 'invalid-base' | 'unborn-head' | 'no-merge-base' | 'loading' | 'error'
errorMessage?: string
}
export type GitBranchCompareResult = {
summary: GitBranchCompareSummary
entries: GitBranchChangeEntry[]
}
export type GitDiffTextResult = {
kind: 'text'
originalContent: string
modifiedContent: string
isImage?: boolean
mimeType?: string
originalIsBinary: false
modifiedIsBinary: false
}
export type GitDiffBinaryResult = {
kind: 'binary'
originalContent: string
modifiedContent: string
} & (
| { originalIsBinary: true; modifiedIsBinary: boolean }
| { originalIsBinary: boolean; modifiedIsBinary: true }
)
export type GitDiffResult = GitDiffTextResult | GitDiffBinaryResult
// ─── Search ─────────────────────────────────────────────
export type SearchMatch = {
line: number