Git commit history (#1534)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(app): collapse commits section by default

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Reshape commit diffs around the existing Changes view

* Fix diff tab migration complexity after rebase

* Address diff tab review findings

* Clean up diff tab review interfaces

* Collapse commit diffs into the existing view

* Load commits when expanded

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
adradr
2026-07-16 17:36:41 +02:00
committed by GitHub
co-authored by Mohamed Boudra
parent 5da6548aff
commit 0d3b717cf3
44 changed files with 2843 additions and 439 deletions
@@ -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],
},
}));