Git commit history (#1534)

* feat(app): add fileView preference for changes panel

* feat(app): add buildDiffTree util for changed-files tree

* feat(app): add changed-files tree directory row

* feat(app): changed-files tree view mode in changes panel (#117)

* feat(protocol): add checkout.commits.list RPC + commitsList feature flag

* feat(server): list branch commits ahead of base with on-remote flags

* feat(server): handle checkout.commits.list RPC and advertise capability

* feat(client): checkout.commits.list method + useCommitsQuery hook

* feat(app): per-commit inline view with local-vs-remote markers (#117)

* feat(protocol,server): per-commit file diff RPC (checkout.commits.file_diff)

* feat(app): open per-commit file diff on click (#117)

* refactor: surface baseRef + commits loading/error, drop dead depth field

* fix(app): flip commit local/remote dot — local hollow, remote filled

* feat(app): list/tree view for expanded commit file list (#117)

* fix(app): syntax-highlight per-commit file diff to match Changes view

* feat(app): draggable resize between commits and diff sections

* refactor(app): dedupe wrap-text helpers into diff-highlighted-text

* fix(app): clean up commits resize drag on unmount + a11y label

* fix(app): collapse commits section by default

* fix(app): render per-commit file diff with the shared Changes line renderer

* perf(app): memoize shared diff line row; drop redundant DiffLineView wrapper

* refactor(app): extract shared DiffFileBody; render commit file diff through it

* fix(app): hide inline-comment affordance in per-commit diffs (no reviewActions)

* feat(app): move commits to a resizable bottom drawer in the Changes panel

* feat(review): commitSha scoping for per-commit review drafts + attachment

* feat(app): per-commit inline comments wired through to the composer (#117)

* refactor(app): own commit file-diff open state in CommitFileList (drop reset effect)

* refactor(app): colocate diff-render cluster under git/diff-file-body/

* feat(app): add diff tab target kind (working/commit diff tabs)

* feat(app): useDiffFiles hook unifying working + commit diff targets

* feat(app): diff tab panel rendering working/commit diffs

* feat(app): open a commit diff tab on commit click; drop per-commit drawer/file list

* feat(app): open/scroll working diff tab on changed-file click

* fix(app): keep commit diff tabs ephemeral; align working diff whitespace

* feat(app): collapsible file sections in the diff tab, collapsed by default

* feat(app): make the on-remote commit dot more subtle

Dims the filled green remote dot (row + legend) to ~0.55 opacity so the
local-only ring stays the state that draws the eye.

* Reshape commit diffs around the existing Changes view

* Fix diff tab migration complexity after rebase

* Address diff tab review findings

* Clean up diff tab review interfaces

* Collapse commit diffs into the existing view

* Load commits when expanded

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
adradr
2026-07-16 17:36:41 +02:00
committed by GitHub
co-authored by Mohamed Boudra
parent 5da6548aff
commit 0d3b717cf3
44 changed files with 2843 additions and 439 deletions
@@ -971,6 +971,52 @@ export function collectAllPanes(root: SplitNode): SplitPane[] {
return internalRoot.group.children.flatMap((child) => collectAllPanes(child));
}
function isEphemeralTab(tab: WorkspaceTab): boolean {
// Commit diff tabs are ephemeral: their SHA may be rebased away before the next
// load, so a restored tab could point at a dead commit.
return tab.target.kind === "commit_diff";
}
function stripEphemeralTabsFromNode(node: SplitNodeInternal): SplitNodeInternal {
if (node.kind === "pane") {
const nextTabs = node.pane.tabs.filter((tab) => !isEphemeralTab(tab));
if (nextTabs.length === node.pane.tabs.length) {
return node;
}
// createPaneNode repoints focusedTabId to a surviving tab (or null) when the
// previously focused tab was an ephemeral one that we just removed.
return createPaneNode({
id: node.pane.id,
tabs: nextTabs,
focusedTabId: node.pane.focusedTabId,
});
}
return createGroupNode({
id: node.group.id,
direction: node.group.direction,
children: node.group.children.map((child) => stripEphemeralTabsFromNode(child)),
sizes: node.group.sizes,
});
}
/**
* Returns a copy of `layout` with every ephemeral tab (commit diff tabs) removed
* from each pane. Applied in the layout store's `partialize` so commit diff tabs
* are never written to storage — they're dropped on the next reload rather than
* restored pointing at a possibly-rebased SHA. Working diff tabs and all other
* tab kinds are left intact. Panes (and their ids/structure) are preserved even
* when emptied; the parent-tab map is renormalized against the surviving tabs.
*/
export function stripEphemeralTabsFromLayout(layout: WorkspaceLayout): WorkspaceLayout {
const internalLayout = asInternalLayout(layout);
const nextRoot = stripEphemeralTabsFromNode(internalLayout.root);
return withNormalizedParentTabMap({
root: nextRoot,
focusedPaneId: internalLayout.focusedPaneId,
parentTabIdByTabId: layout.parentTabIdByTabId,
});
}
export function getFocusedBrowserId(layout: WorkspaceLayout | null | undefined): string | null {
if (!layout) {
return null;
@@ -37,6 +37,7 @@ import {
retargetTabInLayout,
splitPaneEmptyInLayout,
splitPaneInLayout,
stripEphemeralTabsFromLayout,
type SplitGroup,
type SplitNode,
type SplitPane,
@@ -59,6 +60,7 @@ export {
normalizeLayout,
removePaneFromTree,
removeTabFromTree,
stripEphemeralTabsFromLayout,
};
export type {
SplitGroup,
@@ -902,7 +904,11 @@ export function createWorkspaceLayoutStore(
partialize: (state) => {
const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
for (const key in state.layoutByWorkspace) {
layoutByWorkspace[key] = normalizeLayout(state.layoutByWorkspace[key]);
// Strip ephemeral (commit diff) tabs before persisting so they are
// dropped on reload rather than restored pointing at a rebased SHA.
layoutByWorkspace[key] = stripEphemeralTabsFromLayout(
normalizeLayout(state.layoutByWorkspace[key]),
);
}
return {
layoutByWorkspace,
@@ -8,6 +8,7 @@ import {
applyRetargetTab,
buildWorkspaceTabPersistenceKey,
initialWorkspaceTabsCoreState,
migrateWorkspaceTabsState,
type WorkspaceTabsCoreState,
} from "./state";
@@ -334,6 +335,20 @@ describe("workspace-tabs-store reducers", () => {
expect(result.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(result.tabId);
});
it("opens a commit diff tab with a commit-specific id", () => {
let state = emptyState();
const commit = applyOpenOrFocusTab(state, {
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "commit_diff", sha: "abc123" },
now: NOW,
});
state = commit.state;
expect(commit.tabId).toBe("commit_diff_abc123");
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1);
});
it("closeTab focuses the most-recent remaining tab when the focused tab is removed", () => {
let state = emptyState();
const first = applyOpenOrFocusTab(state, {
@@ -362,3 +377,56 @@ describe("workspace-tabs-store reducers", () => {
expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1);
});
});
describe("migrateWorkspaceTabsState commit diff coercion", () => {
// This legacy store no longer enforces the "commit diff tabs are ephemeral"
// guarantee — that now lives in the workspace-layout store's partialize (see
// stripEphemeralTabsFromLayout). This migration only needs to carry old commit
// diff targets forward to the dedicated `commit_diff` tab shape.
it("migrates a legacy commit diff tab to the dedicated target shape", () => {
const persisted = {
state: {
uiTabsByWorkspace: {
[WORKSPACE_KEY]: [
{
tabId: "commit_diff_abc123",
target: { kind: "diff", diffTarget: { kind: "commit", sha: "abc123" } },
createdAt: NOW,
},
],
},
},
};
const migrated = migrateWorkspaceTabsState(persisted, { now: NOW });
const tabs = migrated.uiTabsByWorkspace[WORKSPACE_KEY] ?? [];
expect(tabs).toHaveLength(1);
expect(tabs[0]?.target).toEqual({ kind: "commit_diff", sha: "abc123" });
expect(migrated.tabOrderByWorkspace[WORKSPACE_KEY]).toEqual(["commit_diff_abc123"]);
});
it("drops a legacy working diff tab during migration", () => {
const persisted = {
state: {
uiTabsByWorkspace: {
[WORKSPACE_KEY]: [
{
tabId: "diff_working:base:main",
target: {
kind: "diff",
diffTarget: { kind: "working", mode: "base", baseRef: "main" },
},
createdAt: NOW,
},
],
},
},
};
const migrated = migrateWorkspaceTabsState(persisted, { now: NOW });
expect(migrated.uiTabsByWorkspace[WORKSPACE_KEY]).toBeUndefined();
expect(migrated.tabOrderByWorkspace[WORKSPACE_KEY]).toBeUndefined();
});
});
@@ -23,7 +23,8 @@ export type WorkspaceTabTarget =
| { kind: "terminal"; terminalId: string }
| { kind: "browser"; browserId: string }
| WorkspaceFileTabTarget
| { kind: "setup"; workspaceId: string };
| { kind: "setup"; workspaceId: string }
| { kind: "commit_diff"; sha: string };
export interface WorkspaceTab {
tabId: string;
@@ -537,7 +538,35 @@ function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTar
if (kind === "setup" && typeof raw.workspaceId === "string") {
return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId });
}
return null;
return coercePersistedDiffTabTargetByKind(kind, raw);
}
function coercePersistedDiffTabTargetByKind(
kind: string | null,
raw: Record<string, unknown>,
): WorkspaceTabTarget | null {
if (kind === "commit_diff" && typeof raw.sha === "string") {
return normalizeWorkspaceTabTarget({ kind: "commit_diff", sha: raw.sha });
}
return kind === "diff" ? coercePersistedDiffTabTarget(raw) : null;
}
// NOTE: This legacy store no longer persists diff tabs — live tab persistence is
// owned by the workspace-layout store, which is where the "commit diff tabs are
// ephemeral on reload" guarantee is enforced (see stripEphemeralTabsFromLayout).
// This coercion exists only so this store's migration stays type-complete for any
// old diff-shaped entries left in `workspace-tabs-state` blobs. Working-tree diff
// tabs are dropped because they no longer exist as workspace targets; commit diff
// tabs migrate to the dedicated `commit_diff` target shape.
function coercePersistedDiffTabTarget(raw: Record<string, unknown>): WorkspaceTabTarget | null {
const diffTarget = toObjectRecord(raw.diffTarget);
if (!diffTarget || diffTarget.kind !== "commit" || typeof diffTarget.sha !== "string") {
return null;
}
return normalizeWorkspaceTabTarget({
kind: "commit_diff",
sha: diffTarget.sha,
});
}
function migrateSingleTab(rawTab: unknown, now: number): WorkspaceTab | null {