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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user