feat(diff): add branch scope to context panel diff view
Show every change on the current branch relative to its base in the Changed/Staged/Last turn dropdown. The base comes from the branch's reflog record or an explicit per-branch user choice (persisted), never a main/master guess; when git has no record the user picks a base once from a searchable branch list. - server: GET /api/git/branch-base (reflog-derived base), GET /api/git/range-files (name-status -z with rename/copy destination paths and -C copy detection) - shared UI: optional getBranchBase/getGitRangeFiles runtime APIs with boundary parsing; persisted per-branch overrides keyed by runtime+directory+branch - DiffView: branch scope with confirmed-unavailability coercion of persisted tabs (detached HEAD, default-branch checkout, metadata settled without a default), range-invalidated diff cache guarded against stale completions, bounded branch-metadata retry, read-only diff actions in branch scope; hidden in VS Code - helper module branchDiffScope.ts with tests for coercion, availability, race conditions, and retry exhaustion
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
let runtimeKey = "runtime-a"
|
||||
mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => runtimeKey }))
|
||||
|
||||
const { gitBaseBranchEntryKey, useGitBaseBranchStore } = await import("./useGitBaseBranchStore")
|
||||
|
||||
describe("git base branch overrides", () => {
|
||||
beforeEach(() => {
|
||||
runtimeKey = "runtime-a"
|
||||
useGitBaseBranchStore.setState({ overrides: {} })
|
||||
})
|
||||
|
||||
test("keys the same repository per branch and runtime", () => {
|
||||
const featureA = gitBaseBranchEntryKey("/repo", "feature-a")
|
||||
const featureB = gitBaseBranchEntryKey("/repo", "feature-b")
|
||||
runtimeKey = "runtime-b"
|
||||
const featureARemote = gitBaseBranchEntryKey("/repo", "feature-a")
|
||||
|
||||
expect(new Set([featureA, featureB, featureARemote]).size).toBe(3)
|
||||
})
|
||||
|
||||
test("a base picked for one branch does not apply to another branch", () => {
|
||||
const store = useGitBaseBranchStore.getState()
|
||||
store.setOverride("/repo", "feature-a", "main")
|
||||
|
||||
expect(store.getOverride("/repo", "feature-a")).toBe("main")
|
||||
// feature-b must fall back to its own detection, not feature-a's choice.
|
||||
expect(store.getOverride("/repo", "feature-b")).toBeNull()
|
||||
})
|
||||
|
||||
test("different branches of one repository keep independent bases", () => {
|
||||
const store = useGitBaseBranchStore.getState()
|
||||
store.setOverride("/repo", "feature-a", "main")
|
||||
store.setOverride("/repo", "feature-b", "develop")
|
||||
|
||||
expect(store.getOverride("/repo", "feature-a")).toBe("main")
|
||||
expect(store.getOverride("/repo", "feature-b")).toBe("develop")
|
||||
})
|
||||
|
||||
test("clearOverride removes only the targeted branch's choice", () => {
|
||||
const store = useGitBaseBranchStore.getState()
|
||||
store.setOverride("/repo", "feature-a", "main")
|
||||
store.setOverride("/repo", "feature-b", "develop")
|
||||
store.clearOverride("/repo", "feature-a")
|
||||
|
||||
expect(store.getOverride("/repo", "feature-a")).toBeNull()
|
||||
expect(store.getOverride("/repo", "feature-b")).toBe("develop")
|
||||
})
|
||||
|
||||
test("rejects empty directory, branch, or base", () => {
|
||||
const store = useGitBaseBranchStore.getState()
|
||||
store.setOverride("", "feature-a", "main")
|
||||
store.setOverride("/repo", "", "main")
|
||||
store.setOverride("/repo", "feature-a", "")
|
||||
store.clearOverride("", "feature-a")
|
||||
|
||||
expect(useGitBaseBranchStore.getState().overrides).toEqual({})
|
||||
expect(store.getOverride("", "feature-a")).toBeNull()
|
||||
expect(store.getOverride("/repo", "")).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
||||
|
||||
const GIT_BASE_BRANCH_STORAGE_KEY = 'openchamber.git-base-branch';
|
||||
const MAX_BASE_BRANCH_ENTRIES = 100;
|
||||
|
||||
/**
|
||||
* Build the persisted override key for one branch of one repository.
|
||||
*
|
||||
* The branch is part of the identity on purpose: a base picked for one feature
|
||||
* branch is not an answer for a different branch of the same repository, and a
|
||||
* directory-only key would silently shadow reflog detection after checkout.
|
||||
* Keys include the runtime identity so a remote runtime's paths never shadow
|
||||
* local ones.
|
||||
*/
|
||||
export const gitBaseBranchEntryKey = (directory: string, branch: string): string =>
|
||||
JSON.stringify([getRuntimeKey(), directory, branch]);
|
||||
|
||||
type GitBaseBranchState = {
|
||||
overrides: Record<string, string>;
|
||||
getOverride: (directory: string, branch: string) => string | null;
|
||||
setOverride: (directory: string, branch: string, base: string) => void;
|
||||
clearOverride: (directory: string, branch: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Explicit per-branch base choices for the "Branch" diff scope.
|
||||
*
|
||||
* Git does not record a parent branch for every branch (clones, detached
|
||||
* starts). When no authoritative source exists, the user picks a base once and
|
||||
* the choice is remembered for that branch.
|
||||
*/
|
||||
export const useGitBaseBranchStore = create<GitBaseBranchState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
overrides: {},
|
||||
getOverride: (directory, branch) => {
|
||||
if (!directory || !branch) return null;
|
||||
return get().overrides[gitBaseBranchEntryKey(directory, branch)] ?? null;
|
||||
},
|
||||
setOverride: (directory, branch, base) => {
|
||||
if (!directory || !branch || !base) return;
|
||||
set((state) => {
|
||||
const key = gitBaseBranchEntryKey(directory, branch);
|
||||
const entries = Object.entries({ ...state.overrides, [key]: base });
|
||||
while (entries.length > MAX_BASE_BRANCH_ENTRIES) {
|
||||
entries.shift();
|
||||
}
|
||||
return { overrides: Object.fromEntries(entries) };
|
||||
});
|
||||
},
|
||||
clearOverride: (directory, branch) => {
|
||||
if (!directory || !branch) return;
|
||||
set((state) => {
|
||||
const key = gitBaseBranchEntryKey(directory, branch);
|
||||
if (!(key in state.overrides)) return state;
|
||||
const next = { ...state.overrides };
|
||||
delete next[key];
|
||||
return { overrides: next };
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: GIT_BASE_BRANCH_STORAGE_KEY,
|
||||
storage: createDeferredSafeJSONStorage(),
|
||||
partialize: (state) => ({ overrides: state.overrides }),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -20,7 +20,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
export type WorkspaceSurface = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram';
|
||||
/** @deprecated Use WorkspaceSurface. */
|
||||
export type MainTab = WorkspaceSurface;
|
||||
export type PendingDiffScope = 'working' | 'staged' | 'turn';
|
||||
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch';
|
||||
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal';
|
||||
export type MermaidRenderingMode = 'svg' | 'ascii';
|
||||
export type UserMessageRenderingMode = 'markdown' | 'plain';
|
||||
@@ -205,7 +205,7 @@ const normalizeContextTabLabel = (value: string | null | undefined): string | nu
|
||||
};
|
||||
|
||||
const normalizePendingDiffScope = (value: unknown): PendingDiffScope | null => {
|
||||
return value === 'working' || value === 'staged' || value === 'turn' ? value : null;
|
||||
return value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' ? value : null;
|
||||
};
|
||||
|
||||
const buildDefaultContextPanelTabDedupeKey = (mode: ContextPanelMode, targetPath: string | null): string => {
|
||||
|
||||
Reference in New Issue
Block a user