mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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,
|
||||
|
||||
68
packages/app/src/git/commits-section/commit-row.tsx
Normal file
68
packages/app/src/git/commits-section/commit-row.tsx
Normal file
@@ -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,
|
||||
},
|
||||
}));
|
||||
226
packages/app/src/git/commits-section/commits-section.tsx
Normal file
226
packages/app/src/git/commits-section/commits-section.tsx
Normal file
@@ -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,
|
||||
},
|
||||
}));
|
||||
51
packages/app/src/git/commits-section/shared.ts
Normal file
51
packages/app/src/git/commits-section/shared.ts
Normal file
@@ -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
@@ -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
packages/app/src/git/themed-chevron.ts
Normal file
12
packages/app/src/git/themed-chevron.ts
Normal 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,
|
||||
});
|
||||
85
packages/app/src/git/use-commits-query.test.ts
Normal file
85
packages/app/src/git/use-commits-query.test.ts
Normal file
@@ -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
packages/app/src/git/use-commits-query.ts
Normal file
102
packages/app/src/git/use-commits-query.ts
Normal 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
packages/app/src/git/use-diff-files.test.ts
Normal file
101
packages/app/src/git/use-diff-files.test.ts
Normal 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
packages/app/src/git/use-diff-files.ts
Normal file
117
packages/app/src/git/use-diff-files.ts
Normal 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]);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: "خطأ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "エラー",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Ошибка",
|
||||
|
||||
@@ -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: "错误",
|
||||
|
||||
124
packages/app/src/panels/commit-diff-panel.tsx
Normal file
124
packages/app/src/panels/commit-diff-panel.tsx
Normal file
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ import type {
|
||||
AgentForkContextResponseMessage,
|
||||
GitSetupOptions,
|
||||
CheckoutStatusResponse,
|
||||
CheckoutCommit,
|
||||
ParsedDiffFile,
|
||||
CheckoutCommitResponse,
|
||||
CheckoutMergeResponse,
|
||||
CheckoutMergeFromBaseResponse,
|
||||
@@ -3499,6 +3501,48 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listCheckoutCommits(
|
||||
cwd: string,
|
||||
requestId?: string,
|
||||
): Promise<{ baseRef: string | null; commits: CheckoutCommit[] }> {
|
||||
const payload =
|
||||
await this.sendNamespacedCorrelatedSessionRequest<"checkout.commits.list.response">({
|
||||
requestId,
|
||||
message: {
|
||||
type: "checkout.commits.list.request",
|
||||
cwd,
|
||||
},
|
||||
timeout: 60000,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
return { baseRef: payload.baseRef, commits: payload.commits };
|
||||
}
|
||||
|
||||
async getCommitFileDiff(
|
||||
cwd: string,
|
||||
sha: string,
|
||||
path: string,
|
||||
requestId?: string,
|
||||
): Promise<{ file: ParsedDiffFile | null }> {
|
||||
const payload =
|
||||
await this.sendNamespacedCorrelatedSessionRequest<"checkout.commits.file_diff.response">({
|
||||
requestId,
|
||||
message: {
|
||||
type: "checkout.commits.file_diff.request",
|
||||
cwd,
|
||||
sha,
|
||||
path,
|
||||
},
|
||||
timeout: 60000,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
return { file: payload.file };
|
||||
}
|
||||
|
||||
async checkoutPrCreate(
|
||||
cwd: string,
|
||||
input: { title?: string; body?: string; baseRef?: string },
|
||||
|
||||
133
packages/protocol/src/messages.checkout-commit-file-diff.test.ts
Normal file
133
packages/protocol/src/messages.checkout-commit-file-diff.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
CheckoutCommitFileDiffRequestSchema,
|
||||
CheckoutCommitFileDiffResponseSchema,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
describe("checkout.commits.file_diff schemas", () => {
|
||||
test("parses a valid request", () => {
|
||||
expect(
|
||||
CheckoutCommitFileDiffRequestSchema.parse({
|
||||
type: "checkout.commits.file_diff.request",
|
||||
cwd: "/tmp/repo",
|
||||
sha: "1111111111111111111111111111111111111111",
|
||||
path: "src/a.ts",
|
||||
requestId: "request-file-diff",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "checkout.commits.file_diff.request",
|
||||
cwd: "/tmp/repo",
|
||||
sha: "1111111111111111111111111111111111111111",
|
||||
path: "src/a.ts",
|
||||
requestId: "request-file-diff",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses a valid response with a populated ParsedDiffFile", () => {
|
||||
const payload = {
|
||||
cwd: "/tmp/repo",
|
||||
sha: "1111111111111111111111111111111111111111",
|
||||
path: "src/a.ts",
|
||||
file: {
|
||||
path: "src/a.ts",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 2,
|
||||
deletions: 1,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 2,
|
||||
newStart: 1,
|
||||
newCount: 3,
|
||||
lines: [
|
||||
{ type: "header" as const, content: "@@ -1,2 +1,3 @@" },
|
||||
{ type: "context" as const, content: "first" },
|
||||
{ type: "remove" as const, content: "old" },
|
||||
{ type: "add" as const, content: "new1" },
|
||||
{ type: "add" as const, content: "new2" },
|
||||
],
|
||||
},
|
||||
],
|
||||
status: "ok" as const,
|
||||
},
|
||||
error: null,
|
||||
requestId: "request-file-diff",
|
||||
};
|
||||
|
||||
const parsed = CheckoutCommitFileDiffResponseSchema.parse({
|
||||
type: "checkout.commits.file_diff.response",
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(parsed.payload).toEqual(payload);
|
||||
expect(parsed.payload.file?.hunks[0]?.lines).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("parses a valid response with a null file", () => {
|
||||
const payload = {
|
||||
cwd: "/tmp/repo",
|
||||
sha: "1111111111111111111111111111111111111111",
|
||||
path: "src/missing.ts",
|
||||
file: null,
|
||||
error: null,
|
||||
requestId: "request-file-diff",
|
||||
};
|
||||
|
||||
expect(
|
||||
CheckoutCommitFileDiffResponseSchema.parse({
|
||||
type: "checkout.commits.file_diff.response",
|
||||
payload,
|
||||
}).payload,
|
||||
).toEqual(payload);
|
||||
});
|
||||
|
||||
test("accepts a null file with an error payload", () => {
|
||||
const payload = {
|
||||
cwd: "/tmp/repo",
|
||||
sha: "1111111111111111111111111111111111111111",
|
||||
path: "src/a.ts",
|
||||
file: null,
|
||||
error: { code: "UNKNOWN" as const, message: "boom" },
|
||||
requestId: "request-file-diff",
|
||||
};
|
||||
|
||||
expect(
|
||||
CheckoutCommitFileDiffResponseSchema.parse({
|
||||
type: "checkout.commits.file_diff.response",
|
||||
payload,
|
||||
}).payload,
|
||||
).toEqual(payload);
|
||||
});
|
||||
|
||||
test("parses the request through the inbound message union", () => {
|
||||
expect(
|
||||
SessionInboundMessageSchema.parse({
|
||||
type: "checkout.commits.file_diff.request",
|
||||
cwd: "/tmp/repo",
|
||||
sha: "1111111111111111111111111111111111111111",
|
||||
path: "src/a.ts",
|
||||
requestId: "request-file-diff",
|
||||
}),
|
||||
).toMatchObject({ type: "checkout.commits.file_diff.request" });
|
||||
});
|
||||
|
||||
test("parses the response through the outbound message union", () => {
|
||||
expect(
|
||||
SessionOutboundMessageSchema.parse({
|
||||
type: "checkout.commits.file_diff.response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
sha: "1111111111111111111111111111111111111111",
|
||||
path: "src/a.ts",
|
||||
file: null,
|
||||
error: null,
|
||||
requestId: "request-file-diff",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ type: "checkout.commits.file_diff.response" });
|
||||
});
|
||||
});
|
||||
133
packages/protocol/src/messages.checkout-commits.test.ts
Normal file
133
packages/protocol/src/messages.checkout-commits.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
CheckoutCommitsListRequestSchema,
|
||||
CheckoutCommitsListResponseSchema,
|
||||
ServerInfoStatusPayloadSchema,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
} from "./messages.js";
|
||||
|
||||
describe("checkout.commits.list schemas", () => {
|
||||
test("parses a valid request", () => {
|
||||
expect(
|
||||
CheckoutCommitsListRequestSchema.parse({
|
||||
type: "checkout.commits.list.request",
|
||||
cwd: "/tmp/repo",
|
||||
requestId: "request-commits",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "checkout.commits.list.request",
|
||||
cwd: "/tmp/repo",
|
||||
requestId: "request-commits",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses a valid response with local-only and remote commits", () => {
|
||||
const payload = {
|
||||
cwd: "/tmp/repo",
|
||||
baseRef: "main",
|
||||
commits: [
|
||||
{
|
||||
sha: "1111111111111111111111111111111111111111",
|
||||
shortSha: "1111111",
|
||||
subject: "Add feature",
|
||||
authorName: "Ada",
|
||||
authorDate: "2026-06-13T10:00:00.000Z",
|
||||
isOnRemote: true,
|
||||
files: [
|
||||
{ path: "src/a.ts", additions: 10, deletions: 2, status: "modified" },
|
||||
{ path: "src/b.ts", additions: 5, deletions: 0, status: "added" },
|
||||
],
|
||||
},
|
||||
{
|
||||
sha: "2222222222222222222222222222222222222222",
|
||||
shortSha: "2222222",
|
||||
subject: "Local only work",
|
||||
authorName: "Ada",
|
||||
authorDate: "2026-06-13T11:00:00.000Z",
|
||||
isOnRemote: false,
|
||||
files: [{ path: "src/c.ts", additions: 1, deletions: 1 }],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
requestId: "request-commits",
|
||||
};
|
||||
|
||||
const parsed = CheckoutCommitsListResponseSchema.parse({
|
||||
type: "checkout.commits.list.response",
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(parsed.payload).toEqual(payload);
|
||||
expect(parsed.payload.commits[0]?.isOnRemote).toBe(true);
|
||||
expect(parsed.payload.commits[1]?.isOnRemote).toBe(false);
|
||||
expect(parsed.payload.commits[1]?.files[0]?.status).toBeUndefined();
|
||||
});
|
||||
|
||||
test("accepts a null baseRef and an error payload", () => {
|
||||
const payload = {
|
||||
cwd: "/tmp/repo",
|
||||
baseRef: null,
|
||||
commits: [],
|
||||
error: { code: "NOT_GIT_REPO" as const, message: "not a repo" },
|
||||
requestId: "request-commits",
|
||||
};
|
||||
|
||||
expect(
|
||||
CheckoutCommitsListResponseSchema.parse({
|
||||
type: "checkout.commits.list.response",
|
||||
payload,
|
||||
}).payload,
|
||||
).toEqual(payload);
|
||||
});
|
||||
|
||||
test("parses the request through the inbound message union", () => {
|
||||
expect(
|
||||
SessionInboundMessageSchema.parse({
|
||||
type: "checkout.commits.list.request",
|
||||
cwd: "/tmp/repo",
|
||||
requestId: "request-commits",
|
||||
}),
|
||||
).toMatchObject({ type: "checkout.commits.list.request" });
|
||||
});
|
||||
|
||||
test("parses the response through the outbound message union", () => {
|
||||
expect(
|
||||
SessionOutboundMessageSchema.parse({
|
||||
type: "checkout.commits.list.response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
baseRef: "main",
|
||||
commits: [],
|
||||
error: null,
|
||||
requestId: "request-commits",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ type: "checkout.commits.list.response" });
|
||||
});
|
||||
|
||||
test("accepts the commitsList server_info feature flag", () => {
|
||||
expect(
|
||||
ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
serverId: "srv_test",
|
||||
features: {
|
||||
commitsList: true,
|
||||
},
|
||||
}).features,
|
||||
).toEqual({ commitsList: true });
|
||||
});
|
||||
|
||||
test("still parses server_info without the commitsList feature flag", () => {
|
||||
expect(
|
||||
ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
serverId: "srv_test",
|
||||
features: {
|
||||
providersSnapshot: true,
|
||||
},
|
||||
}).features,
|
||||
).toEqual({ providersSnapshot: true });
|
||||
});
|
||||
});
|
||||
@@ -1644,6 +1644,37 @@ export const CheckoutGithubSetAutoMergeRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
const CheckoutCommitFileSchema = z.object({
|
||||
path: z.string(),
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
status: z.enum(["added", "modified", "deleted", "renamed"]).optional(),
|
||||
});
|
||||
|
||||
const CheckoutCommitSchema = z.object({
|
||||
sha: z.string(),
|
||||
shortSha: z.string(),
|
||||
subject: z.string(),
|
||||
authorName: z.string(),
|
||||
authorDate: z.string(), // ISO 8601
|
||||
isOnRemote: z.boolean(), // false = local-only (unpushed)
|
||||
files: z.array(CheckoutCommitFileSchema),
|
||||
});
|
||||
|
||||
export const CheckoutCommitsListRequestSchema = z.object({
|
||||
type: z.literal("checkout.commits.list.request"),
|
||||
cwd: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const CheckoutCommitFileDiffRequestSchema = z.object({
|
||||
type: z.literal("checkout.commits.file_diff.request"),
|
||||
cwd: z.string(),
|
||||
sha: z.string(),
|
||||
path: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
const GitHubRepoSegmentSchema = z.string().regex(/^[A-Za-z0-9._-]+$/);
|
||||
|
||||
export const CheckoutGithubGetCheckDetailsRequestSchema = z.object({
|
||||
@@ -2246,6 +2277,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutPrCreateRequestSchema,
|
||||
CheckoutPrMergeRequestSchema,
|
||||
CheckoutGithubSetAutoMergeRequestSchema,
|
||||
CheckoutCommitsListRequestSchema,
|
||||
CheckoutCommitFileDiffRequestSchema,
|
||||
CheckoutGithubGetCheckDetailsRequestSchema,
|
||||
CheckoutPrStatusRequestSchema,
|
||||
PullRequestTimelineRequestSchema,
|
||||
@@ -2523,6 +2556,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
workspaceGithubRepositorySearch: z.boolean().optional(),
|
||||
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
|
||||
projectCreateDirectory: z.boolean().optional(),
|
||||
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
|
||||
commitsList: z.boolean().optional(),
|
||||
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
|
||||
providerRemoval: z.boolean().optional(),
|
||||
})
|
||||
@@ -3794,6 +3829,31 @@ export const CheckoutGithubSetAutoMergeResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const CheckoutCommitsListResponseSchema = z.object({
|
||||
type: z.literal("checkout.commits.list.response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
baseRef: z.string().nullable(),
|
||||
commits: z.array(CheckoutCommitSchema),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const CheckoutCommitFileDiffResponseSchema = z.object({
|
||||
type: z.literal("checkout.commits.file_diff.response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
sha: z.string(),
|
||||
path: z.string(),
|
||||
// null when the file is absent from the commit or carries no textual diff
|
||||
// (e.g. binary-only changes).
|
||||
file: ParsedDiffFileSchema.nullable(),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const CheckoutGithubCheckAnnotationSchema = z.object({
|
||||
path: z.string().optional(),
|
||||
startLine: z.number().optional(),
|
||||
@@ -4571,6 +4631,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutPrCreateResponseSchema,
|
||||
CheckoutPrMergeResponseSchema,
|
||||
CheckoutGithubSetAutoMergeResponseSchema,
|
||||
CheckoutCommitsListResponseSchema,
|
||||
CheckoutCommitFileDiffResponseSchema,
|
||||
CheckoutGithubGetCheckDetailsResponseSchema,
|
||||
CheckoutPrStatusResponseSchema,
|
||||
PullRequestTimelineResponseSchema,
|
||||
@@ -4883,6 +4945,13 @@ export type CheckoutPushRequest = z.infer<typeof CheckoutPushRequestSchema>;
|
||||
export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>;
|
||||
export type CheckoutRefreshRequest = z.infer<typeof CheckoutRefreshRequestSchema>;
|
||||
export type CheckoutRefreshResponse = z.infer<typeof CheckoutRefreshResponseSchema>;
|
||||
export type CheckoutCommitFile = z.infer<typeof CheckoutCommitFileSchema>;
|
||||
export type CheckoutCommit = z.infer<typeof CheckoutCommitSchema>;
|
||||
export type CheckoutCommitsListRequest = z.infer<typeof CheckoutCommitsListRequestSchema>;
|
||||
export type CheckoutCommitsListResponse = z.infer<typeof CheckoutCommitsListResponseSchema>;
|
||||
export type CheckoutCommitFileDiffRequest = z.infer<typeof CheckoutCommitFileDiffRequestSchema>;
|
||||
export type CheckoutCommitFileDiffResponse = z.infer<typeof CheckoutCommitFileDiffResponseSchema>;
|
||||
export type ParsedDiffFile = z.infer<typeof ParsedDiffFileSchema>;
|
||||
export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>;
|
||||
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
|
||||
export type CheckoutPrMergeRequest = z.infer<typeof CheckoutPrMergeRequestSchema>;
|
||||
|
||||
@@ -1006,6 +1006,7 @@ test("receives server_info on websocket connect", async () => {
|
||||
expect(serverInfo).not.toBeNull();
|
||||
expect(serverInfo?.serverId.length).toBeGreaterThan(0);
|
||||
expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true);
|
||||
expect(serverInfo?.features?.commitsList).toBe(true);
|
||||
expect(serverInfo?.desktopManaged).toBe(false);
|
||||
expect(serverInfo?.features?.daemonSelfUpdate).toBe(true);
|
||||
expect(serverInfo?.features?.worktreeRestore).toBe(true);
|
||||
|
||||
@@ -1620,6 +1620,10 @@ export class Session {
|
||||
switch (msg.type) {
|
||||
case "checkout_status_request":
|
||||
return this.checkoutSession.handleStatusRequest(msg);
|
||||
case "checkout.commits.list.request":
|
||||
return this.checkoutSession.handleCommitsListRequest(msg);
|
||||
case "checkout.commits.file_diff.request":
|
||||
return this.checkoutSession.handleCommitFileDiffRequest(msg);
|
||||
case "validate_branch_request":
|
||||
return this.checkoutSession.handleValidateBranchRequest(msg);
|
||||
case "branch_suggestions_request":
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type pino from "pino";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
|
||||
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
import type {
|
||||
BranchSuggestionsRequest,
|
||||
CheckoutCommitsListRequest,
|
||||
CheckoutCommitFileDiffRequest,
|
||||
CheckoutRefreshRequest,
|
||||
CheckoutRenameBranchRequest,
|
||||
CheckoutStatusRequest,
|
||||
@@ -41,6 +44,8 @@ import {
|
||||
mergeToBase,
|
||||
pullCurrentBranch,
|
||||
pushCurrentBranch,
|
||||
listCheckoutCommits,
|
||||
getCommitFileDiff,
|
||||
} from "../../../utils/checkout-git.js";
|
||||
import { execCommand } from "../../../utils/spawn.js";
|
||||
import { expandTilde } from "../../../utils/path.js";
|
||||
@@ -172,6 +177,44 @@ export class CheckoutSession {
|
||||
}
|
||||
}
|
||||
|
||||
async handleCommitsListRequest(msg: CheckoutCommitsListRequest): Promise<void> {
|
||||
const { cwd, requestId } = msg;
|
||||
|
||||
try {
|
||||
const { baseRef, commits } = await listCheckoutCommits({ cwd: expandTilde(cwd) });
|
||||
this.host.emit({
|
||||
type: "checkout.commits.list.response",
|
||||
payload: { cwd, baseRef, commits, error: null, requestId },
|
||||
});
|
||||
} catch (error) {
|
||||
this.host.emit({
|
||||
type: "checkout.commits.list.response",
|
||||
payload: { cwd, baseRef: null, commits: [], error: toCheckoutError(error), requestId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleCommitFileDiffRequest(msg: CheckoutCommitFileDiffRequest): Promise<void> {
|
||||
const { cwd, sha, path, requestId } = msg;
|
||||
|
||||
try {
|
||||
assertSafeGitRef(sha, "commit");
|
||||
if (path.length === 0 || isAbsolute(path) || path.split(/[\\/]/).includes("..")) {
|
||||
throw new Error(`Invalid path: ${path}`);
|
||||
}
|
||||
const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path });
|
||||
this.host.emit({
|
||||
type: "checkout.commits.file_diff.response",
|
||||
payload: { cwd, sha, path, file, error: null, requestId },
|
||||
});
|
||||
} catch (error) {
|
||||
this.host.emit({
|
||||
type: "checkout.commits.file_diff.response",
|
||||
payload: { cwd, sha, path, file: null, error: toCheckoutError(error), requestId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleValidateBranchRequest(msg: ValidateBranchRequest): Promise<void> {
|
||||
const { cwd, branchName, requestId } = msg;
|
||||
|
||||
|
||||
@@ -1262,6 +1262,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
workspaceGithubRepositorySearch: true,
|
||||
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
|
||||
projectCreateDirectory: true,
|
||||
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
|
||||
commitsList: true,
|
||||
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
|
||||
providerRemoval: true,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { execFileSync } from "child_process";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { getCommitFileDiff } from "./checkout-git.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = realpathSync.native(mkdtempSync(join(tmpdir(), "commit-file-diff-test-")));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function git(args: string[], cwd: string): string {
|
||||
return execFileSync("git", args, { cwd }).toString();
|
||||
}
|
||||
|
||||
function commitFile(repoDir: string, name: string, content: string, message: string): void {
|
||||
writeFileSync(join(repoDir, name), content);
|
||||
git(["add", "."], repoDir);
|
||||
git(["-c", "commit.gpgsign=false", "commit", "-m", message], repoDir);
|
||||
}
|
||||
|
||||
function initRepo(): string {
|
||||
const tempDir = makeTempDir();
|
||||
const repoDir = join(tempDir, "repo");
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
git(["init", "-b", "main"], repoDir);
|
||||
git(["config", "user.email", "test@test.com"], repoDir);
|
||||
git(["config", "user.name", "Test User"], repoDir);
|
||||
return repoDir;
|
||||
}
|
||||
|
||||
function headSha(repoDir: string): string {
|
||||
return git(["rev-parse", "HEAD"], repoDir).trim();
|
||||
}
|
||||
|
||||
describe("getCommitFileDiff", () => {
|
||||
it("returns the parsed diff for a modified file in a commit", async () => {
|
||||
const repoDir = initRepo();
|
||||
commitFile(repoDir, "foo.txt", "a\nb\nc\n", "initial");
|
||||
commitFile(repoDir, "foo.txt", "a\nB\nc\nd\n", "edit foo");
|
||||
const sha = headSha(repoDir);
|
||||
|
||||
const file = await getCommitFileDiff({ cwd: repoDir, sha, path: "foo.txt" });
|
||||
|
||||
expect(file).not.toBeNull();
|
||||
expect(file?.path).toBe("foo.txt");
|
||||
expect(file?.additions).toBe(2);
|
||||
expect(file?.deletions).toBe(1);
|
||||
expect(file?.hunks.length).toBeGreaterThan(0);
|
||||
|
||||
const lines = file?.hunks[0]?.lines ?? [];
|
||||
expect(lines.some((line) => line.type === "add" && line.content === "B")).toBe(true);
|
||||
expect(lines.some((line) => line.type === "add" && line.content === "d")).toBe(true);
|
||||
expect(lines.some((line) => line.type === "remove" && line.content === "b")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns a diff flagged as new for an added file", async () => {
|
||||
const repoDir = initRepo();
|
||||
commitFile(repoDir, "README.md", "base\n", "initial");
|
||||
commitFile(repoDir, "added.txt", "x\ny\n", "add file");
|
||||
const sha = headSha(repoDir);
|
||||
|
||||
const file = await getCommitFileDiff({ cwd: repoDir, sha, path: "added.txt" });
|
||||
|
||||
expect(file?.path).toBe("added.txt");
|
||||
expect(file?.isNew).toBe(true);
|
||||
expect(file?.additions).toBe(2);
|
||||
expect(file?.deletions).toBe(0);
|
||||
});
|
||||
|
||||
it("returns null for a path not changed in the commit", async () => {
|
||||
const repoDir = initRepo();
|
||||
commitFile(repoDir, "foo.txt", "a\n", "initial");
|
||||
commitFile(repoDir, "foo.txt", "a\nb\n", "edit foo");
|
||||
const sha = headSha(repoDir);
|
||||
|
||||
const file = await getCommitFileDiff({ cwd: repoDir, sha, path: "does-not-exist.txt" });
|
||||
|
||||
expect(file).toBeNull();
|
||||
});
|
||||
});
|
||||
138
packages/server/src/utils/checkout-git.commits.test.ts
Normal file
138
packages/server/src/utils/checkout-git.commits.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { execFileSync } from "child_process";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { listCheckoutCommits } from "./checkout-git.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-commits-test-")));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function git(args: string[], cwd?: string): void {
|
||||
execFileSync("git", args, cwd ? { cwd } : {});
|
||||
}
|
||||
|
||||
function commit(repoDir: string, message: string): void {
|
||||
git(["-c", "commit.gpgsign=false", "commit", "-m", message], repoDir);
|
||||
}
|
||||
|
||||
function commitFile(repoDir: string, name: string, content: string, message: string): void {
|
||||
writeFileSync(join(repoDir, name), content);
|
||||
git(["add", "."], repoDir);
|
||||
commit(repoDir, message);
|
||||
}
|
||||
|
||||
function initRepoOnMain(): { repoDir: string; tempDir: string } {
|
||||
const tempDir = makeTempDir();
|
||||
const repoDir = join(tempDir, "repo");
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
git(["init", "-b", "main"], repoDir);
|
||||
git(["config", "user.email", "test@test.com"], repoDir);
|
||||
git(["config", "user.name", "Test User"], repoDir);
|
||||
commitFile(repoDir, "README.md", "base\n", "initial");
|
||||
return { repoDir, tempDir };
|
||||
}
|
||||
|
||||
function addBareRemote(repoDir: string, tempDir: string): string {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
git(["init", "--bare", "-b", "main", remoteDir]);
|
||||
git(["remote", "add", "origin", remoteDir], repoDir);
|
||||
return remoteDir;
|
||||
}
|
||||
|
||||
describe("listCheckoutCommits", () => {
|
||||
it("lists commits ahead of base newest-first with on-remote flags and file stats", async () => {
|
||||
const { repoDir, tempDir } = initRepoOnMain();
|
||||
git(["checkout", "-b", "feature"], repoDir);
|
||||
commitFile(repoDir, "foo.txt", "a\nb\nc\n", "Add foo");
|
||||
|
||||
// Push feature (containing only commit A) to the remote, then add B locally.
|
||||
addBareRemote(repoDir, tempDir);
|
||||
git(["push", "-u", "origin", "feature"], repoDir);
|
||||
commitFile(repoDir, "bar.txt", "x\n", "Add bar");
|
||||
|
||||
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
|
||||
|
||||
expect(baseRef).toBe("main");
|
||||
expect(commits).toHaveLength(2);
|
||||
expect(commits[0]?.subject).toBe("Add bar");
|
||||
expect(commits[1]?.subject).toBe("Add foo");
|
||||
|
||||
expect(commits[0]?.isOnRemote).toBe(false);
|
||||
expect(commits[1]?.isOnRemote).toBe(true);
|
||||
|
||||
expect(commits[0]?.files).toEqual([
|
||||
{ path: "bar.txt", additions: 1, deletions: 0, status: "added" },
|
||||
]);
|
||||
expect(commits[1]?.files).toEqual([
|
||||
{ path: "foo.txt", additions: 3, deletions: 0, status: "added" },
|
||||
]);
|
||||
|
||||
expect(commits[0]?.authorName).toBe("Test User");
|
||||
expect(commits[0]?.sha).toHaveLength(40);
|
||||
expect((commits[0]?.shortSha.length ?? 0) > 0).toBe(true);
|
||||
expect(Number.isNaN(new Date(commits[0]?.authorDate ?? "").getTime())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns [] when there are no commits ahead of base", async () => {
|
||||
const { repoDir } = initRepoOnMain();
|
||||
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
|
||||
expect(baseRef).toBeNull();
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks all commits local-only when there is no remote", async () => {
|
||||
const { repoDir } = initRepoOnMain();
|
||||
git(["checkout", "-b", "feature"], repoDir);
|
||||
commitFile(repoDir, "foo.txt", "a\n", "Add foo");
|
||||
commitFile(repoDir, "bar.txt", "b\n", "Add bar");
|
||||
|
||||
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
|
||||
|
||||
expect(baseRef).toBe("main");
|
||||
expect(commits).toHaveLength(2);
|
||||
expect(commits.every((c) => c.isOnRemote === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("classifies renamed files with status renamed and correct destination path", async () => {
|
||||
const { repoDir } = initRepoOnMain();
|
||||
git(["checkout", "-b", "feature"], repoDir);
|
||||
commitFile(repoDir, "original.txt", "content\n", "Add original");
|
||||
git(["mv", "original.txt", "renamed.txt"], repoDir);
|
||||
commit(repoDir, "Rename file");
|
||||
|
||||
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
||||
|
||||
expect(commits[0]?.files).toEqual([
|
||||
{ path: "renamed.txt", additions: 0, deletions: 0, status: "renamed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives status for modified and deleted files", async () => {
|
||||
const { repoDir } = initRepoOnMain();
|
||||
git(["checkout", "-b", "feature"], repoDir);
|
||||
commitFile(repoDir, "README.md", "base\nmore\n", "Edit readme");
|
||||
git(["rm", "README.md"], repoDir);
|
||||
commit(repoDir, "Delete readme");
|
||||
|
||||
const { commits } = await listCheckoutCommits({ cwd: repoDir });
|
||||
|
||||
expect(commits[0]?.files).toEqual([
|
||||
{ path: "README.md", additions: 0, deletions: 2, status: "deleted" },
|
||||
]);
|
||||
expect(commits[1]?.files).toEqual([
|
||||
{ path: "README.md", additions: 1, deletions: 0, status: "modified" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { resolve, dirname, basename } from "path";
|
||||
import { existsSync, realpathSync } from "fs";
|
||||
import { open as openFile, readFile, stat as statFile } from "fs/promises";
|
||||
import { TTLCache } from "@isaacs/ttlcache";
|
||||
import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages";
|
||||
import type { Logger } from "pino";
|
||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
||||
@@ -1923,6 +1924,323 @@ export async function getCheckoutStatus(
|
||||
};
|
||||
}
|
||||
|
||||
// Cap on how many ahead-of-base commits we enumerate. Branches with more than
|
||||
// this many unmerged commits are truncated to the newest MAX_CHECKOUT_COMMITS.
|
||||
const MAX_CHECKOUT_COMMITS = 200;
|
||||
// Bytes git emits between fields/records. We split parsed output on these.
|
||||
const COMMIT_FIELD_SEPARATOR = "\x00";
|
||||
const COMMIT_RECORD_SEPARATOR = "\x1e";
|
||||
// Record-separated, NUL-field-separated so arbitrary subject text stays parseable.
|
||||
// `%x1e`/`%x00` are git placeholders (literal text in the arg, real bytes in the
|
||||
// output) — passing actual NUL bytes as a process arg is rejected by Node.
|
||||
const COMMIT_LOG_FORMAT = "%x1e%H%x00%h%x00%an%x00%aI%x00%s";
|
||||
|
||||
type CheckoutCommitFileStatus = NonNullable<CheckoutCommitFile["status"]>;
|
||||
|
||||
interface ParsedCheckoutCommit {
|
||||
sha: string;
|
||||
shortSha: string;
|
||||
authorName: string;
|
||||
authorDate: string;
|
||||
subject: string;
|
||||
files: CheckoutCommitFile[];
|
||||
}
|
||||
|
||||
function mapNameStatusLetter(letter: string): CheckoutCommitFileStatus | undefined {
|
||||
switch (letter) {
|
||||
case "A":
|
||||
return "added";
|
||||
case "C":
|
||||
return "added";
|
||||
case "M":
|
||||
return "modified";
|
||||
case "T":
|
||||
return "modified";
|
||||
case "D":
|
||||
return "deleted";
|
||||
case "R":
|
||||
return "renamed";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// A `--raw` line: `:<srcmode> <dstmode> <srcsha> <dstsha> <STATUS>\t<path>`
|
||||
// (rename/copy add a second path: `R100\t<old>\t<new>`). The status token is the
|
||||
// last space-separated field before the first tab. Keyed on the destination path.
|
||||
function parseRawStatusLine(line: string, statuses: Map<string, CheckoutCommitFileStatus>): void {
|
||||
const tabParts = line.split("\t");
|
||||
const meta = tabParts[0] ?? "";
|
||||
const statusToken = meta.slice(meta.lastIndexOf(" ") + 1);
|
||||
const letter = statusToken.charAt(0);
|
||||
const status = mapNameStatusLetter(letter);
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
const path =
|
||||
letter === "R" || letter === "C" ? (tabParts[tabParts.length - 1] ?? "") : (tabParts[1] ?? "");
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
statuses.set(path, status);
|
||||
}
|
||||
|
||||
// A `--numstat` line: `<adds>\t<dels>\t<path>` (renames use `old => new`, binary
|
||||
// files report `-` for both counts). Keyed on the (normalized) destination path.
|
||||
function parseNumstatLine(
|
||||
line: string,
|
||||
stats: Map<string, { additions: number; deletions: number }>,
|
||||
): void {
|
||||
const parts = line.split("\t");
|
||||
if (parts.length < 3) {
|
||||
return;
|
||||
}
|
||||
const additionsField = parts[0] ?? "";
|
||||
const deletionsField = parts[1] ?? "";
|
||||
const path = normalizeNumstatPath(parts.slice(2).join("\t"));
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
if (additionsField === "-" || deletionsField === "-") {
|
||||
stats.set(path, { additions: 0, deletions: 0 });
|
||||
return;
|
||||
}
|
||||
const additions = Number.parseInt(additionsField, 10);
|
||||
const deletions = Number.parseInt(deletionsField, 10);
|
||||
if (Number.isNaN(additions) || Number.isNaN(deletions)) {
|
||||
return;
|
||||
}
|
||||
stats.set(path, { additions, deletions });
|
||||
}
|
||||
|
||||
// Parses the single combined `git log ... --raw --numstat -M` stream. Each record
|
||||
// (split on the record separator) starts with the NUL-field-separated header line,
|
||||
// then a blank line, then the interleaved `--raw` (status) and `--numstat` (counts)
|
||||
// blocks. We merge both by destination path so each file carries counts + status.
|
||||
function parseCheckoutCommitRecords(stdout: string): ParsedCheckoutCommit[] {
|
||||
const records = stdout.split(COMMIT_RECORD_SEPARATOR).filter((record) => record.length > 0);
|
||||
const commits: ParsedCheckoutCommit[] = [];
|
||||
for (const record of records) {
|
||||
const lines = record.split("\n");
|
||||
const fields = (lines[0] ?? "").split(COMMIT_FIELD_SEPARATOR);
|
||||
if (fields.length < 5) {
|
||||
continue;
|
||||
}
|
||||
const sha = (fields[0] ?? "").trim();
|
||||
if (!sha) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stats = new Map<string, { additions: number; deletions: number }>();
|
||||
const statuses = new Map<string, CheckoutCommitFileStatus>();
|
||||
for (let index = 1; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? "";
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith(":")) {
|
||||
parseRawStatusLine(line, statuses);
|
||||
} else {
|
||||
parseNumstatLine(line, stats);
|
||||
}
|
||||
}
|
||||
|
||||
const files: CheckoutCommitFile[] = [];
|
||||
for (const [path, stat] of stats) {
|
||||
const status = statuses.get(path);
|
||||
files.push({
|
||||
path,
|
||||
additions: stat.additions,
|
||||
deletions: stat.deletions,
|
||||
...(status ? { status } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
commits.push({
|
||||
sha,
|
||||
shortSha: (fields[1] ?? "").trim(),
|
||||
authorName: fields[2] ?? "",
|
||||
authorDate: (fields[3] ?? "").trim(),
|
||||
subject: fields[4] ?? "",
|
||||
files,
|
||||
});
|
||||
}
|
||||
return commits;
|
||||
}
|
||||
|
||||
async function resolveCheckoutCommitUpstreamRef(
|
||||
cwd: string,
|
||||
currentBranch: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<string | null> {
|
||||
// Prefer the branch's configured `@{u}`. If it's configured but the
|
||||
// remote-tracking ref isn't present locally (e.g. configured upstream that was
|
||||
// never fetched), fall back to `origin/<branch>` when that ref does exist, so a
|
||||
// standard `origin` push is still recognized. Both missing => no remote.
|
||||
const configured = await getConfiguredUpstreamRef(cwd, currentBranch, context);
|
||||
if (configured && (await doesGitRefExist(cwd, `refs/remotes/${configured}`, context))) {
|
||||
return configured;
|
||||
}
|
||||
if (await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`, context)) {
|
||||
return `origin/${currentBranch}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Returns the set of SHAs on the current branch that are NOT on the upstream
|
||||
// (local-only/unpushed), or `null` when no upstream exists (no remote at all).
|
||||
async function getLocalOnlyCommitShas(
|
||||
cwd: string,
|
||||
currentBranch: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<Set<string> | null> {
|
||||
const upstreamRef = await resolveCheckoutCommitUpstreamRef(cwd, currentBranch, context);
|
||||
if (!upstreamRef) {
|
||||
return null;
|
||||
}
|
||||
const { stdout } = await runGitCommand(["rev-list", `${upstreamRef}..HEAD`], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
logger: context?.logger,
|
||||
});
|
||||
return new Set(
|
||||
stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the current branch's commits that are ahead of its base branch, newest
|
||||
* first, each flagged local-only vs on-remote with per-commit file +/- stats.
|
||||
*
|
||||
* Base ref is resolved exactly like {@link getCheckoutStatus}: the stored/default
|
||||
* base branch, mapped to the best comparison ref (origin/<base> when present,
|
||||
* else local <base>). Returns `[]` when the base cannot be resolved, the current
|
||||
* ref is the base itself, or there are no commits ahead.
|
||||
*/
|
||||
export interface CheckoutCommitsResult {
|
||||
baseRef: string | null;
|
||||
commits: CheckoutCommit[];
|
||||
}
|
||||
|
||||
export async function listCheckoutCommits({
|
||||
cwd,
|
||||
}: {
|
||||
cwd: string;
|
||||
}): Promise<CheckoutCommitsResult> {
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
if (!currentBranch) {
|
||||
return { baseRef: null, commits: [] };
|
||||
}
|
||||
|
||||
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd);
|
||||
if (!resolvedBaseRef) {
|
||||
return { baseRef: null, commits: [] };
|
||||
}
|
||||
|
||||
const normalizedBaseRef = normalizeLocalBranchRefName(resolvedBaseRef);
|
||||
if (!normalizedBaseRef || normalizedBaseRef === currentBranch) {
|
||||
return { baseRef: null, commits: [] };
|
||||
}
|
||||
|
||||
let comparisonBaseRef: string;
|
||||
try {
|
||||
comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef);
|
||||
} catch {
|
||||
// Base branch is not present locally or on origin — nothing to compare against.
|
||||
return { baseRef: null, commits: [] };
|
||||
}
|
||||
|
||||
// Single pass: `--raw` carries the status letter, `--numstat` the +/- counts.
|
||||
// (`--name-status` cannot be combined with `--numstat` — git emits only one.)
|
||||
const logResult = await runGitCommand(
|
||||
[
|
||||
"log",
|
||||
`${comparisonBaseRef}..HEAD`,
|
||||
"--no-merges",
|
||||
`--max-count=${MAX_CHECKOUT_COMMITS}`,
|
||||
`--format=${COMMIT_LOG_FORMAT}`,
|
||||
"--raw",
|
||||
"--numstat",
|
||||
"-M",
|
||||
],
|
||||
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
|
||||
const records = parseCheckoutCommitRecords(logResult.stdout);
|
||||
if (records.length === 0) {
|
||||
return { baseRef: comparisonBaseRef, commits: [] };
|
||||
}
|
||||
|
||||
const localOnlyShas = await getLocalOnlyCommitShas(cwd, currentBranch);
|
||||
|
||||
const commits = records.map((record) => ({
|
||||
sha: record.sha,
|
||||
shortSha: record.shortSha,
|
||||
subject: record.subject,
|
||||
authorName: record.authorName,
|
||||
authorDate: record.authorDate,
|
||||
isOnRemote: localOnlyShas === null ? false : !localOnlyShas.has(record.sha),
|
||||
files: record.files,
|
||||
}));
|
||||
|
||||
return { baseRef: comparisonBaseRef, commits };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the unified diff of a single file as introduced by one commit and
|
||||
* parses it into the same {@link ParsedDiffFile} shape the diff subscription
|
||||
* emits (so the client can reuse its existing renderer).
|
||||
*
|
||||
* Runs `git show <sha> --format= -- <path>` with the sha and path passed as
|
||||
* separate process args (never interpolated into a shell string), and `--format=`
|
||||
* suppresses the commit header so the output is a pure unified diff. The text is
|
||||
* parsed and highlighted by {@link parseAndHighlightDiff} — the exact parser the
|
||||
* diff subscription uses. Returns `null` when the file is absent from the commit
|
||||
* or the change is binary-only (no textual hunks). Throws on git failure (e.g. an
|
||||
* unknown sha), which the caller maps to a typed checkout error.
|
||||
*/
|
||||
export async function getCommitFileDiff({
|
||||
cwd,
|
||||
sha,
|
||||
path,
|
||||
}: {
|
||||
cwd: string;
|
||||
sha: string;
|
||||
path: string;
|
||||
}): Promise<ParsedDiffFile | null> {
|
||||
const { stdout } = await runGitCommand(["show", sha, "--format=", "--", path], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
|
||||
if (stdout.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedFiles = await parseAndHighlightDiff(stdout, cwd, {
|
||||
getOldFileContent: (file) => readGitFileContentAtRef(cwd, `${sha}^`, file.path),
|
||||
getNewFileContent: (file) => readGitFileContentAtRef(cwd, sha, file.path),
|
||||
});
|
||||
|
||||
// `--` scopes the diff to a single pathspec, so there is at most one real
|
||||
// entry. Pick by path to drop any stray header-only section the parser emits.
|
||||
const file = parsedFiles.find((candidate) => candidate.path === path) ?? null;
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Binary changes carry a "Binary files ... differ" marker and no hunks; there
|
||||
// is nothing textual to render, so report them as absent.
|
||||
if (file.hunks.length === 0 && /^Binary files .* differ$/m.test(stdout)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
export interface CheckoutShortstat {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
|
||||
Reference in New Issue
Block a user