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:
Mohamed Boudra
2026-07-23 12:17:48 +02:00
committed by GitHub
parent 31c8dc3f05
commit 8cf70d10bf
19 changed files with 372 additions and 110 deletions
@@ -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,
},
}));
+17 -4
View File
@@ -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,
+1 -1
View File
@@ -795,7 +795,7 @@ export const ar: TranslationResources = {
deletedFile: "تم الحذف",
commits: {
title: "الإيداعات",
countLabel: "{{count}} من الإيداعات الأخيرة",
countLabel: "{{count}} من إيداعات مساحة العمل",
fileDiffEmpty: "لا توجد تغييرات لعرضها",
fileDiffError: "تعذّر تحميل فروق الملف",
loading: "جارٍ تحميل الإيداعات…",
+1 -1
View File
@@ -805,7 +805,7 @@ export const en = {
deletedFile: "Deleted",
commits: {
title: "Commits",
countLabel: "{{count}} recent commits",
countLabel: "{{count}} workspace commits",
fileDiffEmpty: "No changes to display",
fileDiffError: "Failed to load file diff",
loading: "Loading commits…",
+1 -1
View File
@@ -826,7 +826,7 @@ export const es: TranslationResources = {
deletedFile: "Eliminado",
commits: {
title: "Commits",
countLabel: "{{count}} commits recientes",
countLabel: "{{count}} commits del espacio de trabajo",
fileDiffEmpty: "No hay cambios para mostrar",
fileDiffError: "Error al cargar el diff del archivo",
loading: "Cargando commits…",
+1 -1
View File
@@ -825,7 +825,7 @@ export const fr: TranslationResources = {
deletedFile: "Supprimé",
commits: {
title: "Commits",
countLabel: "{{count}} commits récents",
countLabel: "{{count}} commits de l’espace de travail",
fileDiffEmpty: "Aucune modification à afficher",
fileDiffError: "Échec du chargement du diff du fichier",
loading: "Chargement des commits…",
+1 -1
View File
@@ -806,7 +806,7 @@ export const ja: TranslationResources = {
deletedFile: "削除済み",
commits: {
title: "コミット",
countLabel: "最近のコミット数: {{count}}",
countLabel: "ワークスペースのコミット数: {{count}}",
fileDiffEmpty: "表示する変更はありません",
fileDiffError: "ファイル差分の読み込みに失敗しました",
loading: "コミットを読み込み中…",
+1 -1
View File
@@ -817,7 +817,7 @@ export const ptBR: TranslationResources = {
deletedFile: "Excluído",
commits: {
title: "Commits",
countLabel: "{{count}} commits recentes",
countLabel: "{{count}} commits do espaço de trabalho",
fileDiffEmpty: "Nenhuma alteração para exibir",
fileDiffError: "Falha ao carregar diff do arquivo",
loading: "Carregando commits…",
+1 -1
View File
@@ -817,7 +817,7 @@ export const ru: TranslationResources = {
deletedFile: "Удалено",
commits: {
title: "Коммиты",
countLabel: "{{count}} последних коммитов",
countLabel: "{{count}} коммитов рабочего пространства",
fileDiffEmpty: "Нет изменений для отображения",
fileDiffError: "Не удалось загрузить различия файла",
loading: "Загрузка коммитов…",
+1 -1
View File
@@ -787,7 +787,7 @@ export const zhCN: TranslationResources = {
deletedFile: "已删除",
commits: {
title: "提交",
countLabel: "最近 {{count}} 个提交",
countLabel: "{{count}} 个工作区提交",
fileDiffEmpty: "没有可显示的更改",
fileDiffError: "加载文件差异失败",
loading: "正在加载提交…",