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:
@@ -0,0 +1,68 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
|
||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||
import { dotStyles } from "./shared";
|
||||
|
||||
interface CommitRowProps {
|
||||
commit: CheckoutCommit;
|
||||
onCommitPress: (sha: string) => void;
|
||||
}
|
||||
|
||||
export const CommitRow = memo(function CommitRow({ commit, onCommitPress }: CommitRowProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
onCommitPress(commit.sha);
|
||||
}, [commit.sha, onCommitPress]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
testID={`commit-row-${commit.shortSha}`}
|
||||
onPress={handlePress}
|
||||
style={styles.row}
|
||||
>
|
||||
<View
|
||||
testID={commit.isOnRemote ? "commit-dot-remote" : "commit-dot-local"}
|
||||
style={commit.isOnRemote ? dotStyles.dotRemote : dotStyles.dotLocal}
|
||||
/>
|
||||
<Text style={styles.shortSha}>{commit.shortSha}</Text>
|
||||
<Text style={styles.subject} numberOfLines={1}>
|
||||
{commit.subject}
|
||||
</Text>
|
||||
<View style={styles.caret}>
|
||||
<ThemedChevron size={14} uniProps={chevronColorMapping} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
shortSha: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 0,
|
||||
},
|
||||
subject: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
caret: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
|
||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||
import { CommitRow } from "./commit-row";
|
||||
import { dotStyles } from "./shared";
|
||||
|
||||
interface CommitsSectionProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
onCommitPress: (sha: string) => void;
|
||||
}
|
||||
|
||||
const SKELETON_ROW_KEYS = ["commit-skeleton-1", "commit-skeleton-2", "commit-skeleton-3"];
|
||||
|
||||
function CommitsSectionSkeleton() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View
|
||||
accessible
|
||||
accessibilityLabel={t("workspace.git.diff.commits.loading")}
|
||||
style={styles.skeleton}
|
||||
testID="commits-section-skeleton"
|
||||
>
|
||||
{SKELETON_ROW_KEYS.map((key) => (
|
||||
<View key={key} style={styles.skeletonRow}>
|
||||
<View style={styles.skeletonDot} />
|
||||
<View style={styles.skeletonSha} />
|
||||
<View style={styles.skeletonSubject} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CommitsSectionContent({
|
||||
query,
|
||||
onCommitPress,
|
||||
}: {
|
||||
query: Exclude<CheckoutCommitsQueryResult, { status: "unsupported" }>;
|
||||
onCommitPress: (sha: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (query.status === "error") {
|
||||
return (
|
||||
<Text style={styles.errorRow} testID="commits-section-error">
|
||||
{t("workspace.git.diff.commits.loadError")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (query.status !== "loaded") {
|
||||
return <CommitsSectionSkeleton />;
|
||||
}
|
||||
if (query.data.commits.length === 0) {
|
||||
return (
|
||||
<Text style={styles.emptyRow} testID="commits-section-empty">
|
||||
{t("workspace.git.diff.commits.empty")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.list}>
|
||||
{query.data.commits.map((commit) => (
|
||||
<CommitRow key={commit.sha} commit={commit} onCommitPress={onCommitPress} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { preferences, updatePreferences } = useChangesPreferences();
|
||||
const collapsed = preferences.commitsCollapsed;
|
||||
const query = useCheckoutCommitsQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled: !collapsed,
|
||||
});
|
||||
|
||||
const handleToggleSection = useCallback(() => {
|
||||
void updatePreferences({ commitsCollapsed: !collapsed });
|
||||
}, [collapsed, updatePreferences]);
|
||||
|
||||
const headerChevronStyle = useMemo(
|
||||
() => [styles.headerChevron, !collapsed && styles.headerChevronExpanded],
|
||||
[collapsed],
|
||||
);
|
||||
|
||||
if (query.status === "unsupported") {
|
||||
return null;
|
||||
}
|
||||
const commitCount = query.status === "loaded" ? query.data.commits.length : null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
testID="commits-section-header"
|
||||
onPress={handleToggleSection}
|
||||
style={styles.header}
|
||||
>
|
||||
<View style={headerChevronStyle}>
|
||||
<ThemedChevron size={14} uniProps={chevronColorMapping} />
|
||||
</View>
|
||||
<Text style={styles.title}>{t("workspace.git.diff.commits.title")}</Text>
|
||||
{commitCount === null ? (
|
||||
<View style={styles.countSpacer} />
|
||||
) : (
|
||||
<Text
|
||||
style={styles.count}
|
||||
accessibilityLabel={t("workspace.git.diff.commits.countLabel", {
|
||||
count: commitCount,
|
||||
})}
|
||||
>
|
||||
{commitCount}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.legend}>
|
||||
<View style={dotStyles.dotLocal} />
|
||||
<Text style={styles.legendText}>{t("workspace.git.diff.commits.legendLocal")}</Text>
|
||||
<View style={dotStyles.legendDotRemote} />
|
||||
<Text style={styles.legendText}>{t("workspace.git.diff.commits.legendRemote")}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
{collapsed ? null : <CommitsSectionContent query={query} onCommitPress={onCommitPress} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
borderTopWidth: theme.borderWidth[1],
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
flexShrink: 0,
|
||||
},
|
||||
headerChevron: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
headerChevronExpanded: {
|
||||
transform: [{ rotate: "90deg" }],
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
count: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flex: 1,
|
||||
},
|
||||
countSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
legend: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
legendText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
list: {
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
emptyRow: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
errorRow: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.statusDanger,
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
skeleton: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
skeletonRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 20,
|
||||
},
|
||||
skeletonDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
skeletonSha: {
|
||||
width: 48,
|
||||
height: 10,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
skeletonSubject: {
|
||||
width: "55%",
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,51 @@
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type CheckoutCommitFile = CheckoutCommit["files"][number];
|
||||
|
||||
export type FilePressHandler = (commit: CheckoutCommit, file: CheckoutCommitFile) => void;
|
||||
|
||||
export const DOT_SIZE = 8;
|
||||
|
||||
// The "on remote" dot is intentionally understated: the local-only ring is the
|
||||
// state worth noticing (you still have work to push), so the remote fill is
|
||||
// dimmed toward the background rather than rendered at full-strength green.
|
||||
const REMOTE_DOT_OPACITY = 0.55;
|
||||
|
||||
/**
|
||||
* Shared local/remote commit dot styles used by both the commit rows and the
|
||||
* section legend so the two never drift.
|
||||
*
|
||||
* Semantics: a local-only commit is a hollow ring; a commit that has reached
|
||||
* the remote is a subtle (dimmed) filled green dot.
|
||||
*/
|
||||
export const dotStyles = StyleSheet.create((theme) => ({
|
||||
dotLocal: {
|
||||
width: DOT_SIZE,
|
||||
height: DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.foregroundMuted,
|
||||
flexShrink: 0,
|
||||
},
|
||||
dotRemote: {
|
||||
width: DOT_SIZE,
|
||||
height: DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
opacity: REMOTE_DOT_OPACITY,
|
||||
flexShrink: 0,
|
||||
},
|
||||
// Legend variant of the remote dot: same fill, with leading spacing so it
|
||||
// separates from the preceding "local" label in the section header legend.
|
||||
legendDotRemote: {
|
||||
width: DOT_SIZE,
|
||||
height: DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.statusSuccess,
|
||||
opacity: REMOTE_DOT_OPACITY,
|
||||
flexShrink: 0,
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
}));
|
||||
+511
-420
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,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
// Shared chevron used by collapsible git rows (changed-files tree, commits
|
||||
// section). Kept in one place so the muted-foreground tint and the
|
||||
// withUnistyles wrapping stay consistent across every disclosure control.
|
||||
export const ThemedChevron = withUnistyles(ChevronRight);
|
||||
|
||||
export const chevronColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCheckoutCommitsQueryResult, type CheckoutCommitsData } from "./use-commits-query";
|
||||
|
||||
const EMPTY_COMMITS: CheckoutCommitsData = { baseRef: "main", commits: [] };
|
||||
|
||||
describe("resolveCheckoutCommitsQueryResult", () => {
|
||||
it("stays idle while the collapsed section has never loaded", () => {
|
||||
expect(
|
||||
resolveCheckoutCommitsQueryResult({
|
||||
enabled: false,
|
||||
capabilityPresent: true,
|
||||
canFetch: true,
|
||||
data: undefined,
|
||||
isPlaceholderData: false,
|
||||
error: null,
|
||||
}),
|
||||
).toEqual({ status: "idle" });
|
||||
});
|
||||
|
||||
it("reports loading instead of an empty result while the first request is pending", () => {
|
||||
expect(
|
||||
resolveCheckoutCommitsQueryResult({
|
||||
enabled: true,
|
||||
capabilityPresent: true,
|
||||
canFetch: true,
|
||||
data: undefined,
|
||||
isPlaceholderData: false,
|
||||
error: null,
|
||||
}),
|
||||
).toEqual({ status: "loading" });
|
||||
});
|
||||
|
||||
it("types an empty commit list as loaded data", () => {
|
||||
expect(
|
||||
resolveCheckoutCommitsQueryResult({
|
||||
enabled: true,
|
||||
capabilityPresent: true,
|
||||
canFetch: true,
|
||||
data: EMPTY_COMMITS,
|
||||
isPlaceholderData: false,
|
||||
error: null,
|
||||
}),
|
||||
).toEqual({ status: "loaded", data: EMPTY_COMMITS });
|
||||
});
|
||||
|
||||
it("surfaces a cold-load error", () => {
|
||||
const error = new Error("git log failed");
|
||||
expect(
|
||||
resolveCheckoutCommitsQueryResult({
|
||||
enabled: true,
|
||||
capabilityPresent: true,
|
||||
canFetch: true,
|
||||
data: undefined,
|
||||
isPlaceholderData: false,
|
||||
error,
|
||||
}),
|
||||
).toEqual({ status: "error", error });
|
||||
});
|
||||
|
||||
it("keeps cached data available while collapsed", () => {
|
||||
expect(
|
||||
resolveCheckoutCommitsQueryResult({
|
||||
enabled: false,
|
||||
capabilityPresent: true,
|
||||
canFetch: true,
|
||||
data: EMPTY_COMMITS,
|
||||
isPlaceholderData: false,
|
||||
error: null,
|
||||
}),
|
||||
).toEqual({ status: "loaded", data: EMPTY_COMMITS });
|
||||
});
|
||||
|
||||
it("keeps previous-checkout placeholder data in loading state", () => {
|
||||
expect(
|
||||
resolveCheckoutCommitsQueryResult({
|
||||
enabled: true,
|
||||
capabilityPresent: true,
|
||||
canFetch: true,
|
||||
data: EMPTY_COMMITS,
|
||||
isPlaceholderData: true,
|
||||
error: null,
|
||||
}),
|
||||
).toEqual({ status: "loading" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user