Skip to content

feat(diff-view): unified changes view (split/unified toggle)#2473

Draft
snirye wants to merge 11 commits into
generalaction:mainfrom
snirye:emdash/changes-view-wu9ld
Draft

feat(diff-view): unified changes view (split/unified toggle)#2473
snirye wants to merge 11 commits into
generalaction:mainfrom
snirye:emdash/changes-view-wu9ld

Conversation

@snirye

@snirye snirye commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a Unified view to the Changes panel that lists every file differing between the working tree and the PR base (or default branch when no PR exists), under a single read-only list. The existing 3-section Split view is preserved and the user toggles between them via a new icon in the panel header. Persisted per-task via app settings.

  • getMergeBase RPC + new unified diff group plumbed through tab manager / lifecycle / file renderer (orig = merge-base commit, modified = working-tree buffer)
  • UnifiedChangesStore derives base ref (PR base ?? default branch), fetches merge-base(base, HEAD), and produces one row per file via existing getChangedFiles
  • Refresh piggy-backs on the same git/fs/ref events as the split-view status pipeline (no new poll loops)
  • Diff is read-only — toolbar labels the source as All changes (read-only)

Test plan

  • Open a task with an open PR, modify a file with both committed-unpushed and uncommitted edits → flip to Unified → file appears once with combined diff
  • Open a task with no PR, ensure Unified compares against the project's default branch
  • Toggle between Split and Unified — split-mode selections preserved across mode flips
  • Switch list/tree mode while in Unified — persists independently from split sections
  • Empty states render: no base configured, no merge-base (orphan branch), no diff vs base

Spec: docs/superpowers/specs/2026-06-10-unified-changes-view-design.md
Plan: docs/superpowers/plans/2026-06-10-unified-changes-view.md

