diff --git a/apps/emdash-desktop/src/main/core/git/controller.ts b/apps/emdash-desktop/src/main/core/git/controller.ts index 3b5136377..b186385da 100644 --- a/apps/emdash-desktop/src/main/core/git/controller.ts +++ b/apps/emdash-desktop/src/main/core/git/controller.ts @@ -89,6 +89,18 @@ export const gitController = createRPCController({ } }, + getMergeBase: async (projectId: string, workspaceId: string, base: GitObjectRef) => { + try { + const env = resolveWorkspace(projectId, workspaceId); + if (!env) return err({ type: 'not_found' as const }); + const sha = await env.git.getMergeBase(base); + return ok({ sha }); + } catch (e) { + log.error('gitCtrl.getMergeBase failed', { projectId, workspaceId, base, error: e }); + return err({ type: 'git_error' as const, message: String(e) }); + } + }, + getFileAtHead: async (projectId: string, workspaceId: string, filePath: string) => { try { const env = resolveWorkspace(projectId, workspaceId); diff --git a/apps/emdash-desktop/src/main/core/git/impl/git-service.ts b/apps/emdash-desktop/src/main/core/git/impl/git-service.ts index bd2171739..280961f9d 100644 --- a/apps/emdash-desktop/src/main/core/git/impl/git-service.ts +++ b/apps/emdash-desktop/src/main/core/git/impl/git-service.ts @@ -957,6 +957,17 @@ export class GitService implements GitProvider, IDisposable { return changes; } + async getMergeBase(base: GitObjectRef): Promise { + const baseStr = toRefString(base); + try { + const { stdout } = await this.ctx.exec('git', ['merge-base', baseStr, 'HEAD']); + const sha = stdout.trim(); + return sha === '' ? null : sha; + } catch { + return null; + } + } + async getCommitFiles(commitHash: string): Promise { const { stdout } = await this.ctx.exec('git', [ 'diff-tree', diff --git a/apps/emdash-desktop/src/main/core/git/workspace-git-provider.ts b/apps/emdash-desktop/src/main/core/git/workspace-git-provider.ts index 8cc197737..9163c01e2 100644 --- a/apps/emdash-desktop/src/main/core/git/workspace-git-provider.ts +++ b/apps/emdash-desktop/src/main/core/git/workspace-git-provider.ts @@ -43,6 +43,7 @@ export interface WorkspaceGitProvider extends Hookable { */ getWorktreeGitDir(mainDotGitAbs: string): Promise; getChangedFiles(base: DiffMode | GitObjectRef | MergeBaseRange): Promise; + getMergeBase(base: GitObjectRef): Promise; getFileDiff(filePath: string, base?: DiffMode | GitObjectRef): Promise; getFileAtHead(filePath: string): Promise; diff --git a/apps/emdash-desktop/src/main/core/settings/schema.ts b/apps/emdash-desktop/src/main/core/settings/schema.ts index 248834fc6..f604bd60c 100644 --- a/apps/emdash-desktop/src/main/core/settings/schema.ts +++ b/apps/emdash-desktop/src/main/core/settings/schema.ts @@ -117,8 +117,11 @@ export const changesViewModeSchema = z.object({ unstaged: z.enum(['flat', 'tree']), staged: z.enum(['flat', 'tree']), pr: z.enum(['flat', 'tree']), + unified: z.enum(['flat', 'tree']), }); +export const changesPanelModeSchema = z.enum(['split', 'unified']); + export const browserPreviewSettingsSchema = z.object({ enabled: z.boolean() }); export const resourceMonitorSettingsSchema = z.object({ enabled: z.boolean() }); @@ -143,6 +146,7 @@ export const APP_SETTINGS_SCHEMA_MAP = { browserPreview: browserPreviewSettingsSchema, resourceMonitor: resourceMonitorSettingsSchema, changesViewMode: changesViewModeSchema, + changesPanelMode: changesPanelModeSchema, } as const; export const appSettingsSchema = z.object({ @@ -160,4 +164,5 @@ export const appSettingsSchema = z.object({ browserPreview: browserPreviewSettingsSchema, resourceMonitor: resourceMonitorSettingsSchema, changesViewMode: changesViewModeSchema, + changesPanelMode: changesPanelModeSchema, }); diff --git a/apps/emdash-desktop/src/main/core/settings/settings-registry.ts b/apps/emdash-desktop/src/main/core/settings/settings-registry.ts index 20464a378..db5e95757 100644 --- a/apps/emdash-desktop/src/main/core/settings/settings-registry.ts +++ b/apps/emdash-desktop/src/main/core/settings/settings-registry.ts @@ -70,7 +70,9 @@ export const SETTINGS_DEFAULTS = { unstaged: 'flat' as const, staged: 'flat' as const, pr: 'flat' as const, + unified: 'flat' as const, }, + changesPanelMode: 'split' as const, } satisfies SettingsDefaultsMap; export function getDefaultForKey(key: K): AppSettings[K] { diff --git a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/changes-panel.tsx b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/changes-panel.tsx index 2af87a808..f2a0ade4a 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/changes-panel.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/changes-panel.tsx @@ -2,10 +2,13 @@ import { observer } from 'mobx-react-lite'; import { useWorkspace, useWorkspaceViewModel } from '@renderer/features/tasks/task-view-context'; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@renderer/lib/ui/resizable'; import { cn } from '@renderer/utils/utils'; +import { SplitUnifiedToggle } from './components/split-unified-toggle'; import { GitStatusSection } from './git-status-section'; import { SECTION_HEADER_HEIGHT, usePanelLayout } from './hooks/use-panel-layout'; +import { usePanelMode } from './hooks/use-panel-mode'; import { PullRequestsSection } from './pr-section'; import { StagedSection } from './staged-section'; +import { UnifiedSection } from './unified-section'; import { UnstagedSection } from './unstaged-section'; export const ChangesPanel = observer(function ChangesPanel() { @@ -25,69 +28,80 @@ export const ChangesPanel = observer(function ChangesPanel() { spacerRef, } = usePanelLayout(changesView ?? null); + const { mode: panelMode, setMode: setPanelMode } = usePanelMode(); + if (!diffView || !changesView || !workspace.git.hasData) return null; return (
- - - - - - + +
+ {panelMode === 'split' ? ( + - - - - - toggleExpanded('pullRequests')} - collapsed={!expanded.pullRequests} + + + + + + + + + + toggleExpanded('pullRequests')} + collapsed={!expanded.pullRequests} + /> + + - - - + + ) : ( +
+ +
+ )} ); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/split-unified-toggle.tsx b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/split-unified-toggle.tsx new file mode 100644 index 000000000..6612abae1 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/split-unified-toggle.tsx @@ -0,0 +1,34 @@ +import { Layers, Rows3 } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '@renderer/lib/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/lib/ui/tooltip'; +import type { ChangesPanelMode } from '@shared/core/app-settings'; + +interface SplitUnifiedToggleProps { + value: ChangesPanelMode; + onChange: (mode: ChangesPanelMode) => void; +} + +export function SplitUnifiedToggle({ value, onChange }: SplitUnifiedToggleProps) { + const next: ChangesPanelMode = value === 'split' ? 'unified' : 'split'; + const Icon = value === 'split' ? Layers : Rows3; + const tooltip = value === 'split' ? 'Switch to unified view' : 'Switch to split view'; + const [open, setOpen] = useState(false); + + return ( + { + if (!nextOpen && details.reason === 'trigger-press') return; + setOpen(nextOpen); + }} + > + + + + {tooltip} + + ); +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/hooks/use-panel-mode.ts b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/hooks/use-panel-mode.ts new file mode 100644 index 000000000..ff201d88e --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/hooks/use-panel-mode.ts @@ -0,0 +1,9 @@ +import { useAppSettingsKey } from '@renderer/features/settings/use-app-settings-key'; +import type { ChangesPanelMode } from '@shared/core/app-settings'; + +export function usePanelMode() { + const { value, update } = useAppSettingsKey('changesPanelMode'); + const mode: ChangesPanelMode = value ?? 'split'; + const setMode = (next: ChangesPanelMode) => update(next as never); + return { mode, setMode }; +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/unified-section.tsx b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/unified-section.tsx new file mode 100644 index 000000000..2dac7ef68 --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/unified-section.tsx @@ -0,0 +1,85 @@ +import { observer } from 'mobx-react-lite'; +import { useWorkspace, useWorkspaceViewModel } from '@renderer/features/tasks/task-view-context'; +import { EmptyState } from '@renderer/lib/ui/empty-state'; +import { commitRef, type GitChange } from '@shared/core/git/git'; +import { ChangesListOrTree } from './components/changes-list-or-tree'; +import { ChangesViewModeToggle } from './components/changes-view-mode-toggle'; +import { SectionHeader } from './components/section-header'; +import { useChangesViewMode } from './hooks/use-changes-view-mode'; + +export const UnifiedSection = observer(function UnifiedSection() { + const taskView = useWorkspaceViewModel(); + const workspace = useWorkspace(); + const diffView = taskView.diffView; + const unified = diffView?.unifiedChanges; + + const { mode: viewMode, setMode: setViewMode } = useChangesViewMode('unified'); + + if (!diffView || !unified || !workspace.git.hasData) return null; + + const mergeBaseSha = unified.mergeBase.data; + const changes = unified.changes.data ?? []; + const hasChanges = changes.length > 0; + const isLoading = unified.changes.loading || unified.mergeBase.loading; + const baseUnresolvable = unified.baseRef === null; + const noMergeBase = !mergeBaseSha && unified.baseRef !== null && !isLoading; + + const activePath = + taskView.tabManager.activeDescriptor?.kind === 'diff' && + taskView.tabManager.activeDescriptor.diffGroup === 'unified' + ? taskView.tabManager.activeDescriptor.path + : undefined; + + const open = (change: GitChange, preview: boolean) => { + if (!mergeBaseSha) return; + const activeFile = { + path: change.path, + type: 'disk' as const, + group: 'unified' as const, + originalRef: commitRef(mergeBaseSha), + }; + if (preview) taskView.tabManager.openDiffPreview(activeFile, change.status); + else taskView.tabManager.openDiff(activeFile, change.status); + }; + + return ( +
+ + } + /> +
+ {baseUnresolvable && ( + + )} + {!baseUnresolvable && noMergeBase && ( + + )} + {!baseUnresolvable && !noMergeBase && !isLoading && !hasChanges && ( + + )} +
+ open(c, true)} + onDoubleClickChange={(c) => open(c, false)} + /> +
+
+
+ ); +}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-file-renderer.tsx b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-file-renderer.tsx index d6c0dfca2..8dd3fc551 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-file-renderer.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-file-renderer.tsx @@ -113,7 +113,7 @@ const MonacoDiffRenderer = observer(function MonacoDiffRenderer({ tab }: DiffFil if (tab.diffGroup === 'disk') { return modelRegistry.toGitUri(uri, STAGED_REF); } - if (tab.diffGroup === 'git' || tab.diffGroup === 'pr') { + if (tab.diffGroup === 'git' || tab.diffGroup === 'pr' || tab.diffGroup === 'unified') { return modelRegistry.toGitUri(uri, tab.originalRef); } return modelRegistry.toGitUri(uri, HEAD_REF); @@ -127,13 +127,14 @@ const MonacoDiffRenderer = observer(function MonacoDiffRenderer({ tab }: DiffFil if (tab.diffGroup === 'git') { return modelRegistry.toGitUri(uri, tab.modifiedRef ?? HEAD_REF); } + // 'disk' and 'unified' both compare against the live working-tree buffer. return uri; })(); useEffect(() => { let disposed = false; - if (tab.diffGroup === 'disk') { + if (tab.diffGroup === 'disk' || tab.diffGroup === 'unified') { const diskUri = modelRegistry.toDiskUri(uri); void (async () => { if (tab.status !== 'deleted') { @@ -166,8 +167,9 @@ const MonacoDiffRenderer = observer(function MonacoDiffRenderer({ tab }: DiffFil modelRegistry.unregisterModel(modifiedUri); } })().catch(() => {}); + const originalRefForGit = tab.diffGroup === 'unified' ? tab.originalRef : STAGED_REF; void modelRegistry - .registerModel(projectId, workspaceId, root, tab.path, language, 'git', STAGED_REF) + .registerModel(projectId, workspaceId, root, tab.path, language, 'git', originalRefForGit) .catch(() => {}); } else if (tab.diffGroup === 'staged') { void modelRegistry @@ -198,7 +200,7 @@ const MonacoDiffRenderer = observer(function MonacoDiffRenderer({ tab }: DiffFil disposed = true; modelRegistry.unregisterModel(originalUri); modelRegistry.unregisterModel(modifiedUri); - if (tab.diffGroup === 'disk') { + if (tab.diffGroup === 'disk' || tab.diffGroup === 'unified') { modelRegistry.unregisterModel(modelRegistry.toDiskUri(uri)); } }; @@ -265,7 +267,7 @@ function diffTabToCommentTarget(tab: DiffTabStore): DraftCommentTarget { function tabToActiveFile(tab: DiffTabStore): ActiveFile { return { path: tab.path, - type: tab.diffGroup === 'disk' ? 'disk' : 'git', + type: tab.diffGroup === 'disk' || tab.diffGroup === 'unified' ? 'disk' : 'git', group: tab.diffGroup, originalRef: tab.originalRef, modifiedRef: tab.modifiedRef, diff --git a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-toolbar.tsx b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-toolbar.tsx index 27fe99843..df66416bc 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-toolbar.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-toolbar.tsx @@ -18,6 +18,7 @@ export const DiffToolbar = observer(function DiffToolbar({ tab }: DiffToolbarPro if (tab.diffGroup === 'disk') return 'Changed'; if (tab.diffGroup === 'pr') return 'PR'; if (tab.diffGroup === 'git') return 'Git'; + if (tab.diffGroup === 'unified') return 'All changes (read-only)'; return undefined; })(); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/image-diff-view.tsx b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/image-diff-view.tsx index 29c374ff1..9dc1a70bb 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/image-diff-view.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/image-diff-view.tsx @@ -117,6 +117,7 @@ function loadModified( ): Promise { switch (activeFile.group) { case 'disk': + case 'unified': return loadFromDisk(projectId, workspaceId, activeFile.path); case 'staged': return loadGitImage(() => diff --git a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/diff-tab-lifecycle-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/diff-tab-lifecycle-store.ts index 6f70bbfcb..04feb311d 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/diff-tab-lifecycle-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/diff-tab-lifecycle-store.ts @@ -37,7 +37,7 @@ export class DiffTabLifecycleStore { if (tab) { const activeFile: ActiveFile = { path: tab.path, - type: tab.diffGroup === 'disk' ? 'disk' : 'git', + type: tab.diffGroup === 'disk' || tab.diffGroup === 'unified' ? 'disk' : 'git', group: tab.diffGroup, originalRef: tab.originalRef, modifiedRef: tab.modifiedRef, @@ -79,6 +79,7 @@ export class DiffTabLifecycleStore { t !== undefined && t.kind === 'diff' && t.diffGroup !== 'git' && + t.diffGroup !== 'unified' && !validKeys.has(`${t.diffGroup}:${t.path}`) ); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/diff-view-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/diff-view-store.ts index c14abf8b1..5ed9105b8 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/diff-view-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/diff-view-store.ts @@ -5,6 +5,7 @@ import { type Snapshottable } from '@renderer/lib/stores/snapshottable'; import { commitRef, type GitObjectRef } from '@shared/core/git/git'; import type { ActiveFile, DiffViewSnapshot } from '@shared/view-state'; import { type GitStore } from './git-store'; +import { UnifiedChangesStore } from './unified-changes-store'; export const MAX_STACKED_FILES = 8; @@ -28,6 +29,7 @@ export class DiffViewStore implements Snapshottable { prTab: 'files' | 'commits' | 'checks' = 'files'; readonly changesView: ChangesViewStore; + readonly unifiedChanges: UnifiedChangesStore; /** * Index of the override file within its source list at the time it was set. @@ -44,6 +46,13 @@ export class DiffViewStore implements Snapshottable { private readonly pr: PrStore ) { this.changesView = new ChangesViewStore(git, pr); + this.unifiedChanges = new UnifiedChangesStore( + git.projectId, + git.workspaceId, + git.repositoryStore, + pr + ); + this.unifiedChanges.start(); makeObservable(this, { activeFileOverride: observable, @@ -72,7 +81,9 @@ export class DiffViewStore implements Snapshottable { reaction( () => this.activeFile, (file) => { - if (!file || file.group === 'git' || file.group === 'pr') return; + if (!file || file.group === 'git' || file.group === 'pr' || file.group === 'unified') { + return; + } this.changesView.expandForActiveFileType(file.group); } ) @@ -89,8 +100,10 @@ export class DiffViewStore implements Snapshottable { const override = this.activeFileOverride; if (!override) return this._defaultActiveFile; - // git/pr groups cannot be validated against working-tree lists — trust the override - if (override.group === 'git' || override.group === 'pr') return override; + // git/pr/unified groups cannot be validated against working-tree lists — trust the override + if (override.group === 'git' || override.group === 'pr' || override.group === 'unified') { + return override; + } const isStaged = override.group === 'staged'; const ownList = isStaged ? this.git.stagedFileChanges : this.git.unstagedFileChanges; @@ -192,6 +205,7 @@ export class DiffViewStore implements Snapshottable { for (const dispose of this._disposeReactions) dispose(); this._disposeReactions = []; this.changesView.dispose(); + this.unifiedChanges.dispose(); } private get _defaultActiveFile(): ActiveFile | null { diff --git a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/git-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/git-store.ts index bcb459254..493866cc5 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/git-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/git-store.ts @@ -15,9 +15,9 @@ export class GitStore { readonly fullStatus: Resource; constructor( - private readonly projectId: string, - private readonly workspaceId: string, - private readonly repositoryStore: RepositoryStore + readonly projectId: string, + readonly workspaceId: string, + readonly repositoryStore: RepositoryStore ) { this.fullStatus = new Resource( () => this._fetchFullStatus(), diff --git a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/unified-changes-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/unified-changes-store.ts new file mode 100644 index 000000000..8d465cc6e --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/unified-changes-store.ts @@ -0,0 +1,189 @@ +import { computed, makeObservable, reaction } from 'mobx'; +import type { RepositoryStore } from '@renderer/features/projects/stores/repository-store'; +import type { PrStore } from '@renderer/features/tasks/stores/pr-store'; +import { events, rpc } from '@renderer/lib/ipc'; +import { Resource } from '@renderer/lib/stores/resource'; +import { fsWatchEventChannel } from '@shared/core/fs/fsEvents'; +import { + branchRef, + commitRef, + type GitChange, + type GitObjectRef, + refsEqual, + remoteRef, +} from '@shared/core/git/git'; +import { gitRefChangedChannel, gitWorkspaceChangedChannel } from '@shared/core/git/gitEvents'; + +/** + * Resource of "unified changes": one row per file from the merge-base of + * (PR base, or default branch) and HEAD all the way to the working tree. + * + * Backed by `git diff --name-status ` (no second ref → vs working + * tree). Refreshed on the same git/fs events as the normal status pipeline. + */ +export class UnifiedChangesStore { + readonly changes: Resource; + readonly mergeBase: Resource; + + private readonly _disposeReactions: Array<() => void> = []; + + constructor( + private readonly projectId: string, + private readonly workspaceId: string, + private readonly repositoryStore: RepositoryStore, + private readonly prStore: PrStore + ) { + makeObservable(this, { + baseRef: computed, + }); + + this.mergeBase = new Resource( + () => this._fetchMergeBase(), + this._statusEventStrategies('merge-base') + ); + + this.changes = new Resource( + () => this._fetchChanges(), + this._statusEventStrategies('unified-changes') + ); + + // When the base ref changes, refetch. + this._disposeReactions.push( + reaction( + () => { + const ref = this.baseRef; + return ref ? JSON.stringify(ref) : null; + }, + () => { + this.mergeBase.invalidate(); + this.changes.invalidate(); + } + ) + ); + } + + /** + * Resolved base ref: PR base if a PR exists for this task, else the project + * default branch. Null when neither is configured. + */ + get baseRef(): GitObjectRef | null { + const pr = this.prStore.currentPr; + if (pr) return remoteRef(this.repositoryStore.baseRemote, pr.baseRefName); + const def = this.repositoryStore.defaultBranch; + if (!def) return null; + return branchRef(def); + } + + start(): void { + this.mergeBase.start(); + this.changes.start(); + } + + dispose(): void { + for (const fn of this._disposeReactions) fn(); + this._disposeReactions.length = 0; + this.mergeBase.dispose(); + this.changes.dispose(); + } + + private async _fetchMergeBase(): Promise { + const base = this.baseRef; + if (!base) return null; + const result = await rpc.workspace.git.getMergeBase(this.projectId, this.workspaceId, base); + if (!result.success) return null; + return result.data.sha; + } + + private async _fetchChanges(): Promise { + 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; + } + + /** + * Same event strategies used by the normal status pipeline so unified + * changes refresh in lock step with split-view sections. + */ + private _statusEventStrategies(watchTag: string): Array<{ + kind: 'event'; + subscribe: (handler: () => void) => () => void; + onEvent: 'reload'; + debounceMs: number; + }> { + const projectId = this.projectId; + const workspaceId = this.workspaceId; + return [ + { + kind: 'event', + subscribe: (handler) => + events.on(gitWorkspaceChangedChannel, (payload) => { + if (payload.workspaceId === workspaceId && payload.kind === 'head') handler(); + }), + onEvent: 'reload', + debounceMs: 100, + }, + { + kind: 'event', + subscribe: (handler) => + events.on(gitWorkspaceChangedChannel, (payload) => { + if (payload.workspaceId === workspaceId && payload.kind === 'index') handler(); + }), + onEvent: 'reload', + debounceMs: 300, + }, + { + kind: 'event', + subscribe: (handler) => + events.on(gitRefChangedChannel, (payload) => { + if (payload.projectId !== projectId) return; + if (payload.workspaceId !== undefined && payload.workspaceId !== workspaceId) return; + // Reload on any local or remote ref change, since the base ref + // (or the merge-base computation) can move with either. + const baseRef = this.baseRef; + if (!baseRef || baseRef.kind !== 'branch') { + handler(); + return; + } + if (!payload.changedRefs || payload.changedRefs.some((r) => refsEqual(r, baseRef))) { + handler(); + } + }), + onEvent: 'reload', + debounceMs: 500, + }, + { + kind: 'event', + subscribe: (handler) => { + rpc.workspace.fs + .watchSetPaths(projectId, workspaceId, [''], `unified-changes-${watchTag}`) + .catch(() => {}); + const unsub = events.on(fsWatchEventChannel, (payload) => { + if (payload.workspaceId !== workspaceId) return; + const relevant = payload.events.some((e) => { + if (e.path.startsWith('.git')) return false; + if (e.oldPath?.startsWith('.git')) return false; + return true; + }); + if (relevant) handler(); + }); + return () => { + unsub(); + rpc.workspace.fs + .watchStop(projectId, workspaceId, `unified-changes-${watchTag}`) + .catch(() => {}); + }; + }, + onEvent: 'reload', + debounceMs: 500, + }, + ]; + } +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/tabs/diff-tab-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/tabs/diff-tab-store.ts index 3b2388ad4..122843571 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/tabs/diff-tab-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/tabs/diff-tab-store.ts @@ -15,7 +15,7 @@ export class DiffTabStore { path: string; isPreview: boolean; renderer: DiffRendererData; - diffGroup: 'disk' | 'staged' | 'git' | 'pr'; + diffGroup: 'disk' | 'staged' | 'git' | 'pr' | 'unified'; originalRef: GitObjectRef; modifiedRef: GitObjectRef | undefined; prNumber: number | undefined; diff --git a/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts index 2b700e6ea..108cacac9 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts @@ -117,7 +117,7 @@ export type ResolvedDiffTab = { kind: 'diff'; tabId: string; path: string; - diffGroup: 'disk' | 'staged' | 'git' | 'pr'; + diffGroup: 'disk' | 'staged' | 'git' | 'pr' | 'unified'; originalRef: GitObjectRef; modifiedRef?: GitObjectRef; prNumber?: number; diff --git a/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar/diff-tab-item.tsx b/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar/diff-tab-item.tsx index 0d30faadb..b228f4621 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar/diff-tab-item.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/view/tab-bar/diff-tab-item.tsx @@ -16,6 +16,8 @@ export function diffGroupSuffix(diffGroup: ResolvedDiffTab['diffGroup']): string return '(PR)'; case 'git': return '(Git)'; + case 'unified': + return '(All changes)'; } } diff --git a/apps/emdash-desktop/src/shared/core/app-settings.ts b/apps/emdash-desktop/src/shared/core/app-settings.ts index 178496139..4146e57c8 100644 --- a/apps/emdash-desktop/src/shared/core/app-settings.ts +++ b/apps/emdash-desktop/src/shared/core/app-settings.ts @@ -2,6 +2,7 @@ import type z from 'zod'; import { appSettingsSchema, type agentAutoApproveDefaultsSchema, + type changesPanelModeSchema, type changesViewModeSchema, type interfaceSettingsSchema, type localProjectSettingsSchema, @@ -27,6 +28,7 @@ export type ProviderCustomConfigs = Record; export type ChangesViewMode = z.infer; export type ChangesSection = keyof ChangesViewMode; export type ChangesListViewMode = ChangesViewMode[ChangesSection]; +export type ChangesPanelMode = z.infer; export type AppSettings = z.infer; export type AppSettingsKey = keyof AppSettings; diff --git a/apps/emdash-desktop/src/shared/view-state.ts b/apps/emdash-desktop/src/shared/view-state.ts index 6dbae69e2..f8b94df39 100644 --- a/apps/emdash-desktop/src/shared/view-state.ts +++ b/apps/emdash-desktop/src/shared/view-state.ts @@ -20,7 +20,7 @@ export type TabDescriptor = kind: 'diff'; tabId: string; path: string; - diffGroup: 'disk' | 'staged' | 'git' | 'pr'; + diffGroup: 'disk' | 'staged' | 'git' | 'pr' | 'unified'; originalRef: GitObjectRef; modifiedRef?: GitObjectRef; prNumber?: number; @@ -75,11 +75,12 @@ export interface ActiveFile { type: 'disk' | 'git'; /** Semantic context: which diff panel/group this file belongs to. * Determines which side is original/modified and which events make it stale. - * 'disk' = working tree vs HEAD - * 'staged' = index vs HEAD - * 'git' = arbitrary ref-to-ref comparison - * 'pr' = PR diff (originalRef is remote-tracking base) */ - group: 'disk' | 'staged' | 'git' | 'pr'; + * 'disk' = working tree vs HEAD + * 'staged' = index vs HEAD + * 'git' = arbitrary ref-to-ref comparison + * 'pr' = PR diff (originalRef is remote-tracking base) + * 'unified' = working tree vs merge-base of (PR base | default branch) and HEAD */ + group: 'disk' | 'staged' | 'git' | 'pr' | 'unified'; originalRef: GitObjectRef; /** Fixed modified-side ref for 'git' and 'pr' diffs. * When absent the diff viewer falls back to HEAD_REF. */ diff --git a/docs/superpowers/plans/2026-06-10-unified-changes-view.md b/docs/superpowers/plans/2026-06-10-unified-changes-view.md new file mode 100644 index 000000000..e1013583c --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-unified-changes-view.md @@ -0,0 +1,560 @@ +# Unified Changes View Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Unified view-mode to `ChangesPanel` that shows one row per file for the full delta between the PR base (or default branch) and the working tree. Read-only. + +**Architecture:** Add a new `unified` diff group, a `getUnifiedChangedFiles` RPC backed by `git diff --name-status` against `merge-base(, HEAD)`, a `unifiedChanges` Resource on `GitStore`, and a `viewMode: 'split' | 'unified'` toggle on `ChangesViewStore`. The diff renderer treats `unified` like `disk` but with `merge-base` as the original ref instead of `STAGED_REF`. + +**Tech Stack:** TypeScript, React, MobX, Electron, vitest, simple-git. + +**Spec:** `docs/superpowers/specs/2026-06-10-unified-changes-view-design.md` + +--- + +## File map + +**New:** +- `apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/unified-section.tsx` +- `apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/split-unified-toggle.tsx` + +**Modify:** +- `apps/emdash-desktop/src/main/core/git/impl/git-service.ts` — add `getUnifiedChangedFiles`, `getUnifiedDiffMergeBase`. +- `apps/emdash-desktop/src/main/core/git/workspace-git-provider.ts` — extend interface. +- `apps/emdash-desktop/src/main/core/git/controller.ts` — register RPC methods. +- `apps/emdash-desktop/src/shared/core/git/git.ts` — `UnifiedDiffError` type. +- `apps/emdash-desktop/src/main/core/settings/schema.ts` — add `unified` section to `changesViewModeSchema`, add `changesPanelMode` schema. +- `apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/git-store.ts` — `unifiedChanges` resource + `unifiedBaseRef` computed. +- `apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/changes-view-store.ts` — `panelMode` toggle. +- `apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/changes-panel.tsx` — branch on panelMode. +- `apps/emdash-desktop/src/renderer/features/tasks/tabs/diff-tab-store.ts` — add `'unified'` to `diffGroup`, store `mergeBaseRef`. +- `apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts` — add `'unified'` to type, add `openUnifiedDiff` opener. +- `apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-file-renderer.tsx` — handle `'unified'` (orig=mergeBase ref, mod=working tree buffer). +- `apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-toolbar.tsx` — hide stage/unstage when `diffGroup === 'unified'`. +- `apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/diff-tab-lifecycle-store.ts` — accept `'unified'` tabs. + +--- + +## Task 1: Backend — `getUnifiedChangedFiles` RPC + +**Files:** +- Modify: `apps/emdash-desktop/src/main/core/git/impl/git-service.ts` +- Modify: `apps/emdash-desktop/src/main/core/git/workspace-git-provider.ts` +- Modify: `apps/emdash-desktop/src/main/core/git/controller.ts` +- Modify: `apps/emdash-desktop/src/shared/core/git/git.ts` +- Test: `apps/emdash-desktop/src/main/core/git/impl/git-service.test.ts` (add tests) + +- [ ] **Step 1.1**: Add `UnifiedDiffError` to `git.ts`: + +```ts +export type UnifiedDiffError = + | { kind: 'no-merge-base'; baseRef: string } + | { kind: 'base-unresolvable' }; +``` + +- [ ] **Step 1.2**: Add to `WorkspaceGitProvider` interface: + +```ts +getUnifiedChangedFiles(base: GitObjectRef): Promise>; +getUnifiedMergeBase(base: GitObjectRef): Promise>; +``` + +- [ ] **Step 1.3**: Implement in `git-service.ts`: + +```ts +async getUnifiedChangedFiles(base: GitObjectRef): Promise> { + const baseStr = toRefString(base); + const mb = await this._mergeBase(baseStr).catch(() => null); + if (!mb) return err({ kind: 'no-merge-base', baseRef: baseStr }); + // git diff --name-status -M -C (no second ref → vs working tree) + const raw = await this.git.raw(['diff', '--name-status', '-M', '-C', mb]); + return ok(parseNameStatus(raw)); +} + +async getUnifiedMergeBase(base: GitObjectRef): Promise> { + const baseStr = toRefString(base); + const mb = await this._mergeBase(baseStr).catch(() => null); + if (!mb) return err({ kind: 'no-merge-base', baseRef: baseStr }); + return ok(mb); +} + +private async _mergeBase(baseStr: string): Promise { + const out = await this.git.raw(['merge-base', baseStr, 'HEAD']); + return out.trim(); +} +``` + +(Reuse the existing name-status parsing helper if present, otherwise add a small `parseNameStatus` next to the existing parsers — same style as `getChangedFiles`.) + +- [ ] **Step 1.4**: Register RPC in `controller.ts` (mirror existing `getChangedFiles` controller method): + +```ts +getUnifiedChangedFiles: async ( + projectId: string, + workspaceId: string, + base: GitObjectRef +): Promise> => { + const env = await this.acquire(projectId, workspaceId); + if (!env.ok) return env; + return env.value.git.getUnifiedChangedFiles(base); +}, +getUnifiedMergeBase: async ( + projectId: string, + workspaceId: string, + base: GitObjectRef +): Promise> => { + const env = await this.acquire(projectId, workspaceId); + if (!env.ok) return env; + return env.value.git.getUnifiedMergeBase(base); +}, +``` + +- [ ] **Step 1.5**: Add tests in `git-service.test.ts` covering: + - committed-unpushed only on the branch + - staged only + - unstaged only + - all three on same path + - rename via `-M` + - orphan branch returns `no-merge-base` + +Use the existing temp-repo helpers (look for `createTempGitRepo` or similar in `git-service.test.ts`). + +- [ ] **Step 1.6**: Run tests: + +```bash +pnpm vitest run apps/emdash-desktop/src/main/core/git/impl/git-service.test.ts +``` + +Expected: all new tests pass; existing tests unaffected. + +- [ ] **Step 1.7**: Commit: + +```bash +git add apps/emdash-desktop/src/main/core/git/ apps/emdash-desktop/src/shared/core/git/git.ts +git commit -m "feat(git): add getUnifiedChangedFiles RPC for unified diff base..working tree" +``` + +--- + +## Task 2: Settings schema — persist panel view mode + +**Files:** +- Modify: `apps/emdash-desktop/src/main/core/settings/schema.ts` + +- [ ] **Step 2.1**: Add a new schema entry next to `changesViewModeSchema`: + +```ts +export const changesPanelModeSchema = z.enum(['split', 'unified']); +``` + +- [ ] **Step 2.2**: Register it on the per-task settings (search for where the existing `changesViewMode` is registered in app settings — `appSettingsSchema` and the persistence layer): + +```ts +changesPanelMode: changesPanelModeSchema.default('split'), +``` + +- [ ] **Step 2.3**: Run typecheck: + +```bash +pnpm run typecheck +``` + +Expected: pass. + +- [ ] **Step 2.4**: Commit: + +```bash +git add apps/emdash-desktop/src/main/core/settings/schema.ts +git commit -m "feat(settings): add changesPanelMode persisted setting (split | unified)" +``` + +--- + +## Task 3: GitStore — `unifiedChanges` Resource and `unifiedBaseRef` + +**Files:** +- Modify: `apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/git-store.ts` +- Test: `apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/git-store.test.ts` (add tests) + +- [ ] **Step 3.1**: Add `unifiedBaseRef` and `unifiedMergeBase` computeds: + +```ts +get unifiedBaseRef(): GitObjectRef | null { + const prBase = this.prStore.currentPr; + if (prBase) return remoteRef(this.repositoryStore.baseRemote, prBase.baseRefName); + const def = this.repositoryStore.defaultBranch; + if (!def) return null; + return def.type === 'remote' + ? remoteRef(def.remote, def.branch) + : localRef(def.branch); +} +``` + +(Follow the same pattern `pr-store.ts` uses for `remoteRef(this.repositoryStore.baseRemote, pr.baseRefName)`.) + +- [ ] **Step 3.2**: Inject `prStore` into `GitStore` constructor (currently it doesn't receive it). Update the call sites that construct `GitStore` to pass it. If `prStore` and `gitStore` have a circular construction concern, alternatively add `unifiedBaseRef` as a method that takes `prStore` as a parameter and let the section component pass it in. Pick whichever is cleaner after grepping for `new GitStore(`. + +- [ ] **Step 3.3**: Add `unifiedChanges: Resource`: + +```ts +this.unifiedChanges = new Resource( + () => this._fetchUnifiedChanges(), + // Reuse the same event subscriptions as fullStatus (head, index, fs, refs). + this._buildStatusEventSpecs() +); +``` + +Plus `_fetchUnifiedChanges`: + +```ts +private async _fetchUnifiedChanges(): Promise { + const base = this.unifiedBaseRef; + if (!base) return []; + const result = await rpc.git.getUnifiedChangedFiles(this.projectId, this.workspaceId, base); + if (!result.ok) { + if (result.error.kind === 'no-merge-base') { + throw new Error(`No common history with ${result.error.baseRef}`); + } + throw new Error('Failed to load unified changes'); + } + return result.value; +} +``` + +Reload the resource when `unifiedBaseRef` changes (use a `mobx.reaction` in the constructor). + +- [ ] **Step 3.4**: Add tests: + +```ts +it('emits empty list when no base ref configured', async () => { ... }); +it('reloads when PR base changes', async () => { ... }); +it('surfaces no-merge-base errors', async () => { ... }); +``` + +- [ ] **Step 3.5**: Run tests: + +```bash +pnpm vitest run apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/git-store.test.ts +``` + +- [ ] **Step 3.6**: Commit: + +```bash +git add apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/git-store.ts apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/git-store.test.ts +git commit -m "feat(diff-view): add unifiedChanges resource to GitStore" +``` + +--- + +## Task 4: ChangesViewStore — panel mode + +**Files:** +- Modify: `apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/changes-view-store.ts` +- Test: `apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/changes-view-store.test.ts` (add tests) + +- [ ] **Step 4.1**: Extend `ChangesViewStore` with `panelMode`: + +```ts +panelMode: 'split' | 'unified' = 'split'; + +setPanelMode(mode: 'split' | 'unified'): void { + runInAction(() => { this.panelMode = mode; }); +} +``` + +Add to `makeObservable` definitions. + +- [ ] **Step 4.2**: Add a getter `unifiedFileChanges` that returns `gitStore.unifiedChanges.data ?? []` for convenience. + +- [ ] **Step 4.3**: Persist via the existing per-task settings hook (same mechanism as `useChangesViewMode`). The hook layer (`use-panel-mode.ts`) lives in `changes-panel/hooks/`: + +Create `apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/hooks/use-panel-mode.ts`: + +```ts +import { useAppSettingsKey } from '@renderer/features/settings/use-app-settings-key'; + +export function usePanelMode() { + const { value, update } = useAppSettingsKey('changesPanelMode'); + const mode = value ?? 'split'; + const setMode = (next: 'split' | 'unified') => update(next); + return { mode, setMode }; +} +``` + +(Verify the `useAppSettingsKey` API for non-object settings; if it requires object semantics, wrap.) + +- [ ] **Step 4.4**: Tests: + +```ts +it('toggles panelMode independently of selections', () => { ... }); +``` + +- [ ] **Step 4.5**: Commit: + +```bash +git add apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/changes-view-store.ts apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/changes-view-store.test.ts apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/hooks/use-panel-mode.ts +git commit -m "feat(diff-view): add panelMode (split | unified) to ChangesViewStore" +``` + +--- + +## Task 5: New `unified` diff group plumbing + +**Files:** +- Modify: `apps/emdash-desktop/src/renderer/features/tasks/tabs/diff-tab-store.ts` +- Modify: `apps/emdash-desktop/src/renderer/features/tasks/tabs/tab-manager-store.ts` +- Modify: `apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-file-renderer.tsx` +- Modify: `apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/diff-tab-lifecycle-store.ts` + +- [ ] **Step 5.1**: Add `'unified'` to the `diffGroup` union in `diff-tab-store.ts`: + +```ts +diffGroup: 'disk' | 'staged' | 'git' | 'pr' | 'unified'; +``` + +Persist `mergeBaseRef: GitObjectRef` for unified tabs (alongside `originalRef`). + +- [ ] **Step 5.2**: Same change in `tab-manager-store.ts:120` and `:388/:446` serialization. Add an `openUnifiedDiff(activeFile, status, mergeBaseRef)` opener mirroring `openDiff`/`openDiffPreview` for the staged group. + +- [ ] **Step 5.3**: In `diff-file-renderer.tsx`, extend the URI logic: + +```ts +const originalUri = (() => { + if (tab.diffGroup === 'disk') return modelRegistry.toGitUri(uri, STAGED_REF); + if (tab.diffGroup === 'unified') return modelRegistry.toGitUri(uri, tab.originalRef); + if (tab.diffGroup === 'git' || tab.diffGroup === 'pr') return modelRegistry.toGitUri(uri, tab.originalRef); + return modelRegistry.toGitUri(uri, HEAD_REF); +})(); + +const modifiedUri = (() => { + if (tab.diffGroup === 'staged') return modelRegistry.toGitUri(uri, STAGED_REF); + if (tab.diffGroup === 'pr') return modelRegistry.toGitUri(uri, tab.modifiedRef ?? HEAD_REF); + if (tab.diffGroup === 'git') return modelRegistry.toGitUri(uri, tab.modifiedRef ?? HEAD_REF); + // disk and unified: working-tree buffer + return uri; +})(); +``` + +In the `useEffect` model-registration block, add a branch for `unified` that registers (a) original = git ref `tab.originalRef`, (b) modified = `'buffer'` (mirroring the `disk` flow but without `STAGED_REF`). + +- [ ] **Step 5.4**: In `diff-tab-lifecycle-store.ts`, add `'unified'` to validation paths so unified tabs aren't pruned. Treat unified tabs as ephemeral (close when leaving unified mode is acceptable, or keep them — pick whichever matches existing behavior for `disk` tabs). + +- [ ] **Step 5.5**: Run typecheck and unit tests: + +```bash +pnpm run typecheck +pnpm vitest run apps/emdash-desktop/src/renderer/features/tasks +``` + +- [ ] **Step 5.6**: Commit: + +```bash +git add apps/emdash-desktop/src/renderer/features/tasks/tabs apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-file-renderer.tsx apps/emdash-desktop/src/renderer/features/tasks/diff-view/stores/diff-tab-lifecycle-store.ts +git commit -m "feat(diff-view): add 'unified' diff group plumbing (working tree vs merge-base)" +``` + +--- + +## Task 6: `UnifiedSection` UI + +**Files:** +- Create: `apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/unified-section.tsx` + +- [ ] **Step 6.1**: Implement `UnifiedSection` modeled on `staged-section.tsx`, omitting all stage/unstage/commit affordances: + +```tsx +export const UnifiedSection = observer(function UnifiedSection() { + const { projectId } = useTaskViewContext(); + const workspaceId = useWorkspaceId(); + const taskView = useWorkspaceViewModel(); + const workspace = useWorkspace(); + const git = workspace.git; + const diffView = taskView.diffView; + if (!diffView) return null; + + const { mode: viewMode, setMode: setViewMode } = useChangesViewMode('unified'); + const changes = git.unifiedChanges.data ?? []; + const hasChanges = changes.length > 0; + const isLoading = git.unifiedChanges.isLoading; + const error = git.unifiedChanges.error; + + const activePath = + taskView.tabManager.activeDescriptor?.kind === 'diff' && + taskView.tabManager.activeDescriptor.diffGroup === 'unified' + ? taskView.tabManager.activeDescriptor.path + : undefined; + + const handleSelect = async (change: GitChange) => { + const baseRef = git.unifiedBaseRef; + if (!baseRef) return; + const mb = await rpc.git.getUnifiedMergeBase(projectId, workspaceId, baseRef); + if (!mb.ok) return; + taskView.tabManager.openUnifiedDiff( + { path: change.path, type: 'unified', group: 'unified', originalRef: commitRef(mb.value) }, + change.status + ); + }; + + return ( + <> + } + /> + {error && } + {!error && !isLoading && !hasChanges && ( + + )} +
+ false} + onToggleSelect={() => {}} + activePath={activePath} + onSelectChange={(c) => void handleSelect(c)} + onDoubleClickChange={(c) => void handleSelect(c)} + /> +
+ + ); +}); +``` + +(Adjust prop names to match the actual `ChangesListOrTree` API. Some checkbox-related props may need to be made optional in the component or replaced with no-ops.) + +- [ ] **Step 6.2**: If `ChangesListOrTree` requires a checkbox column, add an optional `selectionDisabled` prop that hides selection UI; thread through to `VirtualizedChangesList` and `VirtualizedChangesTree`. + +- [ ] **Step 6.3**: Extend the `changesViewModeSchema` in `apps/emdash-desktop/src/main/core/settings/schema.ts` to include `unified: z.enum(['flat', 'tree'])`. + +- [ ] **Step 6.4**: Commit: + +```bash +git add apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/unified-section.tsx apps/emdash-desktop/src/main/core/settings/schema.ts +git commit -m "feat(diff-view): add UnifiedSection rendering all changes vs base" +``` + +--- + +## Task 7: Split/Unified toggle and `ChangesPanel` branching + +**Files:** +- Create: `apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/components/split-unified-toggle.tsx` +- Modify: `apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/changes-panel.tsx` + +- [ ] **Step 7.1**: Create the toggle component (mirrors `changes-view-mode-toggle.tsx`): + +```tsx +import { Layers, Rows3 } from 'lucide-react'; +import { Button } from '@renderer/lib/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/lib/ui/tooltip'; + +interface Props { + value: 'split' | 'unified'; + onChange: (mode: 'split' | 'unified') => void; +} + +export function SplitUnifiedToggle({ value, onChange }: Props) { + const next = value === 'split' ? 'unified' : 'split'; + const Icon = value === 'split' ? Layers : Rows3; + const tooltip = value === 'split' ? 'Switch to unified view' : 'Switch to split view'; + return ( + + + + + {tooltip} + + ); +} +``` + +- [ ] **Step 7.2**: Refactor `changes-panel.tsx` to branch on `panelMode`: + +```tsx +const { mode: panelMode, setMode: setPanelMode } = usePanelMode(); + +if (!diffView || !changesView || !workspace.git.hasData) return null; + +return ( +
+
+ +
+ {panelMode === 'split' ? ( + // existing ResizablePanelGroup + ) : ( + + )} + +
+); +``` + +- [ ] **Step 7.3**: Run dev to spot-check (manual): + +```bash +pnpm run d +``` + +Verify: +- Toggle visible at the top of the changes panel. +- Split mode unchanged. +- Unified mode shows one list, populated by changes vs base. +- Click a file in unified mode → opens a diff that respects unified semantics. + +- [ ] **Step 7.4**: Commit: + +```bash +git add apps/emdash-desktop/src/renderer/features/tasks/diff-view/changes-panel/ +git commit -m "feat(diff-view): wire split/unified panel toggle" +``` + +--- + +## Task 8: Toolbar — hide stage/unstage in unified mode + +**Files:** +- Modify: `apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-toolbar.tsx` + +- [ ] **Step 8.1**: At each stage/unstage/revert button render, gate on `tab.diffGroup !== 'unified'`. Keep navigation, copy-path, and view controls visible. + +- [ ] **Step 8.2**: Manual verification: open a file in unified mode → toolbar shows no stage/unstage actions. + +- [ ] **Step 8.3**: Commit: + +```bash +git add apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-toolbar.tsx +git commit -m "feat(diff-view): hide stage/unstage actions in unified diff toolbar" +``` + +--- + +## Task 9: Local merge gate + +- [ ] **Step 9.1**: Run the full local merge gate: + +```bash +pnpm run format +pnpm run lint +pnpm run typecheck +pnpm run test +``` + +Fix any failures inline, commit fixes as separate commits with descriptive messages. + +- [ ] **Step 9.2**: Final commit if any fixups happened. + +--- + +## Self-review checklist (run after writing all tasks) + +- [x] Each spec section maps to a task: backend RPC (Task 1), settings (Task 2), GitStore (Task 3), ChangesViewStore (Task 4), diff group (Task 5), UI (Tasks 6/7), toolbar (Task 8), gate (Task 9). +- [x] No "TBD" or "TODO". +- [x] Type names consistent: `panelMode`, `'split' | 'unified'`, `unifiedChanges`, `unifiedBaseRef` used throughout. +- [x] All file paths absolute under `apps/emdash-desktop/...`. +- [x] Backend tests in Task 1 cover all four scenarios from the spec's testing section. + diff --git a/docs/superpowers/specs/2026-06-10-unified-changes-view-design.md b/docs/superpowers/specs/2026-06-10-unified-changes-view-design.md new file mode 100644 index 000000000..ec090b8b4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-unified-changes-view-design.md @@ -0,0 +1,205 @@ +# Unified Changes View — Design + +Date: 2026-06-10 +Status: Draft + +## Problem + +The diff view's `ChangesPanel` exposes three collapsible sections: + +- **Unstaged** — working-tree modifications. +- **Staged** — index modifications. +- **Pull Request** — files in the active PR. + +Users reviewing what they're about to ship have to scan all three and mentally +merge them. There is no single place to answer "what is the total delta this +branch introduces, including local work that isn't pushed yet?" + +This is especially painful when local commits exist that aren't yet in the PR +(or no PR exists yet) and the user wants a single review surface. + +## Goals + +1. One-screen review of every change a branch introduces, regardless of + working-tree / index / committed-unpushed / pushed-PR state. +2. Read-only review surface — no stage/unstage/revert from this view. +3. Reuse the existing list / tree / virtualization / diff renderer. +4. Same data refresh pipeline as today's status — no new poll loops. + +## Non-Goals + +- Hunk- or file-level actions in the unified view (stage, unstage, revert). +- Detection or special handling of `.gitignore`-matched files. + Files matching `.gitignore` do not appear in normal `git status`, and we are + explicitly **not** adding `git status --ignored` support in this iteration. +- Layer attribution (no badges showing "this hunk came from the index" etc.). +- A user-configurable diff base. The base is derived automatically. + +## User-facing behavior + +- A new toggle in the `ChangesPanel` header switches between **Split** + (today's three-section layout) and **Unified**. +- In Unified mode the panel shows one full-height list (or tree, respecting + the existing list/tree toggle). One row per changed file. +- Diff base resolution: + - If the active task has a Pull Request: use the PR's base ref. + - Else: use the project's configured default branch. + - Else: empty state with a link to configure the default branch. +- Diff source per file: `merge-base(, HEAD) → working tree`. This means + a file with both committed-unpushed hunks and uncommitted hunks renders as + one continuous diff. +- The unified diff is read-only. The diff toolbar's stage / unstage / revert + controls are hidden when a unified-view file is selected. +- View-mode preference persists per task (same per-task settings store the + existing list/tree toggle uses). + +## Architecture + +### Main process + +`apps/emdash-desktop/src/main/core/git/` + +- `impl/git-service.ts` — add: + - `getUnifiedChangedFiles(base: GitObjectRef): Promise>` + - Resolves `mergeBase = git merge-base HEAD`. + - Runs `git diff --name-status -M -C ` (no second ref → diffs + against the working tree, including index and unstaged). + - Maps the output into `GitChange[]` reusing existing parsing helpers. + - Returns `err({ kind: 'no-merge-base', baseRef })` if `merge-base` fails. + - `getUnifiedFileDiff(filePath: string, base: GitObjectRef): Promise>` + - Same merge-base resolution, then `git diff -M -C -- `. +- `controller.ts` — register two new RPC methods: + - `git.getUnifiedChangedFiles(workspaceId, base)` + - `git.getUnifiedFileDiff(workspaceId, filePath, base)` +- `workspace-git-provider.ts` — add the two methods to the provider interface. + +### Shared types + +`apps/emdash-desktop/src/shared/core/git/git.ts` + +- Add `UnifiedDiffError` discriminated union: + ```ts + export type UnifiedDiffError = + | { kind: 'no-merge-base'; baseRef: string } + | { kind: 'base-unresolvable' } + | { kind: 'too-many-files' }; + ``` +- No new file-row type required — reuse `GitChange`. Layer attribution is not + surfaced in the UI per Non-Goals. + +### Renderer + +`apps/emdash-desktop/src/renderer/features/tasks/diff-view/` + +- `stores/git-store.ts` + - Add `unifiedChanges: Resource`, with the same event + subscriptions as `fullStatus` (head / index / fs-watch / local-refs). + Reload triggers reuse the existing debounce values. + - Add `unifiedBaseRef: GitObjectRef | null` computed from + `prStore.activePullRequest?.baseRef ?? repositoryStore.defaultBranchRef`. + - When `unifiedBaseRef` changes, `unifiedChanges` reloads. +- `stores/changes-view-store.ts` + - Add `viewMode: 'split' | 'unified'` (observable). + - Add `setViewMode(mode)`. Persisted via the existing per-task settings + mechanism used for the list/tree toggle. + - Mode change does not clear `unstagedSelection` / `stagedSelection` + (split-mode selection state is independent of unified view). +- `stores/diff-selectors.ts` — add a selector that returns the unified file + set merged with status iconography appropriate for the unified mode. +- `changes-panel/changes-panel.tsx` + - Branch on `changesView.viewMode`: + - `split`: existing `ResizablePanelGroup` (unchanged). + - `unified`: render `` filling the panel; keep + `` pinned at the bottom. +- `changes-panel/unified-section.tsx` — new. Renders a single + `` over `gitStore.unifiedChanges`. Handles loading, + error, and empty states. No checkbox column (read-only). +- `changes-panel/components/changes-view-mode-toggle.tsx` — already exists + for list/tree; add a sibling `view-mode-split-unified-toggle.tsx` placed in + the same header. Both togglers stay visible together; "tree vs list" + applies in either mode. +- `main-panel/diff-toolbar.tsx` + - Accept a `mode: 'normal' | 'unified'` prop. + - When `mode === 'unified'`, hide stage/unstage/revert and any commit-from-here + controls. Keep navigation, copy-path, and view-mode controls. +- `main-panel/diff-view.tsx` + - Accept a unified file selection (`{ kind: 'unified', path }`). + - When unified, fetch via `rpc.git.getUnifiedFileDiff(workspaceId, path, baseRef)`. + - Pass `mode='unified'` to the toolbar. + +### Selection plumbing + +The diff view's selection state already supports a tagged kind (disk / staged / +git / pr — see `expandForActiveFileType`). Add `unified` as a new tag and +route it to the unified data path. + +## Data flow + +1. User clicks the new Split/Unified toggle. +2. `changesViewStore.viewMode` flips. `ChangesPanel` re-renders. +3. `UnifiedSection` reads `gitStore.unifiedChanges`. +4. `unifiedChanges.Resource` is already subscribed to git/fs events; on first + read it triggers a load via `rpc.git.getUnifiedChangedFiles(workspaceId, baseRef)`. +5. User clicks a row → `diffView.selectFile({ kind: 'unified', path })`. +6. Main panel calls `rpc.git.getUnifiedFileDiff(workspaceId, path, baseRef)` + and renders the result with `mode='unified'`. +7. Any subsequent index, head, or fs change triggers a refresh through the + existing event pipeline. + +## Error handling + +| Scenario | Source | UI | +|---|---|---| +| No PR and no default branch configured | renderer (computed `unifiedBaseRef === null`) | Empty state: "Configure a default branch to use this view." Link to project settings. | +| `merge-base` fails (orphan branch / no shared history) | main: `err({ kind: 'no-merge-base', baseRef })` | Inline error: "No common history with ``." | +| Too many files (reuse existing cap) | main: `err({ kind: 'too-many-files' })` | Existing `TOO_MANY_FILES_MSG` UI. | +| Single-file diff fetch fails | reuse `main-panel/missing-file-error.ts` | Existing missing-file UI. | +| Toggle race during refresh | n/a | Skeleton loader (same as split sections today). | + +`Result` is used for all new main-process returns, per `agents/conventions/main-patterns.md`. + +## Testing + +### Main process (vitest `node` project) + +- `git-service.test.ts` — `getUnifiedChangedFiles`: + - synthetic repo with (a) committed-unpushed only, (b) staged only, + (c) unstaged only, (d) all three on the same file → expect one row per + file with combined status. + - rename and copy detection across the merge-base boundary. + - orphan branch returns `no-merge-base`. +- `controller.test.ts` — RPC wiring; base-ref resolution. + +### Renderer (vitest `node` project) + +- `changes-view-store.test.ts` — `viewMode` toggle, persistence, + no cross-contamination of `unstagedSelection` / `stagedSelection`. +- `git-store.test.ts` — `unifiedChanges` derived correctly; reloads when + PR base or default branch changes. + +### Renderer browser (vitest `browser` project) + +- `unified-section.test.tsx` — list rendering, list↔tree toggle, file counts, + empty state, error state, click-to-select wiring. +- `changes-panel.test.tsx` — split↔unified toggle keeps split-mode selection + state intact across mode flips. + +### Manual verification + +- Open a task with an open PR. +- Modify file `A` in a committed-unpushed commit AND with extra uncommitted + edits. +- Flip to Unified view → expect one row for `A`, status `M`, diff shows the + full delta. +- Switch back to Split → confirm Unstaged and Staged selections preserved. + +## Risks + +1. **`merge-base` cost on large monorepos** — single call per refresh; should + be a no-op compared to the existing status pipeline. +2. **Toggle UI clutter** — two toggles (list/tree, split/unified) live in the + same header. Mitigated by placing them as a single togglegroup row. +3. **Read-only constraint surfacing** — users may try to right-click and stage + from the unified view. Mitigation: make the read-only intent obvious by + hiding rather than disabling the toolbar actions, and document in the + toggle tooltip ("Read-only review of the full branch delta").