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
+7
View File
@@ -1,6 +1,7 @@
import {
keepPreviousData,
skipToken,
useQueries,
useQuery,
type QueryKey,
type UseQueryOptions,
@@ -50,6 +51,12 @@ export function useFetchQuery<
return useQuery(fetchQueryOptions(input));
}
export function useFetchQueries<TData>(
inputs: FetchQueryInput<TData, Error, TData, QueryKey>[],
): UseQueryResult<TData, Error>[] {
return useQueries({ queries: inputs.map((input) => fetchQueryOptions(input)) });
}
function replicaQueryOptions<
TQueryFnData,
TError = Error,
@@ -0,0 +1,68 @@
import { memo, useCallback } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
import { dotStyles } from "./shared";
interface CommitRowProps {
commit: CheckoutCommit;
onCommitPress: (sha: string) => void;
}
export const CommitRow = memo(function CommitRow({ commit, onCommitPress }: CommitRowProps) {
const handlePress = useCallback(() => {
onCommitPress(commit.sha);
}, [commit.sha, onCommitPress]);
return (
<Pressable
accessibilityRole="button"
testID={`commit-row-${commit.shortSha}`}
onPress={handlePress}
style={styles.row}
>
<View
testID={commit.isOnRemote ? "commit-dot-remote" : "commit-dot-local"}
style={commit.isOnRemote ? dotStyles.dotRemote : dotStyles.dotLocal}
/>
<Text style={styles.shortSha}>{commit.shortSha}</Text>
<Text style={styles.subject} numberOfLines={1}>
{commit.subject}
</Text>
<View style={styles.caret}>
<ThemedChevron size={14} uniProps={chevronColorMapping} />
</View>
</Pressable>
);
});
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingLeft: theme.spacing[2],
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
shortSha: {
fontSize: theme.fontSize.xs,
fontFamily: theme.fontFamily.mono,
color: theme.colors.foregroundMuted,
flexShrink: 0,
},
subject: {
flex: 1,
minWidth: 0,
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
caret: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
}));
@@ -0,0 +1,226 @@
import { useCallback, useMemo } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
import { CommitRow } from "./commit-row";
import { dotStyles } from "./shared";
interface CommitsSectionProps {
serverId: string;
cwd: string;
onCommitPress: (sha: string) => void;
}
const SKELETON_ROW_KEYS = ["commit-skeleton-1", "commit-skeleton-2", "commit-skeleton-3"];
function CommitsSectionSkeleton() {
const { t } = useTranslation();
return (
<View
accessible
accessibilityLabel={t("workspace.git.diff.commits.loading")}
style={styles.skeleton}
testID="commits-section-skeleton"
>
{SKELETON_ROW_KEYS.map((key) => (
<View key={key} style={styles.skeletonRow}>
<View style={styles.skeletonDot} />
<View style={styles.skeletonSha} />
<View style={styles.skeletonSubject} />
</View>
))}
</View>
);
}
function CommitsSectionContent({
query,
onCommitPress,
}: {
query: Exclude<CheckoutCommitsQueryResult, { status: "unsupported" }>;
onCommitPress: (sha: string) => void;
}) {
const { t } = useTranslation();
if (query.status === "error") {
return (
<Text style={styles.errorRow} testID="commits-section-error">
{t("workspace.git.diff.commits.loadError")}
</Text>
);
}
if (query.status !== "loaded") {
return <CommitsSectionSkeleton />;
}
if (query.data.commits.length === 0) {
return (
<Text style={styles.emptyRow} testID="commits-section-empty">
{t("workspace.git.diff.commits.empty")}
</Text>
);
}
return (
<View style={styles.list}>
{query.data.commits.map((commit) => (
<CommitRow key={commit.sha} commit={commit} onCommitPress={onCommitPress} />
))}
</View>
);
}
export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionProps) {
const { t } = useTranslation();
const { preferences, updatePreferences } = useChangesPreferences();
const collapsed = preferences.commitsCollapsed;
const query = useCheckoutCommitsQuery({
serverId,
cwd,
enabled: !collapsed,
});
const handleToggleSection = useCallback(() => {
void updatePreferences({ commitsCollapsed: !collapsed });
}, [collapsed, updatePreferences]);
const headerChevronStyle = useMemo(
() => [styles.headerChevron, !collapsed && styles.headerChevronExpanded],
[collapsed],
);
if (query.status === "unsupported") {
return null;
}
const commitCount = query.status === "loaded" ? query.data.commits.length : null;
return (
<View style={styles.container}>
<Pressable
accessibilityRole="button"
testID="commits-section-header"
onPress={handleToggleSection}
style={styles.header}
>
<View style={headerChevronStyle}>
<ThemedChevron size={14} uniProps={chevronColorMapping} />
</View>
<Text style={styles.title}>{t("workspace.git.diff.commits.title")}</Text>
{commitCount === null ? (
<View style={styles.countSpacer} />
) : (
<Text
style={styles.count}
accessibilityLabel={t("workspace.git.diff.commits.countLabel", {
count: commitCount,
})}
>
{commitCount}
</Text>
)}
<View style={styles.legend}>
<View style={dotStyles.dotLocal} />
<Text style={styles.legendText}>{t("workspace.git.diff.commits.legendLocal")}</Text>
<View style={dotStyles.legendDotRemote} />
<Text style={styles.legendText}>{t("workspace.git.diff.commits.legendRemote")}</Text>
</View>
</Pressable>
{collapsed ? null : <CommitsSectionContent query={query} onCommitPress={onCommitPress} />}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
},
header: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingLeft: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingVertical: theme.spacing[2],
flexShrink: 0,
},
headerChevron: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
headerChevronExpanded: {
transform: [{ rotate: "90deg" }],
},
title: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
count: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
flex: 1,
},
countSpacer: {
flex: 1,
},
legend: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
legendText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
list: {
paddingBottom: theme.spacing[1],
},
emptyRow: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
paddingLeft: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
errorRow: {
fontSize: theme.fontSize.xs,
color: theme.colors.statusDanger,
paddingLeft: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
skeleton: {
paddingHorizontal: theme.spacing[2],
paddingBottom: theme.spacing[2],
gap: theme.spacing[2],
},
skeletonRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 20,
},
skeletonDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
},
skeletonSha: {
width: 48,
height: 10,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
skeletonSubject: {
width: "55%",
height: 12,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
}));
@@ -0,0 +1,51 @@
import { StyleSheet } from "react-native-unistyles";
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
export type CheckoutCommitFile = CheckoutCommit["files"][number];
export type FilePressHandler = (commit: CheckoutCommit, file: CheckoutCommitFile) => void;
export const DOT_SIZE = 8;
// The "on remote" dot is intentionally understated: the local-only ring is the
// state worth noticing (you still have work to push), so the remote fill is
// dimmed toward the background rather than rendered at full-strength green.
const REMOTE_DOT_OPACITY = 0.55;
/**
* Shared local/remote commit dot styles used by both the commit rows and the
* section legend so the two never drift.
*
* Semantics: a local-only commit is a hollow ring; a commit that has reached
* the remote is a subtle (dimmed) filled green dot.
*/
export const dotStyles = StyleSheet.create((theme) => ({
dotLocal: {
width: DOT_SIZE,
height: DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: "transparent",
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.foregroundMuted,
flexShrink: 0,
},
dotRemote: {
width: DOT_SIZE,
height: DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.statusSuccess,
opacity: REMOTE_DOT_OPACITY,
flexShrink: 0,
},
// Legend variant of the remote dot: same fill, with leading spacing so it
// separates from the preceding "local" label in the section header legend.
legendDotRemote: {
width: DOT_SIZE,
height: DOT_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.statusSuccess,
opacity: REMOTE_DOT_OPACITY,
flexShrink: 0,
marginLeft: theme.spacing[1],
},
}));
File diff suppressed because it is too large Load Diff
+17
View File
@@ -13,6 +13,10 @@ interface CheckoutQueryScope {
type CheckoutQueryKey = readonly unknown[];
// A commit's file diff is immutable for a given sha+path, so every consumer
// can share the same long-lived cache policy.
export const COMMIT_FILE_DIFF_STALE_TIME = 5 * 60_000;
export function checkoutStatusQueryKey(serverId: string, cwd: string) {
return ["checkoutStatus", serverId, cwd] as const;
}
@@ -31,6 +35,19 @@ export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
return ["checkoutPrStatus", serverId, cwd] as const;
}
export function checkoutCommitsQueryKey(serverId: string, cwd: string) {
return ["checkoutCommits", serverId, cwd] as const;
}
export function checkoutCommitFileDiffQueryKey(
serverId: string,
cwd: string,
sha: string,
path: string,
) {
return ["checkoutCommitFileDiff", serverId, cwd, sha, path] as const;
}
export async function invalidateCheckoutGitQueriesForClient(
queryClient: QueryClient,
identity: CheckoutQueryIdentity,
+12
View File
@@ -0,0 +1,12 @@
import { ChevronRight } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
// Shared chevron used by collapsible git rows (changed-files tree, commits
// section). Kept in one place so the muted-foreground tint and the
// withUnistyles wrapping stay consistent across every disclosure control.
export const ThemedChevron = withUnistyles(ChevronRight);
export const chevronColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { resolveCheckoutCommitsQueryResult, type CheckoutCommitsData } from "./use-commits-query";
const EMPTY_COMMITS: CheckoutCommitsData = { baseRef: "main", commits: [] };
describe("resolveCheckoutCommitsQueryResult", () => {
it("stays idle while the collapsed section has never loaded", () => {
expect(
resolveCheckoutCommitsQueryResult({
enabled: false,
capabilityPresent: true,
canFetch: true,
data: undefined,
isPlaceholderData: false,
error: null,
}),
).toEqual({ status: "idle" });
});
it("reports loading instead of an empty result while the first request is pending", () => {
expect(
resolveCheckoutCommitsQueryResult({
enabled: true,
capabilityPresent: true,
canFetch: true,
data: undefined,
isPlaceholderData: false,
error: null,
}),
).toEqual({ status: "loading" });
});
it("types an empty commit list as loaded data", () => {
expect(
resolveCheckoutCommitsQueryResult({
enabled: true,
capabilityPresent: true,
canFetch: true,
data: EMPTY_COMMITS,
isPlaceholderData: false,
error: null,
}),
).toEqual({ status: "loaded", data: EMPTY_COMMITS });
});
it("surfaces a cold-load error", () => {
const error = new Error("git log failed");
expect(
resolveCheckoutCommitsQueryResult({
enabled: true,
capabilityPresent: true,
canFetch: true,
data: undefined,
isPlaceholderData: false,
error,
}),
).toEqual({ status: "error", error });
});
it("keeps cached data available while collapsed", () => {
expect(
resolveCheckoutCommitsQueryResult({
enabled: false,
capabilityPresent: true,
canFetch: true,
data: EMPTY_COMMITS,
isPlaceholderData: false,
error: null,
}),
).toEqual({ status: "loaded", data: EMPTY_COMMITS });
});
it("keeps previous-checkout placeholder data in loading state", () => {
expect(
resolveCheckoutCommitsQueryResult({
enabled: true,
capabilityPresent: true,
canFetch: true,
data: EMPTY_COMMITS,
isPlaceholderData: true,
error: null,
}),
).toEqual({ status: "loading" });
});
});
+102
View File
@@ -0,0 +1,102 @@
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
import { useFetchQuery } from "@/data/query";
import { checkoutCommitsQueryKey } from "@/git/query-keys";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
// Commits ahead of base change rarely while the section is open; this keeps a
// collapse/re-expand cycle warm without leaving the fetch result stale for long.
const CHECKOUT_COMMITS_STALE_TIME = 30_000;
interface UseCheckoutCommitsQueryOptions {
serverId: string;
cwd: string;
enabled?: boolean;
}
export interface CheckoutCommitsData {
baseRef: string | null;
commits: CheckoutCommit[];
}
export type CheckoutCommitsQueryResult =
| { status: "unsupported" }
| { status: "idle" }
| { status: "connecting" }
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "loaded"; data: CheckoutCommitsData };
interface ResolveCheckoutCommitsQueryResultInput {
enabled: boolean;
capabilityPresent: boolean;
canFetch: boolean;
data: CheckoutCommitsData | undefined;
isPlaceholderData: boolean;
error: Error | null;
}
export function resolveCheckoutCommitsQueryResult({
enabled,
capabilityPresent,
canFetch,
data,
isPlaceholderData,
error,
}: ResolveCheckoutCommitsQueryResultInput): CheckoutCommitsQueryResult {
if (!capabilityPresent) {
return { status: "unsupported" };
}
if (data && !isPlaceholderData) {
return { status: "loaded", data };
}
if (!enabled) {
return { status: "idle" };
}
if (!canFetch) {
return { status: "connecting" };
}
if (error) {
return { status: "error", error };
}
return { status: "loading" };
}
export function useCheckoutCommitsQuery({
serverId,
cwd,
enabled = true,
}: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult {
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
// Single capability-detection site; downstream reads a clean load-state union.
const capabilityPresent = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.commitsList === true,
);
const canFetch = Boolean(cwd) && Boolean(client) && isConnected;
const queryEnabled = enabled && capabilityPresent && canFetch;
const query = useFetchQuery<CheckoutCommitsData>({
queryKey: checkoutCommitsQueryKey(serverId, cwd),
queryFn: async () => {
if (!client) {
throw new Error("Host disconnected");
}
return client.listCheckoutCommits(cwd);
},
enabled: queryEnabled,
staleTimeMs: CHECKOUT_COMMITS_STALE_TIME,
dataShape: "list",
});
return resolveCheckoutCommitsQueryResult({
enabled,
capabilityPresent,
canFetch,
data: query.data,
isPlaceholderData: query.isPlaceholderData,
error: query.error,
});
}
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import type { CheckoutCommitFile, ParsedDiffFile } from "@getpaseo/protocol/messages";
import { resolveCommitDiffFiles } from "./use-diff-files";
function createCommitFile(
overrides: Partial<CheckoutCommitFile> & { path: string },
): CheckoutCommitFile {
return {
path: overrides.path,
additions: overrides.additions ?? 0,
deletions: overrides.deletions ?? 0,
...(overrides.status ? { status: overrides.status } : {}),
};
}
function createParsedDiffFile(
overrides: Partial<ParsedDiffFile> & { path: string },
): ParsedDiffFile {
return {
path: overrides.path,
isNew: overrides.isNew ?? false,
isDeleted: overrides.isDeleted ?? false,
additions: overrides.additions ?? 1,
deletions: overrides.deletions ?? 0,
hunks: overrides.hunks ?? [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [{ type: "add", content: "x", tokens: [] }],
},
],
status: overrides.status,
} as ParsedDiffFile;
}
describe("resolveCommitDiffFiles", () => {
it("keeps pending commit files out of the shared view until their per-file diff resolves", () => {
const files = [
createCommitFile({ path: "blob.bin", status: "added" }),
createCommitFile({ path: "src/app.ts", additions: 3, deletions: 1, status: "modified" }),
];
const resolvedByPath = new Map<string, ParsedDiffFile | null | undefined>([
["blob.bin", undefined],
[
"src/app.ts",
createParsedDiffFile({
path: "src/app.ts",
additions: 3,
deletions: 1,
}),
],
]);
expect(resolveCommitDiffFiles(files, resolvedByPath)).toEqual([
expect.objectContaining({
path: "src/app.ts",
additions: 3,
deletions: 1,
}),
]);
});
it("preserves binary-only commit files from commit metadata when the per-file diff is null", () => {
const files = [
createCommitFile({ path: "blob.bin", status: "added" }),
createCommitFile({ path: "src/app.ts", additions: 3, deletions: 1, status: "modified" }),
];
const resolvedByPath = new Map<string, ParsedDiffFile | null>([
["blob.bin", null],
[
"src/app.ts",
createParsedDiffFile({
path: "src/app.ts",
additions: 3,
deletions: 1,
}),
],
]);
expect(resolveCommitDiffFiles(files, resolvedByPath)).toEqual([
{
path: "blob.bin",
isNew: true,
isDeleted: false,
additions: 0,
deletions: 0,
hunks: [],
status: "binary",
},
expect.objectContaining({
path: "src/app.ts",
additions: 3,
deletions: 1,
}),
]);
});
});
+117
View File
@@ -0,0 +1,117 @@
import { useMemo } from "react";
import type { CheckoutCommitFile, ParsedDiffFile } from "@getpaseo/protocol/messages";
import { useFetchQueries } from "@/data/query";
import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "@/git/query-keys";
import { useCheckoutCommitsQuery } from "@/git/use-commits-query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
/**
* Context needed to resolve a commit diff against a host: which daemon
* (`serverId`), which checkout (`cwd`), and which commit (`sha`). `enabled`
* lets the consumer pause all fetching (e.g. an inactive tab).
*/
export interface CommitDiffFilesContext {
serverId: string;
cwd: string;
sha: string;
enabled?: boolean;
}
export interface CommitDiffFilesResult {
files: ParsedDiffFile[];
isLoading: boolean;
error: Error | null;
capabilityMissing: boolean;
}
export function resolveCommitDiffFile(
file: CheckoutCommitFile,
resolved: ParsedDiffFile | null | undefined,
): ParsedDiffFile | null {
if (resolved !== undefined && resolved !== null) {
return resolved;
}
if (resolved === undefined) {
return null;
}
return {
path: file.path,
isNew: file.status === "added",
isDeleted: file.status === "deleted",
additions: file.additions,
deletions: file.deletions,
hunks: [],
status: "binary",
};
}
export function resolveCommitDiffFiles(
files: CheckoutCommitFile[],
resolvedByPath: ReadonlyMap<string, ParsedDiffFile | null | undefined>,
): ParsedDiffFile[] {
return files.flatMap((file) => {
const resolved = resolveCommitDiffFile(file, resolvedByPath.get(file.path));
return resolved ? [resolved] : [];
});
}
export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFilesResult {
const { serverId, cwd, sha, enabled = true } = ctx;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const commitsQuery = useCheckoutCommitsQuery({ serverId, cwd, enabled });
const commitsData = commitsQuery.status === "loaded" ? commitsQuery.data : null;
const commitFiles = useMemo(() => {
if (!sha || !commitsData) {
return [];
}
return commitsData.commits.find((commit) => commit.sha === sha)?.files ?? [];
}, [commitsData, sha]);
const fileDiffsEnabled =
enabled &&
commitsQuery.status === "loaded" &&
Boolean(cwd) &&
Boolean(sha) &&
Boolean(client) &&
isConnected;
const fileDiffResults = useFetchQueries(
commitFiles.map((file) => ({
queryKey: checkoutCommitFileDiffQueryKey(serverId, cwd, sha, file.path),
queryFn: async (): Promise<{ file: ParsedDiffFile | null }> => {
if (!client) {
throw new Error("Host disconnected");
}
return client.getCommitFileDiff(cwd, sha, file.path);
},
enabled: fileDiffsEnabled,
staleTimeMs: COMMIT_FILE_DIFF_STALE_TIME,
dataShape: "value" as const,
})),
);
const commitsLoading = commitsQuery.status === "connecting" || commitsQuery.status === "loading";
const commitsError = commitsQuery.status === "error" ? commitsQuery.error : null;
const capabilityMissing = commitsQuery.status === "unsupported";
return useMemo<CommitDiffFilesResult>(() => {
const resolvedByPath = new Map<string, ParsedDiffFile | null | undefined>();
commitFiles.forEach((file, index) => {
const fileResult = fileDiffResults[index];
resolvedByPath.set(file.path, fileResult?.data ? fileResult.data.file : undefined);
});
const files = resolveCommitDiffFiles(commitFiles, resolvedByPath);
let firstFileError: Error | null = null;
for (const fileResult of fileDiffResults) {
if (fileResult.error) {
firstFileError = fileResult.error;
break;
}
}
return {
files,
isLoading: commitsLoading || fileDiffResults.some((r) => r.isLoading),
error: commitsError ?? firstFileError,
capabilityMissing,
};
}, [capabilityMissing, commitFiles, commitsError, commitsLoading, fileDiffResults]);
}
+3 -2
View File
@@ -2,7 +2,7 @@ import { useMemo } from "react";
import { useReplicaQuery } from "@/data/query";
import { checkoutDiffPushRoute } from "@/data/push-router";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
import { checkoutDiffQueryKey } from "@/git/query-keys";
interface UseCheckoutDiffQueryOptions {
@@ -16,7 +16,8 @@ interface UseCheckoutDiffQueryOptions {
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
export type ParsedDiffFile = CheckoutDiffQueryPayload["files"][number];
// Re-export the canonical protocol type so all consumers share one definition.
export type { ParsedDiffFile };
export type DiffHunk = ParsedDiffFile["hunks"][number];
export type DiffLine = DiffHunk["lines"][number];
export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
@@ -31,6 +31,7 @@ describe("loadChangesPreferencesFromStorage", () => {
viewMode: "flat",
wrapLines: true,
hideWhitespace: false,
commitsCollapsed: true,
});
expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(JSON.stringify(result));
});
@@ -53,12 +54,39 @@ describe("loadChangesPreferencesFromStorage", () => {
viewMode: "tree",
hideWhitespace: true,
wrapLines: false,
commitsCollapsed: true,
});
expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(persisted);
expect(storage.entries.size).toBe(1);
});
});
describe("changes preferences commitsCollapsed", () => {
it("collapses commits by default", () => {
expect(DEFAULT_CHANGES_PREFERENCES.commitsCollapsed).toBe(true);
});
it("round-trips commitsCollapsed: false", async () => {
const storage = createInMemoryKeyValueStorage({
[CHANGES_PREFERENCES_STORAGE_KEY]: JSON.stringify({ commitsCollapsed: false }),
});
const prefs = await loadChangesPreferencesFromStorage(storage);
expect(prefs.commitsCollapsed).toBe(false);
});
it("falls back to collapsed for invalid commitsCollapsed", async () => {
const storage = createInMemoryKeyValueStorage({
[CHANGES_PREFERENCES_STORAGE_KEY]: JSON.stringify({ commitsCollapsed: "nope" }),
});
const prefs = await loadChangesPreferencesFromStorage(storage);
expect(prefs.commitsCollapsed).toBe(true);
});
});
describe("saveChangesPreferences", () => {
it("merges updates onto cached preferences and persists the result", async () => {
const storage = createInMemoryKeyValueStorage();
@@ -10,6 +10,7 @@ const changesPreferencesSchema = z.object({
viewMode: z.enum(["flat", "tree"]).optional(),
wrapLines: z.boolean().optional(),
hideWhitespace: z.boolean().optional(),
commitsCollapsed: z.boolean().optional(),
});
export interface ChangesPreferences {
@@ -17,6 +18,7 @@ export interface ChangesPreferences {
viewMode: "flat" | "tree";
wrapLines: boolean;
hideWhitespace: boolean;
commitsCollapsed: boolean;
}
export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
@@ -24,6 +26,7 @@ export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
viewMode: "flat",
wrapLines: false,
hideWhitespace: false,
commitsCollapsed: true,
};
export interface KeyValueStorage {
+20
View File
@@ -742,6 +742,17 @@ export const ar: TranslationResources = {
base: "قاعدة",
newFile: "جديد",
deletedFile: "تم الحذف",
commits: {
title: "الإيداعات",
legendLocal: "محلي",
legendRemote: "على المستودع البعيد",
countLabel: "{{count}} إيداعات قبل الأساس",
fileDiffEmpty: "لا توجد تغييرات لعرضها",
fileDiffError: "تعذّر تحميل فروق الملف",
loading: "جارٍ تحميل الإيداعات…",
loadError: "تعذّر تحميل الإيداعات",
empty: "لا توجد إيداعات قبل الأساس",
},
},
openInEditor: {
open: "يفتح",
@@ -1396,6 +1407,15 @@ export const ar: TranslationResources = {
failedToLoad: "فشل تحميل الملف",
failedToLoadPreview: "فشل تحميل معاينة الملف",
},
diff: {
changesLabel: "التغييرات",
changesSubtitle: "فروقات شجرة العمل",
commitSubtitle: "فروقات الالتزام",
directoryMissing: "لم يتم العثور على دليل Workspace.",
empty: "لا توجد تغييرات",
loadError: "فشل تحميل الفروقات",
capabilityMissing: "حدّث المضيف لعرض فروقات الالتزامات.",
},
},
toolCallDetails: {
error: "خطأ",
+20
View File
@@ -748,6 +748,17 @@ export const en = {
base: "base",
newFile: "New",
deletedFile: "Deleted",
commits: {
title: "Commits",
legendLocal: "local",
legendRemote: "on remote",
countLabel: "{{count}} commits ahead of base",
fileDiffEmpty: "No changes to display",
fileDiffError: "Failed to load file diff",
loading: "Loading commits…",
loadError: "Failed to load commits",
empty: "No commits ahead of base",
},
},
openInEditor: {
open: "Open",
@@ -1403,6 +1414,15 @@ export const en = {
failedToLoad: "Failed to load file",
failedToLoadPreview: "Failed to load file preview",
},
diff: {
changesLabel: "Changes",
changesSubtitle: "Working tree diff",
commitSubtitle: "Commit diff",
directoryMissing: "Workspace directory not found.",
empty: "No changes",
loadError: "Failed to load diff",
capabilityMissing: "Update the host to view commit diffs.",
},
},
toolCallDetails: {
error: "Error",
+20
View File
@@ -769,6 +769,17 @@ export const es: TranslationResources = {
base: "base",
newFile: "Nuevo",
deletedFile: "Eliminado",
commits: {
title: "Commits",
legendLocal: "local",
legendRemote: "en remoto",
countLabel: "{{count}} commits por delante de la base",
fileDiffEmpty: "No hay cambios para mostrar",
fileDiffError: "Error al cargar el diff del archivo",
loading: "Cargando commits…",
loadError: "Error al cargar los commits",
empty: "No hay commits por delante de la base",
},
},
openInEditor: {
open: "Abierto",
@@ -1435,6 +1446,15 @@ export const es: TranslationResources = {
failedToLoad: "No se pudo cargar el archivo",
failedToLoadPreview: "No se pudo cargar la vista previa del archivo",
},
diff: {
changesLabel: "Cambios",
changesSubtitle: "Diferencias del árbol de trabajo",
commitSubtitle: "Diferencias del commit",
directoryMissing: "No se encontró el directorio de Workspace.",
empty: "Sin cambios",
loadError: "No se pudieron cargar las diferencias",
capabilityMissing: "Actualiza el host para ver las diferencias de los commits.",
},
},
toolCallDetails: {
error: "Error",
+20
View File
@@ -767,6 +767,17 @@ export const fr: TranslationResources = {
base: "base",
newFile: "Nouveau",
deletedFile: "Supprimé",
commits: {
title: "Commits",
legendLocal: "local",
legendRemote: "sur le distant",
countLabel: "{{count}} commits en avance sur la base",
fileDiffEmpty: "Aucune modification à afficher",
fileDiffError: "Échec du chargement du diff du fichier",
loading: "Chargement des commits…",
loadError: "Échec du chargement des commits",
empty: "Aucun commit en avance sur la base",
},
},
openInEditor: {
open: "Ouvrir",
@@ -1437,6 +1448,15 @@ export const fr: TranslationResources = {
failedToLoad: "Échec du chargement du fichier",
failedToLoadPreview: "Échec du chargement de l'aperçu du fichier",
},
diff: {
changesLabel: "Modifications",
changesSubtitle: "Différences de l'arbre de travail",
commitSubtitle: "Différences du commit",
directoryMissing: "Répertoire Workspace introuvable.",
empty: "Aucune modification",
loadError: "Échec du chargement des différences",
capabilityMissing: "Mettez à jour l'hôte pour voir les différences des commits.",
},
},
toolCallDetails: {
error: "Erreur",
+20
View File
@@ -754,6 +754,17 @@ export const ja: TranslationResources = {
base: "ベース",
newFile: "新規",
deletedFile: "削除済み",
commits: {
title: "コミット",
legendLocal: "ローカル",
legendRemote: "リモート",
countLabel: "ベースより先のコミット数: {{count}}",
fileDiffEmpty: "表示する変更はありません",
fileDiffError: "ファイル差分の読み込みに失敗しました",
loading: "コミットを読み込み中…",
loadError: "コミットの読み込みに失敗しました",
empty: "ベースより先のコミットはありません",
},
},
openInEditor: {
open: "開く",
@@ -1413,6 +1424,15 @@ export const ja: TranslationResources = {
failedToLoad: "ファイルの読み込みに失敗しました",
failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました",
},
diff: {
changesLabel: "変更",
changesSubtitle: "作業ツリーの差分",
commitSubtitle: "コミット差分",
directoryMissing: "ワークスペースディレクトリが見つかりません。",
empty: "変更はありません",
loadError: "差分の読み込みに失敗しました",
capabilityMissing: "コミット差分を表示するにはホストを更新してください。",
},
},
toolCallDetails: {
error: "エラー",
+20
View File
@@ -760,6 +760,17 @@ export const ptBR: TranslationResources = {
base: "base",
newFile: "Novo",
deletedFile: "Excluído",
commits: {
title: "Commits",
legendLocal: "local",
legendRemote: "no remoto",
countLabel: "{{count}} commits à frente da base",
fileDiffEmpty: "Nenhuma alteração para exibir",
fileDiffError: "Falha ao carregar diff do arquivo",
loading: "Carregando commits…",
loadError: "Falha ao carregar commits",
empty: "Nenhum commit à frente da base",
},
},
openInEditor: {
open: "Abrir",
@@ -1421,6 +1432,15 @@ export const ptBR: TranslationResources = {
failedToLoad: "Falha ao carregar arquivo",
failedToLoadPreview: "Falha ao carregar prévia do arquivo",
},
diff: {
changesLabel: "Alterações",
changesSubtitle: "Diff da árvore de trabalho",
commitSubtitle: "Diff do commit",
directoryMissing: "Diretório do workspace não encontrado.",
empty: "Nenhuma alteração",
loadError: "Falha ao carregar diff",
capabilityMissing: "Atualize o host para ver diffs de commits.",
},
},
toolCallDetails: {
error: "Erro",
+20
View File
@@ -761,6 +761,17 @@ export const ru: TranslationResources = {
base: "база",
newFile: "Новый",
deletedFile: "Удалено",
commits: {
title: "Коммиты",
legendLocal: "локально",
legendRemote: "на удалённом",
countLabel: "{{count}} коммитов впереди базы",
fileDiffEmpty: "Нет изменений для отображения",
fileDiffError: "Не удалось загрузить различия файла",
loading: "Загрузка коммитов…",
loadError: "Не удалось загрузить коммиты",
empty: "Нет коммитов впереди базы",
},
},
openInEditor: {
open: "Открыть",
@@ -1427,6 +1438,15 @@ export const ru: TranslationResources = {
failedToLoad: "Не удалось загрузить файл",
failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.",
},
diff: {
changesLabel: "Изменения",
changesSubtitle: "Различия рабочего дерева",
commitSubtitle: "Различия коммита",
directoryMissing: "Каталог Workspace не найден.",
empty: "Нет изменений",
loadError: "Не удалось загрузить различия",
capabilityMissing: "Обновите хост, чтобы просматривать различия коммитов.",
},
},
toolCallDetails: {
error: "Ошибка",
+20
View File
@@ -737,6 +737,17 @@ export const zhCN: TranslationResources = {
base: "base",
newFile: "新增",
deletedFile: "已删除",
commits: {
title: "提交",
legendLocal: "本地",
legendRemote: "已推送",
countLabel: "领先基线 {{count}} 个提交",
fileDiffEmpty: "没有可显示的更改",
fileDiffError: "加载文件差异失败",
loading: "正在加载提交…",
loadError: "加载提交失败",
empty: "没有领先基线的提交",
},
},
openInEditor: {
open: "打开",
@@ -1380,6 +1391,15 @@ export const zhCN: TranslationResources = {
failedToLoad: "加载文件失败",
failedToLoadPreview: "加载文件预览失败",
},
diff: {
changesLabel: "更改",
changesSubtitle: "工作区差异",
commitSubtitle: "提交差异",
directoryMissing: "未找到 workspace 目录。",
empty: "没有更改",
loadError: "加载差异失败",
capabilityMissing: "请更新主机以查看提交差异。",
},
},
toolCallDetails: {
error: "错误",
@@ -0,0 +1,124 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { GitCommitHorizontal } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useAppSettings } from "@/hooks/use-settings";
import { SharedDiffView } from "@/git/diff-pane";
import { useCommitDiffFiles } from "@/git/use-diff-files";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
function CommitDiffPanel() {
const { t } = useTranslation();
const { serverId, workspaceId, target } = usePaneContext();
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const { settings } = useAppSettings();
const { preferences } = useChangesPreferences();
const isCompact = useIsCompactFormFactor();
invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target");
const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({
serverId,
cwd: cwd ?? "",
sha: target.sha,
enabled: Boolean(cwd),
});
const effectiveLayout = isWeb && !isCompact ? preferences.layout : "unified";
const displayPreferences = useMemo(
() => ({
layout: effectiveLayout,
wrapLines: preferences.wrapLines,
codeFontSize: settings.codeFontSize,
monoFontFamily: settings.monoFontFamily,
}),
[effectiveLayout, preferences.wrapLines, settings.codeFontSize, settings.monoFontFamily],
);
const commitMode = useMemo(() => ({ kind: "commit" as const }), []);
if (!cwd) {
return (
<View style={styles.centerState}>
<Text style={styles.mutedText}>{t("panels.diff.directoryMissing")}</Text>
</View>
);
}
if (capabilityMissing) {
return (
<View style={styles.centerState} testID="commit-diff-capability-missing">
<Text style={styles.mutedText}>{t("panels.diff.capabilityMissing")}</Text>
</View>
);
}
if (error) {
return (
<View style={styles.centerState} testID="commit-diff-error">
<Text style={styles.errorText}>{t("panels.diff.loadError")}</Text>
</View>
);
}
if (isLoading && files.length === 0) {
return (
<View style={styles.centerState} testID="commit-diff-loading">
<Text style={styles.mutedText}>{t("workspace.tabs.loading")}</Text>
</View>
);
}
if (files.length === 0) {
return (
<View style={styles.centerState} testID="commit-diff-empty">
<Text style={styles.mutedText}>{t("panels.diff.empty")}</Text>
</View>
);
}
return <SharedDiffView files={files} displayPreferences={displayPreferences} mode={commitMode} />;
}
function useCommitDiffPanelDescriptor(
target: Extract<WorkspaceTabTarget, { kind: "commit_diff" }>,
): PanelDescriptor {
const { t } = useTranslation();
return {
label: target.sha.slice(0, 7),
subtitle: t("panels.diff.commitSubtitle"),
titleState: "ready",
icon: ThemedGitCommitHorizontal,
statusBucket: null,
};
}
export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = {
kind: "commit_diff",
component: CommitDiffPanel,
useDescriptor: useCommitDiffPanelDescriptor,
};
const styles = StyleSheet.create((theme) => ({
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[16],
},
mutedText: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
errorText: {
fontSize: theme.fontSize.base,
color: theme.colors.destructive,
textAlign: "center",
},
}));
@@ -1,5 +1,6 @@
import { agentPanelRegistration } from "@/panels/agent-panel";
import { browserPanelRegistration } from "@/panels/browser-panel";
import { commitDiffPanelRegistration } from "@/panels/commit-diff-panel";
import { draftPanelRegistration } from "@/panels/draft-panel";
import { filePanelRegistration } from "@/panels/file-panel";
import { registerPanel } from "@/panels/panel-registry";
@@ -20,5 +21,6 @@ export function ensurePanelsRegistered(): void {
registerPanel(terminalPanelRegistration);
registerPanel(browserPanelRegistration);
registerPanel(filePanelRegistration);
registerPanel(commitDiffPanelRegistration);
panelsRegistered = true;
}
@@ -351,6 +351,9 @@ function getFallbackTabOptionLabel(
if (tab.target.kind === "file") {
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
}
if (tab.target.kind === "commit_diff") {
return tab.target.sha.slice(0, 7);
}
return labels.agent;
}
@@ -382,6 +385,9 @@ function getFallbackTabOptionDescription(
if (tab.target.kind === "provider_subagent") {
return labels.agent;
}
if (tab.target.kind === "commit_diff") {
return tab.target.sha.slice(0, 7);
}
return tab.target.path;
}
@@ -141,6 +141,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "provider_subagent") {
return `workspace-provider-subagent-close-${tab.target.subagentId}`;
}
if (tab.target.kind === "commit_diff") {
return `workspace-commit-diff-close-${encodeFilePathForPathSegment(tab.target.sha)}`;
}
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
}
@@ -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 {
+2 -1
View File
@@ -1,4 +1,5 @@
import type { DiffLine, ParsedDiffFile } from "@/git/use-diff-query";
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import type { DiffLine } from "@/git/use-diff-query";
type ReviewSide = "old" | "new";
type ReviewableLineType = "add" | "remove" | "context";
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, it, test } from "vitest";
import {
buildDeterministicWorkspaceTabId,
normalizeWorkspaceTabTarget,
@@ -43,3 +43,56 @@ describe("provider subagent tab identity", () => {
expect(first).not.toBe(second);
});
});
describe("commit diff tab identity", () => {
it("keys a commit diff tab by its sha", () => {
expect(buildDeterministicWorkspaceTabId({ kind: "commit_diff", sha: "abc123" })).toBe(
"commit_diff_abc123",
);
});
it("does not collide a commit diff tab id with a file tab id", () => {
const diffId = buildDeterministicWorkspaceTabId({ kind: "commit_diff", sha: "abc123" });
const fileId = buildDeterministicWorkspaceTabId({
kind: "file",
path: "abc123",
});
expect(diffId).not.toBe(fileId);
});
it("treats two commit diff targets with the same sha as equal", () => {
expect(
workspaceTabTargetsEqual(
{ kind: "commit_diff", sha: "abc123" },
{ kind: "commit_diff", sha: "abc123" },
),
).toBe(true);
});
it("treats commit diff targets with different shas as unequal", () => {
expect(
workspaceTabTargetsEqual(
{ kind: "commit_diff", sha: "abc123" },
{ kind: "commit_diff", sha: "def456" },
),
).toBe(false);
});
it("normalizes a commit diff target", () => {
expect(
normalizeWorkspaceTabTarget({
kind: "commit_diff",
sha: "abc123",
}),
).toEqual({ kind: "commit_diff", sha: "abc123" });
});
it("rejects a commit diff target with a blank sha", () => {
expect(
normalizeWorkspaceTabTarget({
kind: "commit_diff",
sha: " ",
}),
).toBeNull();
});
});
+33 -12
View File
@@ -28,22 +28,37 @@ export function normalizeWorkspaceTabTarget(
? { kind: "provider_subagent", parentAgentId, subagentId }
: null;
}
if (value.kind === "terminal") {
const terminalId = trimNonEmpty(value.terminalId);
return terminalId ? { kind: "terminal", terminalId } : null;
}
if (value.kind === "browser") {
const browserId = trimNonEmpty(value.browserId);
return browserId ? { kind: "browser", browserId } : null;
}
if (value.kind === "file") {
return normalizeFileTabTarget(value);
}
if (value.kind === "setup") {
const workspaceId = trimNonEmpty(value.workspaceId);
return workspaceId ? { kind: "setup", workspaceId } : null;
return normalizeSimpleWorkspaceTabTarget(value);
}
function normalizeSimpleWorkspaceTabTarget(value: WorkspaceTabTarget): WorkspaceTabTarget | null {
switch (value.kind) {
case "agent": {
const agentId = trimNonEmpty(value.agentId);
return agentId ? { kind: "agent", agentId } : null;
}
case "terminal": {
const terminalId = trimNonEmpty(value.terminalId);
return terminalId ? { kind: "terminal", terminalId } : null;
}
case "browser": {
const browserId = trimNonEmpty(value.browserId);
return browserId ? { kind: "browser", browserId } : null;
}
case "setup": {
const workspaceId = trimNonEmpty(value.workspaceId);
return workspaceId ? { kind: "setup", workspaceId } : null;
}
case "commit_diff": {
const sha = trimNonEmpty(value.sha);
return sha ? { kind: "commit_diff", sha } : null;
}
default:
return null;
}
return null;
}
export function normalizeWorkspaceDraftTabSetup(
@@ -98,6 +113,9 @@ export function workspaceTabTargetsEqual(
if (left.kind === "setup" && right.kind === "setup") {
return left.workspaceId === right.workspaceId;
}
if (left.kind === "commit_diff" && right.kind === "commit_diff") {
return left.sha === right.sha;
}
return false;
}
@@ -153,6 +171,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
if (target.kind === "setup") {
return `setup_${target.workspaceId}`;
}
if (target.kind === "commit_diff") {
return `commit_diff_${target.sha}`;
}
return `file_${target.path}`;
}