Comment on lines +97 to +109
private async _fetchChanges(): Promise<GitChange[]> {
const base = this.baseRef;
if (!base) return [];
const mbResult = await rpc.workspace.git.getMergeBase(this.projectId, this.workspaceId, base);
if (!mbResult.success || !mbResult.data.sha) return [];
const result = await rpc.workspace.git.getChangedFiles(
this.projectId,
this.workspaceId,
commitRef(mbResult.data.sha)
);
if (!result.success) return [];
return result.data.changes;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 mergeBase and changes use independent, unsynchronised SHA fetches

_fetchChanges calls getMergeBase via RPC on its own (line 100), while UnifiedSection.open() reads from unified.mergeBase.data (the separate mergeBase Resource). Because the two resources run on independent refresh cycles with different debounce timers (e.g. 100 ms for HEAD vs 300 ms for index), their cached SHAs can momentarily diverge. If a user clicks a file while mergeBase.data still holds the previous SHA but changes was already rebuilt with a newer one, the diff viewer opens with an originalRef that does not match the comparison used to produce the file list — the rendered diff can show the wrong additions and deletions.

Consider returning { sha, changes } from a single fetch function and exposing one combined Resource, or making _fetchChanges read from this.mergeBase so that both consumers always use the same SHA.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/unified-changes-store.ts
Line: 97-109

Comment:
**`mergeBase` and `changes` use independent, unsynchronised SHA fetches**

`_fetchChanges` calls `getMergeBase` via RPC on its own (line 100), while `UnifiedSection.open()` reads from `unified.mergeBase.data` (the separate `mergeBase` Resource). Because the two resources run on independent refresh cycles with different debounce timers (e.g. 100 ms for HEAD vs 300 ms for index), their cached SHAs can momentarily diverge. If a user clicks a file while `mergeBase.data` still holds the previous SHA but `changes` was already rebuilt with a newer one, the diff viewer opens with an `originalRef` that does not match the comparison used to produce the file list — the rendered diff can show the wrong additions and deletions.

Consider returning `{ sha, changes }` from a single fetch function and exposing one combined `Resource`, or making `_fetchChanges` read from `this.mergeBase` so that both consumers always use the same SHA.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +40 to +47
this.mergeBase = new Resource<string | null>(
() => this._fetchMergeBase(),
this._statusEventStrategies('merge-base')
);

this.changes = new Resource<GitChange[]>(
() => this._fetchChanges(),
this._statusEventStrategies('unified-changes')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant filesystem watcher registered for mergeBase resource

Both mergeBase and changes call _statusEventStrategies(watchTag), which registers a filesystem watcher (path '', watch-tag unified-changes-merge-base and unified-changes-unified-changes respectively). The merge-base SHA can only change when HEAD or a remote ref moves — never when an ordinary working-tree file is saved. Yet the mergeBase resource is triggered and issues a getMergeBase RPC call on every non-.git file modification, doubling unnecessary round-trips on every keystroke. The mergeBase resource only needs the HEAD, index, and ref-changed event strategies; the filesystem strategy can be dropped from it.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/unified-changes-store.ts
Line: 40-47

Comment:
**Redundant filesystem watcher registered for `mergeBase` resource**

Both `mergeBase` and `changes` call `_statusEventStrategies(watchTag)`, which registers a filesystem watcher (path `''`, watch-tag `unified-changes-merge-base` and `unified-changes-unified-changes` respectively). The merge-base SHA can only change when HEAD or a remote ref moves — never when an ordinary working-tree file is saved. Yet the `mergeBase` resource is triggered and issues a `getMergeBase` RPC call on every non-`.git` file modification, doubling unnecessary round-trips on every keystroke. The `mergeBase` resource only needs the HEAD, index, and ref-changed event strategies; the filesystem strategy can be dropped from it.

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps

greptile-apps Bot commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds a Unified view to the Changes panel that lists every file differing between the working tree and the merge-base of the PR base (or default branch), with a split/unified toggle persisted in app settings. The existing three-section Split view is preserved intact.

  • New UnifiedChangesStore exposes two independent Resource instances (mergeBase and changes), wired into DiffViewStore with proper start/dispose lifecycle.
  • New getMergeBase RPC endpoint (git-service + controller) computes git merge-base <base> HEAD; unified is threaded through diffGroup, ActiveFile, tab lifecycle, file renderer, and toolbar.
  • Toggle is a thin hook reading/writing the new changesPanelMode app-settings key, rendered in a new SplitUnifiedToggle component in the panel header.

Confidence Score: 3/5

The feature is largely well-structured, but UnifiedChangesStore has a design flaw where the merge-base SHA used to open a diff can differ from the SHA used to build the file list, which can cause the diff viewer to show incorrect additions/deletions.

The _fetchChanges method fetches the merge-base via its own independent RPC call rather than reading from the sibling mergeBase Resource. UnifiedSection.open() then uses mergeBase.data as originalRef. If the two resources are momentarily out of sync — e.g. changes has refreshed after a HEAD change but mergeBase.data still holds the previous SHA — the diff viewer opens the file against the wrong commit, showing incorrect additions and deletions.

unified-changes-store.ts needs the most attention: the split between _fetchMergeBase and _fetchChanges should be collapsed into a single fetch so both consumers always share the same SHA.

Important Files Changed

Filename Overview
apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/unified-changes-store.ts New store for unified changes view; has a P1 race condition where _fetchChanges re-fetches the merge base independently from the mergeBase Resource, and a P2 redundant filesystem watcher on the mergeBase resource.
apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/unified-section.tsx New unified section component; reads mergeBase.data for originalRef in open() while the file list is built from a separate fetch inside _fetchChanges — coherent with the P1 in the store.
apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/diff-view-store.ts Wires up UnifiedChangesStore and adds 'unified' guard to activeFile override and reaction; disposal is handled correctly.
apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-file-renderer.tsx Extends disk-branch logic to also cover unified for model registration, URI resolution, and cleanup; pattern is consistent.
apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/changes-panel.tsx Adds split/unified toggle header and conditionally renders split or unified layout; structurally clean refactor.
apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/hooks/use-panel-mode.ts Thin hook persisting panel mode to app settings; uses as never cast to work around a type gap in the settings update callback.
apps/emdash-desktop/src/main/core/git/impl/git-service.ts Adds getMergeBase to GitService; correctly runs git merge-base <base> HEAD and returns null on failure.
apps/emdash-desktop/src/main/core/git/controller.ts New getMergeBase RPC handler; follows existing error-handling pattern with Result type and structured logging.
apps/emdash-desktop/src/main/core/settings/schema.ts Adds changesPanelModeSchema ('split'

Sequence Diagram

sequenceDiagram
    participant UI as UnifiedSection
    participant UCStore as UnifiedChangesStore
    participant MB as mergeBase Resource
    participant CH as changes Resource
    participant RPC as rpc.git

    Note over UI,RPC: Initial load / refresh
    UCStore->>MB: start()
    UCStore->>CH: start()
    MB->>RPC: getMergeBase(base)
    RPC-->>MB: "sha="abc123""
    CH->>RPC: getMergeBase(base)
    RPC-->>CH: "sha="abc123""
    CH->>RPC: getChangedFiles(commitRef("abc123"))
    RPC-->>CH: [file1, file2]

    Note over UI,RPC: HEAD advances → both resources invalidated
    MB->>RPC: getMergeBase(base)
    RPC-->>MB: "sha="def456""
    Note over UI: mergeBase.data = "def456"
    CH->>RPC: getMergeBase(base)
    Note over UI: changes still showing old list
    RPC-->>CH: "sha="def456""
    CH->>RPC: getChangedFiles(commitRef("def456"))
    RPC-->>CH: [file1, file2, file3]

    Note over UI,RPC: Race window — user clicks file3
    UI->>UI: open(file3) uses mergeBase.data as originalRef
    Note over UI: If mergeBase.data ≠ SHA in _fetchChanges, wrong diff shown
Loading

Comments Outside Diff (1)

  1. apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/hooks/use-panel-mode.ts, line 7 (link)

    P2 as never suppresses type safety on settings update

    update(next as never) works around a typing gap in useAppSettingsKey's update callback. Casting to never defeats TypeScript's ability to catch mismatched setting types at call sites. The underlying issue is that the inferred type of update is too narrow, so next should be explicitly typed to match, or the hook wrapper should be widened to accept the schema type directly.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/hooks/use-panel-mode.ts
    Line: 7
    
    Comment:
    **`as never` suppresses type safety on settings update**
    
    `update(next as never)` works around a typing gap in `useAppSettingsKey`'s `update` callback. Casting to `never` defeats TypeScript's ability to catch mismatched setting types at call sites. The underlying issue is that the inferred type of `update` is too narrow, so `next` should be explicitly typed to match, or the hook wrapper should be widened to accept the schema type directly.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/unified-changes-store.ts:97-109
**`mergeBase` and `changes` use independent, unsynchronised SHA fetches**

`_fetchChanges` calls `getMergeBase` via RPC on its own (line 100), while `UnifiedSection.open()` reads from `unified.mergeBase.data` (the separate `mergeBase` Resource). Because the two resources run on independent refresh cycles with different debounce timers (e.g. 100 ms for HEAD vs 300 ms for index), their cached SHAs can momentarily diverge. If a user clicks a file while `mergeBase.data` still holds the previous SHA but `changes` was already rebuilt with a newer one, the diff viewer opens with an `originalRef` that does not match the comparison used to produce the file list — the rendered diff can show the wrong additions and deletions.

Consider returning `{ sha, changes }` from a single fetch function and exposing one combined `Resource`, or making `_fetchChanges` read from `this.mergeBase` so that both consumers always use the same SHA.

### Issue 2 of 3
apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/unified-changes-store.ts:40-47
**Redundant filesystem watcher registered for `mergeBase` resource**

Both `mergeBase` and `changes` call `_statusEventStrategies(watchTag)`, which registers a filesystem watcher (path `''`, watch-tag `unified-changes-merge-base` and `unified-changes-unified-changes` respectively). The merge-base SHA can only change when HEAD or a remote ref moves — never when an ordinary working-tree file is saved. Yet the `mergeBase` resource is triggered and issues a `getMergeBase` RPC call on every non-`.git` file modification, doubling unnecessary round-trips on every keystroke. The `mergeBase` resource only needs the HEAD, index, and ref-changed event strategies; the filesystem strategy can be dropped from it.

### Issue 3 of 3
apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/hooks/use-panel-mode.ts:7
**`as never` suppresses type safety on settings update**

`update(next as never)` works around a typing gap in `useAppSettingsKey`'s `update` callback. Casting to `never` defeats TypeScript's ability to catch mismatched setting types at call sites. The underlying issue is that the inferred type of `update` is too narrow, so `next` should be explicitly typed to match, or the hook wrapper should be widened to accept the schema type directly.

```suggestion
  const setMode = (next: ChangesPanelMode) => update(next);
```

Reviews (1): Last reviewed commit: "chore: format unified changes view files" | Re-trigger Greptile

@snirye snirye marked this pull request as draft June 10, 2026 15:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant