Show workspace commits clearly in Changes (#2350)
* feat(commits): distinguish workspace history from base commits Keep every workspace commit visible while bounding base history to ten context commits, and make push and base state readable in the commit rail. * fix(commits): preserve history when base refs disappear Fall back to recent HEAD history for stale saved bases, and reject truncated git output instead of presenting an incomplete commit list. * fix(commits): classify history against the current base Use the furthest-ahead local or remote base for classification, and re-infer base history when saved worktree metadata points to a deleted branch.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query";
|
||||
|
||||
interface CommitGraphNodeProps {
|
||||
commit: ClassifiedCheckoutCommit;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
export function CommitGraphNode({ commit, isFirst, isLast }: CommitGraphNodeProps) {
|
||||
const isOnBase = commit.isOnBase;
|
||||
const railColor = isOnBase ? styles.railBase : styles.railWorkspace;
|
||||
const markerColor = isOnBase ? styles.markerBase : styles.markerWorkspace;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{isFirst && isLast ? null : (
|
||||
<View
|
||||
style={[styles.rail, railColor, isFirst && styles.railFirst, isLast && styles.railLast]}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
testID={commit.isOnRemote ? "commit-dot-remote" : "commit-dot-local"}
|
||||
style={[styles.marker, markerColor, !commit.isOnRemote && styles.markerRing]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const MARKER_SIZE = 8;
|
||||
const RAIL_WIDTH = 2;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
width: MARKER_SIZE,
|
||||
alignSelf: "stretch",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
},
|
||||
rail: {
|
||||
position: "absolute",
|
||||
top: -theme.spacing[1] - 1,
|
||||
bottom: -theme.spacing[1] - 1,
|
||||
width: RAIL_WIDTH,
|
||||
},
|
||||
railFirst: {
|
||||
top: "50%",
|
||||
},
|
||||
railLast: {
|
||||
bottom: "50%",
|
||||
},
|
||||
railBase: {
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
railWorkspace: {
|
||||
backgroundColor: theme.colors.accent,
|
||||
},
|
||||
marker: {
|
||||
width: MARKER_SIZE,
|
||||
height: MARKER_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: theme.borderWidth[2],
|
||||
zIndex: 1,
|
||||
},
|
||||
markerBase: {
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
borderColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
markerWorkspace: {
|
||||
backgroundColor: theme.colors.accent,
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
markerRing: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
}));
|
||||
@@ -1,18 +1,33 @@
|
||||
import { memo, useCallback } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
|
||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||
import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { dotStyles } from "./shared";
|
||||
import { CommitGraphNode } from "./commit-graph-node";
|
||||
|
||||
interface CommitRowProps {
|
||||
commit: CheckoutCommit;
|
||||
commit: ClassifiedCheckoutCommit;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
now: Date;
|
||||
onCommitPress: (sha: string) => void;
|
||||
}
|
||||
|
||||
export const CommitRow = memo(function CommitRow({ commit, now, onCommitPress }: CommitRowProps) {
|
||||
function commitRowPressableStyle({
|
||||
hovered,
|
||||
pressed,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.row, (Boolean(hovered) || pressed) && styles.rowActive];
|
||||
}
|
||||
|
||||
export const CommitRow = memo(function CommitRow({
|
||||
commit,
|
||||
isFirst,
|
||||
isLast,
|
||||
now,
|
||||
onCommitPress,
|
||||
}: CommitRowProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
onCommitPress(commit.sha);
|
||||
}, [commit.sha, onCommitPress]);
|
||||
@@ -22,16 +37,17 @@ export const CommitRow = memo(function CommitRow({ commit, now, onCommitPress }:
|
||||
accessibilityRole="button"
|
||||
testID={`commit-row-${commit.shortSha}`}
|
||||
onPress={handlePress}
|
||||
style={styles.row}
|
||||
style={commitRowPressableStyle}
|
||||
>
|
||||
<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>
|
||||
<CommitGraphNode commit={commit} isFirst={isFirst} isLast={isLast} />
|
||||
<View style={styles.commitDetails}>
|
||||
<Text style={styles.shortSha} numberOfLines={1}>
|
||||
{commit.shortSha}
|
||||
</Text>
|
||||
<Text style={styles.subject} numberOfLines={1}>
|
||||
{commit.subject}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.timestamp}>{formatTimeAgo(new Date(commit.authorDate), now)}</Text>
|
||||
<View style={styles.caret}>
|
||||
<ThemedChevron size={14} uniProps={chevronColorMapping} />
|
||||
@@ -49,10 +65,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingRight: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
rowActive: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
commitDetails: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
shortSha: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
color: theme.colors.foregroundMuted,
|
||||
width: theme.spacing[16],
|
||||
flexShrink: 0,
|
||||
},
|
||||
subject: {
|
||||
|
||||
@@ -67,8 +67,15 @@ function CommitsSectionContent({
|
||||
}
|
||||
return (
|
||||
<View style={styles.list}>
|
||||
{query.data.commits.map((commit) => (
|
||||
<CommitRow key={commit.sha} commit={commit} now={now} onCommitPress={onCommitPress} />
|
||||
{query.data.commits.map((commit, index) => (
|
||||
<CommitRow
|
||||
key={commit.sha}
|
||||
commit={commit}
|
||||
isFirst={index === 0}
|
||||
isLast={index === query.data.commits.length - 1}
|
||||
now={now}
|
||||
onCommitPress={onCommitPress}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
@@ -110,7 +117,10 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
|
||||
if (query.status === "unsupported") {
|
||||
return null;
|
||||
}
|
||||
const commitCount = query.status === "loaded" ? query.data.commits.length : null;
|
||||
const commitCount =
|
||||
query.status === "loaded"
|
||||
? query.data.commits.filter((commit) => !commit.isOnBase).length
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
}));
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
|
||||
import invariant from "tiny-invariant";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { checkoutCommitsQueryKey } from "@/git/query-keys";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
@@ -16,7 +17,11 @@ interface UseCheckoutCommitsQueryOptions {
|
||||
|
||||
export interface CheckoutCommitsData {
|
||||
baseRef: string | null;
|
||||
commits: CheckoutCommit[];
|
||||
commits: ClassifiedCheckoutCommit[];
|
||||
}
|
||||
|
||||
export interface ClassifiedCheckoutCommit extends CheckoutCommit {
|
||||
isOnBase: boolean;
|
||||
}
|
||||
|
||||
export type CheckoutCommitsQueryResult =
|
||||
@@ -69,10 +74,13 @@ export function useCheckoutCommitsQuery({
|
||||
}: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
|
||||
// COMPAT(commitsList): added in v0.1.110, remove after 2027-01-16.
|
||||
// COMPAT(commitBaseClassification): added in v0.2.0, remove after 2027-01-23.
|
||||
// Single capability-detection site; downstream reads a clean load-state union.
|
||||
const capabilityPresent = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.commitsList === true,
|
||||
(state) =>
|
||||
state.sessions[serverId]?.serverInfo?.features?.commitsList === true &&
|
||||
state.sessions[serverId]?.serverInfo?.features?.commitBaseClassification === true,
|
||||
);
|
||||
|
||||
const canFetch = Boolean(cwd) && Boolean(client) && isConnected;
|
||||
@@ -84,7 +92,12 @@ export function useCheckoutCommitsQuery({
|
||||
if (!client) {
|
||||
throw new Error("Host disconnected");
|
||||
}
|
||||
return client.listCheckoutCommits(cwd);
|
||||
const data = await client.listCheckoutCommits(cwd);
|
||||
const commits = data.commits.map((commit) => {
|
||||
invariant(commit.isOnBase !== undefined, "Host omitted commit base classification");
|
||||
return { ...commit, isOnBase: commit.isOnBase };
|
||||
});
|
||||
return { baseRef: data.baseRef, commits };
|
||||
},
|
||||
enabled: queryEnabled,
|
||||
staleTimeMs: CHECKOUT_COMMITS_STALE_TIME,
|
||||
|
||||
Reference in New Issue
Block a user