From 737f30c339bbd80390e9ffe8d67ad6b7fe939d24 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 16 Jul 2026 22:15:04 +0200 Subject: [PATCH] Make commit history easier to scan and review (#2146) * feat(app): improve commit history and diff review * fix(app): keep commit timestamps current * fix(app): pause hidden commit clocks * fix(app): reopen commit diff in persistence test --- packages/app/e2e/commit-diff-panel.spec.ts | 72 ++++++++++++++++ .../src/git/commits-section/commit-row.tsx | 10 ++- .../git/commits-section/commits-section.tsx | 47 +++++++++-- packages/app/src/panels/commit-diff-panel.tsx | 83 ++++++++++++++++--- packages/app/src/utils/time.test.ts | 17 +++- packages/app/src/utils/time.ts | 3 +- 6 files changed, 210 insertions(+), 22 deletions(-) create mode 100644 packages/app/e2e/commit-diff-panel.spec.ts diff --git a/packages/app/e2e/commit-diff-panel.spec.ts b/packages/app/e2e/commit-diff-panel.spec.ts new file mode 100644 index 000000000..ca5540400 --- /dev/null +++ b/packages/app/e2e/commit-diff-panel.spec.ts @@ -0,0 +1,72 @@ +import { execFileSync } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { test, expect } from "./fixtures"; + +const COMMIT_SUBJECT = "Show commit timestamps"; + +test("commit history shows dates and shares diff layout preferences", async ({ + page, + withWorkspace, +}) => { + const workspace = await withWorkspace({ prefix: "commit-diff-panel-" }); + await createFeatureCommit(workspace.repoPath); + await page.setViewportSize({ width: 1400, height: 900 }); + await workspace.navigateTo(); + + await page.getByRole("button", { name: "Open explorer" }).click(); + const commitsSection = page.getByRole("button", { name: /Commits/i }); + await expect(commitsSection).toBeVisible({ timeout: 30_000 }); + await commitsSection.click(); + + const commitRow = page.locator('[data-testid^="commit-row-"]').filter({ + hasText: COMMIT_SUBJECT, + }); + await expect(commitRow).toContainText(COMMIT_SUBJECT, { timeout: 30_000 }); + await expect(commitRow).toContainText("Jan 15"); + await commitRow.click(); + + const panel = page.getByTestId("commit-diff-panel").filter({ visible: true }); + await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 }); + await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 }); + + await panel.getByTestId("commit-diff-layout-split").click(); + await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0); + await expect(panel.getByTestId("diff-file-0-body")).toBeVisible(); + + await page.getByTestId(/^workspace-commit-diff-close-/).click(); + await expect(panel).toHaveCount(0); + await commitRow.click(); + await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute( + "aria-selected", + "true", + { timeout: 30_000 }, + ); + + await page.setViewportSize({ width: 480, height: 900 }); + await expect(panel.getByTestId("commit-diff-toolbar")).toHaveCount(0); + await expect(panel.getByTestId("diff-code-row-0")).toBeVisible(); +}); + +async function createFeatureCommit(repoPath: string): Promise { + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" }); + await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n"); + execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", COMMIT_SUBJECT], { + cwd: repoPath, + stdio: "ignore", + env: { + ...process.env, + GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z", + GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z", + }, + }); +} diff --git a/packages/app/src/git/commits-section/commit-row.tsx b/packages/app/src/git/commits-section/commit-row.tsx index 4ea57941b..b5cdb9213 100644 --- a/packages/app/src/git/commits-section/commit-row.tsx +++ b/packages/app/src/git/commits-section/commit-row.tsx @@ -3,14 +3,16 @@ 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 { formatTimeAgo } from "@/utils/time"; import { dotStyles } from "./shared"; interface CommitRowProps { commit: CheckoutCommit; + now: Date; onCommitPress: (sha: string) => void; } -export const CommitRow = memo(function CommitRow({ commit, onCommitPress }: CommitRowProps) { +export const CommitRow = memo(function CommitRow({ commit, now, onCommitPress }: CommitRowProps) { const handlePress = useCallback(() => { onCommitPress(commit.sha); }, [commit.sha, onCommitPress]); @@ -30,6 +32,7 @@ export const CommitRow = memo(function CommitRow({ commit, onCommitPress }: Comm {commit.subject} + {formatTimeAgo(new Date(commit.authorDate), now)} @@ -58,6 +61,11 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, color: theme.colors.foreground, }, + timestamp: { + flexShrink: 0, + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, caret: { width: 16, height: 16, diff --git a/packages/app/src/git/commits-section/commits-section.tsx b/packages/app/src/git/commits-section/commits-section.tsx index 0c2760130..207e8da70 100644 --- a/packages/app/src/git/commits-section/commits-section.tsx +++ b/packages/app/src/git/commits-section/commits-section.tsx @@ -1,7 +1,8 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Pressable, Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query"; import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron"; @@ -30,6 +31,8 @@ function CommitsSectionSkeleton() { + + ))} @@ -38,9 +41,11 @@ function CommitsSectionSkeleton() { function CommitsSectionContent({ query, + now, onCommitPress, }: { query: Exclude; + now: Date; onCommitPress: (sha: string) => void; }) { const { t } = useTranslation(); @@ -64,7 +69,7 @@ function CommitsSectionContent({ return ( {query.data.commits.map((commit) => ( - + ))} ); @@ -73,7 +78,10 @@ function CommitsSectionContent({ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionProps) { const { t } = useTranslation(); const { preferences, updatePreferences } = useChangesPreferences(); + const isPanelActive = useRetainedPanelActive(); const collapsed = preferences.commitsCollapsed; + const [now, setNow] = useState(() => new Date()); + const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]); const query = useCheckoutCommitsQuery({ serverId, cwd, @@ -81,9 +89,20 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP }); const handleToggleSection = useCallback(() => { + if (collapsed) { + setNow(new Date()); + } void updatePreferences({ commitsCollapsed: !collapsed }); }, [collapsed, updatePreferences]); + useEffect(() => { + if (collapsed || !isPanelActive) { + return; + } + const interval = setInterval(() => setNow(new Date()), 10_000); + return () => clearInterval(interval); + }, [collapsed, isPanelActive]); + const headerChevronStyle = useMemo( () => [styles.headerChevron, !collapsed && styles.headerChevronExpanded], [collapsed], @@ -125,7 +144,9 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP {t("workspace.git.diff.commits.legendRemote")} - {collapsed ? null : } + {collapsed ? null : ( + + )} ); } @@ -195,14 +216,15 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[2], }, skeleton: { - paddingHorizontal: theme.spacing[2], - paddingBottom: theme.spacing[2], + paddingBottom: theme.spacing[1], gap: theme.spacing[2], }, skeletonRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], minHeight: 20, }, skeletonDot: { @@ -218,9 +240,22 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, skeletonSubject: { - width: "55%", + flex: 1, + minWidth: 0, height: 12, borderRadius: theme.borderRadius.sm, backgroundColor: theme.colors.surface2, }, + skeletonTimestamp: { + width: 40, + height: 10, + borderRadius: theme.borderRadius.sm, + backgroundColor: theme.colors.surface2, + flexShrink: 0, + }, + skeletonCaret: { + width: 16, + height: 16, + flexShrink: 0, + }, })); diff --git a/packages/app/src/panels/commit-diff-panel.tsx b/packages/app/src/panels/commit-diff-panel.tsx index 9410a249f..d34057e4d 100644 --- a/packages/app/src/panels/commit-diff-panel.tsx +++ b/packages/app/src/panels/commit-diff-panel.tsx @@ -1,10 +1,11 @@ -import { useMemo } from "react"; +import { useCallback, useMemo, type ReactNode } 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 { SegmentedControl } from "@/components/ui/segmented-control"; +import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { useAppSettings } from "@/hooks/use-settings"; @@ -22,7 +23,7 @@ function CommitDiffPanel() { const { serverId, workspaceId, target } = usePaneContext(); const cwd = useWorkspaceDirectory(serverId, workspaceId); const { settings } = useAppSettings(); - const { preferences } = useChangesPreferences(); + const { preferences, updatePreferences } = useChangesPreferences(); const isCompact = useIsCompactFormFactor(); invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target"); @@ -32,7 +33,29 @@ function CommitDiffPanel() { sha: target.sha, enabled: Boolean(cwd), }); - const effectiveLayout = isWeb && !isCompact ? preferences.layout : "unified"; + const canUseSplitLayout = isWeb && !isCompact; + const effectiveLayout = canUseSplitLayout ? preferences.layout : "unified"; + const layoutOptions = useMemo( + () => [ + { + value: "unified" as const, + label: t("workspace.git.diff.unified"), + testID: "commit-diff-layout-unified", + }, + { + value: "split" as const, + label: t("workspace.git.diff.split"), + testID: "commit-diff-layout-split", + }, + ], + [t], + ); + const handleLayoutChange = useCallback( + (layout: "unified" | "split") => { + void updatePreferences({ layout }); + }, + [updatePreferences], + ); const displayPreferences = useMemo( () => ({ layout: effectiveLayout, @@ -59,29 +82,47 @@ function CommitDiffPanel() { ); } + let bodyContent: ReactNode; if (error) { - return ( + bodyContent = ( {t("panels.diff.loadError")} ); - } - if (isLoading && files.length === 0) { - return ( + } else if (isLoading && files.length === 0) { + bodyContent = ( {t("workspace.tabs.loading")} ); - } - if (files.length === 0) { - return ( + } else if (files.length === 0) { + bodyContent = ( {t("panels.diff.empty")} ); + } else { + bodyContent = ( + + ); } - return ; + return ( + + {canUseSplitLayout ? ( + + + + ) : null} + {bodyContent} + + ); } function useCommitDiffPanelDescriptor( @@ -104,6 +145,24 @@ export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = { }; const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + minHeight: 0, + }, + toolbar: { + height: WORKSPACE_SECONDARY_HEADER_HEIGHT, + flexDirection: "row", + alignItems: "center", + justifyContent: "flex-end", + paddingHorizontal: theme.spacing[3], + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + flexShrink: 0, + }, + body: { + flex: 1, + minHeight: 0, + }, centerState: { flex: 1, alignItems: "center", diff --git a/packages/app/src/utils/time.test.ts b/packages/app/src/utils/time.test.ts index f72900a1c..b28fad300 100644 --- a/packages/app/src/utils/time.test.ts +++ b/packages/app/src/utils/time.test.ts @@ -1,5 +1,20 @@ import { describe, it, expect } from "vitest"; -import { formatDuration, formatMessageTimestamp } from "./time"; +import { formatDuration, formatMessageTimestamp, formatTimeAgo } from "./time"; + +describe("formatTimeAgo", () => { + const now = new Date("2026-07-16T12:00:00.000Z"); + + it.each([ + ["2026-07-16T11:59:55.000Z", "just now"], + ["2026-07-16T11:59:30.000Z", "30s ago"], + ["2026-07-16T11:55:00.000Z", "5m ago"], + ["2026-07-16T10:00:00.000Z", "2h ago"], + ["2026-07-13T12:00:00.000Z", "3d ago"], + ["2026-01-15T12:00:00.000Z", "Jan 15"], + ])("formats %s as %s", (date, expected) => { + expect(formatTimeAgo(new Date(date), now)).toBe(expected); + }); +}); describe("formatDuration", () => { it("renders sub-minute durations as whole seconds", () => { diff --git a/packages/app/src/utils/time.ts b/packages/app/src/utils/time.ts index cac9a884c..2f9b5557b 100644 --- a/packages/app/src/utils/time.ts +++ b/packages/app/src/utils/time.ts @@ -2,8 +2,7 @@ * Format a date as a human-friendly relative time string * Examples: "just now", "5m ago", "2h ago", "3d ago", "Jan 15" */ -export function formatTimeAgo(date: Date): string { - const now = new Date(); +export function formatTimeAgo(date: Date, now: Date = new Date()): string { const diffMs = now.getTime() - date.getTime(); const diffSec = Math.floor(diffMs / 1000); const diffMin = Math.floor(diffSec / 60);