Files
paseo/packages/app/src/components/git-diff-pane.tsx

1885 lines
59 KiB
TypeScript

import {
useState,
useCallback,
useEffect,
useMemo,
useRef,
memo,
type ReactElement,
} from "react";
import { useRouter } from "expo-router";
import {
View,
Text,
ActivityIndicator,
Pressable,
FlatList,
Platform,
type LayoutChangeEvent,
type NativeSyntheticEvent,
type NativeScrollEvent,
TextStyle,
} from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
AlignJustify,
Archive,
ChevronDown,
Columns2,
GitBranch,
GitCommitHorizontal,
GitMerge,
ListChevronsDownUp,
ListChevronsUpDown,
Pilcrow,
RefreshCcw,
Upload,
WrapText,
} from "lucide-react-native";
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
import {
useCheckoutDiffQuery,
type ParsedDiffFile,
type DiffLine,
type HighlightToken,
} from "@/hooks/use-checkout-diff-query";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { DiffScroll } from "./diff-scroll";
import {
darkHighlightColors,
lightHighlightColors,
type HighlightStyle as HighlightStyleKey,
} from "@getpaseo/highlight";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
import { buildSplitDiffRows, type SplitDiffDisplayLine, type SplitDiffRow } from "@/utils/diff-layout";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { GitHubIcon } from "@/components/icons/github-icon";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
function openURLInNewTab(url: string): void {
void openExternalUrl(url);
}
type HighlightStyle = NonNullable<HighlightToken["style"]>;
interface HighlightedTextProps {
tokens: HighlightToken[];
wrapLines?: boolean;
}
type WrappedWebTextStyle = TextStyle & {
whiteSpace?: "pre" | "pre-wrap";
overflowWrap?: "normal" | "anywhere";
};
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
if (Platform.OS !== "web") {
return undefined;
}
return wrapLines
? { whiteSpace: "pre-wrap", overflowWrap: "anywhere" }
: { whiteSpace: "pre", overflowWrap: "normal" };
}
function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
const { theme } = useUnistyles();
const isDark = theme.colorScheme === "dark";
const lineHeight = theme.lineHeight.diff;
const getTokenColor = (style: HighlightStyle | null): string => {
const baseColor = isDark ? "#c9d1d9" : "#24292f";
if (!style) return baseColor;
const colors = isDark ? darkHighlightColors : lightHighlightColors;
return colors[style as HighlightStyleKey] ?? baseColor;
};
return (
<Text
style={[
styles.diffLineText,
{ lineHeight, ...getWrappedTextStyle(wrapLines) },
]}
>
{tokens.map((token, index) => (
<Text key={index} style={{ color: getTokenColor(token.style), lineHeight }}>
{token.text}
</Text>
))}
</Text>
);
}
interface DiffFileSectionProps {
file: ParsedDiffFile;
isExpanded: boolean;
onToggle: (path: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void;
testID?: string;
}
function lineTypeBackground(type: DiffLine["type"] | undefined | null) {
if (!type) return styles.emptySplitCell;
if (type === "add") return styles.addLineContainer;
if (type === "remove") return styles.removeLineContainer;
if (type === "header") return styles.headerLineContainer;
return styles.contextLineContainer;
}
function DiffGutterCell({
lineNumber,
type,
gutterWidth,
}: {
lineNumber: number | null;
type: DiffLine["type"] | undefined | null;
gutterWidth: number;
}) {
return (
<View style={[styles.gutterCell, lineTypeBackground(type), { width: gutterWidth }]}>
<Text
style={[
styles.lineNumberText,
type === "add" && styles.addLineNumberText,
type === "remove" && styles.removeLineNumberText,
]}
>
{lineNumber != null ? String(lineNumber) : ""}
</Text>
</View>
);
}
function DiffTextLine({
line,
wrapLines,
}: {
line: DiffLine;
wrapLines: boolean;
}) {
return (
<View style={[styles.textLineContainer, lineTypeBackground(line.type)]}>
{line.tokens && line.type !== "header" ? (
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
) : (
<Text
style={[
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line.type === "add" && styles.addLineText,
line.type === "remove" && styles.removeLineText,
line.type === "header" && styles.headerLineText,
line.type === "context" && styles.contextLineText,
]}
>
{line.content || " "}
</Text>
)}
</View>
);
}
function SplitTextLine({
line,
wrapLines,
}: {
line: SplitDiffDisplayLine | null;
wrapLines: boolean;
}) {
return (
<View style={[styles.textLineContainer, lineTypeBackground(line?.type)]}>
{line?.tokens ? (
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
) : (
<Text
style={[
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line?.type === "add" && styles.addLineText,
line?.type === "remove" && styles.removeLineText,
line?.type === "context" && styles.contextLineText,
!line && styles.emptySplitCellText,
]}
>
{line?.content ?? ""}
</Text>
)}
</View>
);
}
function DiffLineView({
line,
lineNumber,
gutterWidth,
wrapLines,
}: {
line: DiffLine;
lineNumber: number | null;
gutterWidth: number;
wrapLines: boolean;
}) {
return (
<View
style={[
styles.diffLineContainer,
lineTypeBackground(line.type),
]}
>
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
<Text
style={[
styles.lineNumberText,
line.type === "add" && styles.addLineNumberText,
line.type === "remove" && styles.removeLineNumberText,
]}
>
{lineNumber != null ? String(lineNumber) : ""}
</Text>
</View>
{line.tokens && line.type !== "header" ? (
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
) : (
<Text
style={[
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line.type === "add" && styles.addLineText,
line.type === "remove" && styles.removeLineText,
line.type === "header" && styles.headerLineText,
line.type === "context" && styles.contextLineText,
]}
>
{line.content || " "}
</Text>
)}
</View>
);
}
function SplitDiffLine({
line,
gutterWidth,
wrapLines,
}: {
line: SplitDiffDisplayLine | null;
gutterWidth: number;
wrapLines: boolean;
}) {
return (
<View
style={[
styles.diffLineContainer,
lineTypeBackground(line?.type),
]}
>
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
<Text
style={[
styles.lineNumberText,
line?.type === "add" && styles.addLineNumberText,
line?.type === "remove" && styles.removeLineNumberText,
]}
>
{line?.lineNumber != null ? String(line.lineNumber) : ""}
</Text>
</View>
{line?.tokens ? (
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
) : (
<Text
style={[
styles.diffLineText,
getWrappedTextStyle(wrapLines),
line?.type === "add" && styles.addLineText,
line?.type === "remove" && styles.removeLineText,
line?.type === "context" && styles.contextLineText,
!line && styles.emptySplitCellText,
]}
>
{line?.content ?? ""}
</Text>
)}
</View>
);
}
function SplitDiffColumn({
rows,
side,
gutterWidth,
wrapLines,
showDivider = false,
}: {
rows: SplitDiffRow[];
side: "left" | "right";
gutterWidth: number;
wrapLines: boolean;
showDivider?: boolean;
}) {
const [scrollWidth, setScrollWidth] = useState(0);
if (wrapLines) {
return (
<View style={[styles.splitCell, showDivider && styles.splitCellWithDivider]}>
<View style={styles.linesContainer}>
{rows.map((row, i) => {
if (row.kind === "header") {
return (
<View key={`header-${i}`} style={styles.splitHeaderRow}>
<Text style={[styles.diffLineText, styles.headerLineText]}>{row.content}</Text>
</View>
);
}
return (
<SplitDiffLine
key={`line-${i}`}
line={side === "left" ? row.left : row.right}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
/>
);
})}
</View>
</View>
);
}
return (
<View style={[styles.splitCell, showDivider && styles.splitCellWithDivider, styles.splitCellRow]}>
<View style={styles.gutterColumn}>
{rows.map((row, i) => {
if (row.kind === "header") {
return <DiffGutterCell key={`g-${i}`} lineNumber={null} type="header" gutterWidth={gutterWidth} />;
}
const line = side === "left" ? row.left : row.right;
return <DiffGutterCell key={`g-${i}`} lineNumber={line?.lineNumber ?? null} type={line?.type} gutterWidth={gutterWidth} />;
})}
</View>
<DiffScroll
scrollViewWidth={scrollWidth}
onScrollViewWidthChange={setScrollWidth}
style={styles.splitColumnScroll}
contentContainerStyle={styles.diffContentInner}
>
<View style={[styles.linesContainer, scrollWidth > 0 && { minWidth: scrollWidth }]}>
{rows.map((row, i) => {
if (row.kind === "header") {
return (
<View key={`t-${i}`} style={styles.splitHeaderRow}>
<Text style={[styles.diffLineText, styles.headerLineText]}>{row.content}</Text>
</View>
);
}
return <SplitTextLine key={`t-${i}`} line={side === "left" ? row.left : row.right} wrapLines={false} />;
})}
</View>
</DiffScroll>
</View>
);
}
const DiffFileHeader = memo(function DiffFileHeader({
file,
isExpanded,
onToggle,
onHeaderHeightChange,
testID,
}: DiffFileSectionProps) {
const layoutYRef = useRef<number | null>(null);
const pressHandledRef = useRef(false);
const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null);
const toggleExpanded = useCallback(() => {
pressHandledRef.current = true;
onToggle(file.path);
}, [file.path, onToggle]);
return (
<View
style={[styles.fileSectionHeaderContainer, isExpanded && styles.fileSectionHeaderExpanded]}
onLayout={(event) => {
layoutYRef.current = event.nativeEvent.layout.y;
onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID}
>
<Pressable
testID={testID ? `${testID}-toggle` : undefined}
style={({ pressed }) => [styles.fileHeader, pressed && styles.fileHeaderPressed]}
// Android: prevent parent pan/scroll gestures from canceling the tap release.
cancelable={false}
onPressIn={(event) => {
pressHandledRef.current = false;
pressInRef.current = {
ts: Date.now(),
pageX: event.nativeEvent.pageX,
pageY: event.nativeEvent.pageY,
};
}}
onPressOut={(event) => {
if (
Platform.OS !== "web" &&
!pressHandledRef.current &&
layoutYRef.current === 0 &&
pressInRef.current
) {
const durationMs = Date.now() - pressInRef.current.ts;
const dx = event.nativeEvent.pageX - pressInRef.current.pageX;
const dy = event.nativeEvent.pageY - pressInRef.current.pageY;
const distance = Math.hypot(dx, dy);
// Sticky headers on Android can emit pressIn/pressOut without onPress.
// Treat short, low-movement interactions as taps.
if (durationMs <= 500 && distance <= 12) {
toggleExpanded();
}
}
}}
onPress={toggleExpanded}
>
<View style={styles.fileHeaderLeft}>
<Text style={styles.fileName}>{file.path.split("/").pop()}</Text>
<Text style={styles.fileDir} numberOfLines={1}>
{file.path.includes("/") ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}` : ""}
</Text>
{file.isNew && (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>New</Text>
</View>
)}
{file.isDeleted && (
<View style={styles.deletedBadge}>
<Text style={styles.deletedBadgeText}>Deleted</Text>
</View>
)}
</View>
<View style={styles.fileHeaderRight}>
<Text style={styles.additions}>+{file.additions}</Text>
<Text style={styles.deletions}>-{file.deletions}</Text>
</View>
</Pressable>
</View>
);
});
function DiffFileBody({
file,
layout,
wrapLines,
onBodyHeightChange,
testID,
}: {
file: ParsedDiffFile;
layout: "unified" | "split";
wrapLines: boolean;
onBodyHeightChange?: (path: string, height: number) => void;
testID?: string;
}) {
const [scrollViewWidth, setScrollViewWidth] = useState(0);
const [bodyWidth, setBodyWidth] = useState(0);
return (
<View
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
onLayout={(event) => {
setBodyWidth(event.nativeEvent.layout.width);
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID}
>
{(() => {
if (file.status === "too_large" || file.status === "binary") {
return (
<View style={styles.statusMessageContainer}>
<Text style={styles.statusMessageText}>
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
</Text>
</View>
);
}
let maxLineNo = 0;
for (const hunk of file.hunks) {
maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount);
}
const gutterWidth = lineNumberGutterWidth(maxLineNo);
if (layout === "split") {
const rows = buildSplitDiffRows(file);
return (
<View style={[styles.diffContent, styles.splitRow]}>
<SplitDiffColumn rows={rows} side="left" gutterWidth={gutterWidth} wrapLines={wrapLines} />
<SplitDiffColumn rows={rows} side="right" gutterWidth={gutterWidth} wrapLines={wrapLines} showDivider />
</View>
);
}
const computedLines: { line: DiffLine; lineNumber: number | null; key: string }[] = [];
for (const [hunkIndex, hunk] of file.hunks.entries()) {
let oldLineNo = hunk.oldStart;
let newLineNo = hunk.newStart;
for (const [lineIndex, line] of hunk.lines.entries()) {
let lineNumber: number | null = null;
if (line.type === "remove") {
lineNumber = oldLineNo;
oldLineNo++;
} else if (line.type === "add") {
lineNumber = newLineNo;
newLineNo++;
} else if (line.type === "context") {
lineNumber = newLineNo;
oldLineNo++;
newLineNo++;
}
computedLines.push({ line, lineNumber, key: `${hunkIndex}-${lineIndex}` });
}
}
if (wrapLines) {
return (
<View style={styles.diffContent}>
<View style={styles.linesContainer}>
{computedLines.map(({ line, lineNumber, key }) => (
<DiffLineView key={key} line={line} lineNumber={lineNumber} gutterWidth={gutterWidth} wrapLines={wrapLines} />
))}
</View>
</View>
);
}
const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth;
return (
<View style={[styles.diffContent, styles.diffContentRow]}>
<View style={styles.gutterColumn}>
{computedLines.map(({ line, lineNumber, key }) => (
<DiffGutterCell key={key} lineNumber={lineNumber} type={line.type} gutterWidth={gutterWidth} />
))}
</View>
<DiffScroll
scrollViewWidth={scrollViewWidth}
onScrollViewWidthChange={setScrollViewWidth}
style={styles.splitColumnScroll}
contentContainerStyle={styles.diffContentInner}
>
<View style={[styles.linesContainer, availableWidth > 0 && { minWidth: availableWidth }]}>
{computedLines.map(({ line, key }) => (
<DiffTextLine key={key} line={line} wrapLines={false} />
))}
</View>
</DiffScroll>
</View>
);
})()}
</View>
);
}
interface GitDiffPaneProps {
serverId: string;
workspaceId?: string | null;
cwd: string;
hideHeaderRow?: boolean;
}
type DiffFlatItem =
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
const { theme } = useUnistyles();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
useChangesPreferences();
const wrapLines = changesPreferences.wrapLines;
const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
const handleToggleWrapLines = useCallback(() => {
void updateChangesPreferences({ wrapLines: !wrapLines });
}, [updateChangesPreferences, wrapLines]);
const handleLayoutChange = useCallback(
(nextLayout: "unified" | "split") => {
void updateChangesPreferences({ layout: nextLayout });
},
[updateChangesPreferences],
);
const handleToggleHideWhitespace = useCallback(() => {
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
const {
status,
isLoading: isStatusLoading,
isFetching: isStatusFetching,
isError: isStatusError,
error: statusError,
refresh: refreshStatus,
} = useCheckoutStatusQuery({ serverId, cwd });
const gitStatus = status && status.isGit ? status : null;
const isGit = Boolean(gitStatus);
const notGit = status !== null && !status.isGit && !status.error;
const statusErrorMessage =
status?.error?.message ??
(isStatusError && statusError instanceof Error ? statusError.message : null);
const baseRef = gitStatus?.baseRef ?? undefined;
// Auto-select diff mode based on state: uncommitted when dirty, base when clean
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const autoDiffMode = hasUncommittedChanges ? "uncommitted" : "base";
const diffMode = diffModeOverride ?? autoDiffMode;
const {
files,
payloadError: diffPayloadError,
isLoading: isDiffLoading,
isFetching: isDiffFetching,
isError: isDiffError,
error: diffError,
refresh: refreshDiff,
} = useCheckoutDiffQuery({
serverId,
cwd,
mode: diffMode,
baseRef,
ignoreWhitespace: changesPreferences.hideWhitespace,
enabled: isGit,
});
const {
status: prStatus,
githubFeaturesEnabled,
payloadError: prPayloadError,
refresh: refreshPrStatus,
} = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isGit,
});
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
const [isManualRefresh, setIsManualRefresh] = useState(false);
const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
const workspaceStateKey = useMemo(
() =>
buildWorkspaceExplorerStateKey({
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
}),
[normalizedWorkspaceRoot, workspaceId],
);
const expandedPathsArray = usePanelStore((state) =>
workspaceStateKey ? state.diffExpandedPathsByWorkspace[workspaceStateKey] : undefined,
);
const setDiffExpandedPathsForWorkspace = usePanelStore(
(state) => state.setDiffExpandedPathsForWorkspace,
);
const expandedPaths = useMemo(
() => new Set(expandedPathsArray ?? []),
[expandedPathsArray],
);
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
enabled: showDesktopWebScrollbar,
});
const diffListScrollOffsetRef = useRef(0);
const diffListViewportHeightRef = useRef(0);
const headerHeightByPathRef = useRef<Record<string, number>>({});
const bodyHeightByPathRef = useRef<Record<string, number>>({});
const defaultHeaderHeightRef = useRef<number>(44);
const handleRefresh = useCallback(() => {
setIsManualRefresh(true);
void refreshDiff();
void refreshStatus();
void refreshPrStatus();
}, [refreshDiff, refreshStatus, refreshPrStatus]);
const shipDefaultStorageKey = useMemo(() => {
if (!gitStatus?.repoRoot) {
return null;
}
return `@paseo:changes-ship-default:${gitStatus.repoRoot}`;
}, [gitStatus?.repoRoot]);
useEffect(() => {
if (!shipDefaultStorageKey) {
return;
}
let isActive = true;
AsyncStorage.getItem(shipDefaultStorageKey)
.then((value) => {
if (!isActive) return;
if (value === "pr" || value === "merge") {
setShipDefault(value);
}
})
.catch(() => undefined);
return () => {
isActive = false;
};
}, [shipDefaultStorageKey]);
const persistShipDefault = useCallback(
async (next: "merge" | "pr") => {
setShipDefault(next);
if (!shipDefaultStorageKey) return;
try {
await AsyncStorage.setItem(shipDefaultStorageKey, next);
} catch {
// Ignore persistence failures; default will reset to "merge".
}
},
[shipDefaultStorageKey],
);
const { flatItems, stickyHeaderIndices } = useMemo(() => {
const items: DiffFlatItem[] = [];
const stickyIndices: number[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const isExpanded = expandedPaths.has(file.path);
items.push({ type: "header", file, fileIndex: i, isExpanded });
if (isExpanded) {
stickyIndices.push(items.length - 1);
}
if (isExpanded) {
items.push({ type: "body", file, fileIndex: i });
}
}
return { flatItems: items, stickyHeaderIndices: stickyIndices };
}, [expandedPaths, files]);
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
if (!Number.isFinite(height) || height <= 0) {
return;
}
headerHeightByPathRef.current[path] = height;
defaultHeaderHeightRef.current = height;
}, []);
const handleBodyHeightChange = useCallback((path: string, height: number) => {
if (!Number.isFinite(height) || height < 0) {
return;
}
bodyHeightByPathRef.current[path] = height;
}, []);
const handleDiffListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
scrollbar.onScroll(event);
},
[scrollbar.onScroll],
);
const handleDiffListLayout = useCallback(
(event: LayoutChangeEvent) => {
const height = event.nativeEvent.layout.height;
if (!Number.isFinite(height) || height <= 0) {
return;
}
diffListViewportHeightRef.current = height;
scrollbar.onLayout(event);
},
[scrollbar.onLayout],
);
const computeHeaderOffset = useCallback(
(path: string): number => {
const defaultHeaderHeight = defaultHeaderHeightRef.current;
let offset = 0;
for (const file of files) {
if (file.path === path) {
break;
}
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
if (expandedPaths.has(file.path)) {
offset += bodyHeightByPathRef.current[file.path] ?? 0;
}
}
return Math.max(0, offset);
},
[expandedPaths, files],
);
const handleToggleExpanded = useCallback(
(path: string) => {
if (!workspaceStateKey) {
return;
}
const isCurrentlyExpanded = expandedPaths.has(path);
const nextExpanded = !isCurrentlyExpanded;
const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null;
const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current;
const shouldAnchor =
isCurrentlyExpanded &&
targetOffset !== null &&
shouldAnchorHeaderBeforeCollapse({
headerOffset: targetOffset,
headerHeight,
viewportOffset: diffListScrollOffsetRef.current,
viewportHeight: diffListViewportHeightRef.current,
});
// Anchor to the clicked header before collapsing so visual context is preserved.
if (shouldAnchor && targetOffset !== null) {
diffListRef.current?.scrollToOffset({
offset: targetOffset,
animated: false,
});
}
const nextPaths = nextExpanded
? [...expandedPaths, path]
: Array.from(expandedPaths).filter((expandedPath) => expandedPath !== path);
setDiffExpandedPathsForWorkspace(workspaceStateKey, nextPaths);
},
[computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey],
);
const allExpanded = useMemo(() => {
if (files.length === 0) return false;
return files.every((file) => expandedPaths.has(file.path));
}, [expandedPaths, files]);
const handleToggleExpandAll = useCallback(() => {
if (!workspaceStateKey) {
return;
}
if (allExpanded) {
setDiffExpandedPathsForWorkspace(workspaceStateKey, []);
} else {
setDiffExpandedPathsForWorkspace(
workspaceStateKey,
files.map((file) => file.path),
);
}
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
// Reset manual refresh flag when fetch completes
useEffect(() => {
if (!(isDiffFetching || isStatusFetching) && isManualRefresh) {
setIsManualRefresh(false);
}
}, [isDiffFetching, isStatusFetching, isManualRefresh]);
// Clear diff mode override when auto mode changes (e.g., after commit)
useEffect(() => {
setDiffModeOverride(null);
}, [autoDiffMode]);
const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" }),
);
const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" }),
);
const prCreateStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "create-pr" }),
);
const mergeStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-branch" }),
);
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
);
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
);
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPush = useCheckoutGitActionsStore((state) => state.push);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const handleCommit = useCallback(() => {
setActionError(null);
void runCommit({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to commit";
setActionError(message);
});
}, [runCommit, serverId, cwd]);
const handlePush = useCallback(() => {
setActionError(null);
void runPush({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to push";
setActionError(message);
});
}, [runPush, serverId, cwd]);
const handleCreatePr = useCallback(() => {
void persistShipDefault("pr");
setActionError(null);
void runCreatePr({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to create PR";
setActionError(message);
});
}, [persistShipDefault, runCreatePr, serverId, cwd]);
const handleMergeBranch = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
return;
}
void persistShipDefault("merge");
setActionError(null);
void runMergeBranch({ serverId, cwd, baseRef })
.then(() => {
setPostShipArchiveSuggested(true);
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge";
setActionError(message);
});
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
const handleMergeFromBase = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
return;
}
setActionError(null);
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge from base";
setActionError(message);
});
}, [baseRef, runMergeFromBase, serverId, cwd]);
const handleArchiveWorktree = useCallback(() => {
const worktreePath = status?.cwd;
if (!worktreePath) {
setActionError("Worktree path unavailable");
return;
}
setActionError(null);
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";
setActionError(message);
});
}, [runArchiveWorktree, router, serverId, cwd, status]);
const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => {
if (item.type === "header") {
return (
<DiffFileHeader
file={item.file}
isExpanded={item.isExpanded}
onToggle={handleToggleExpanded}
onHeaderHeightChange={handleHeaderHeightChange}
testID={`diff-file-${item.fileIndex}`}
/>
);
}
return (
<DiffFileBody
file={item.file}
layout={effectiveLayout}
wrapLines={wrapLines}
onBodyHeightChange={handleBodyHeightChange}
testID={`diff-file-${item.fileIndex}-body`}
/>
);
},
[
effectiveLayout,
handleBodyHeightChange,
handleHeaderHeightChange,
handleToggleExpanded,
wrapLines,
],
);
const flatKeyExtractor = useCallback(
(item: DiffFlatItem) => `${item.type}-${item.file.path}`,
[],
);
const hasChanges = files.length > 0;
const diffErrorMessage =
diffPayloadError?.message ??
(isDiffError && diffError instanceof Error ? diffError.message : null);
const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null;
const branchLabel =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
? gitStatus.currentBranch
: notGit
? "Not a git repository"
: "Unknown";
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
const baseRefLabel = useMemo(() => {
if (!baseRef) return "base";
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
}, [baseRef]);
const committedDiffDescription = useMemo(() => {
if (!branchLabel || !baseRefLabel) {
return undefined;
}
return branchLabel === baseRefLabel ? undefined : `${branchLabel} -> ${baseRefLabel}`;
}, [baseRefLabel, branchLabel]);
const hasPullRequest = Boolean(prStatus?.url);
const hasRemote = gitStatus?.hasRemote ?? false;
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
const isMergedPullRequest = Boolean(prStatus?.isMerged);
const currentBranch = gitStatus?.currentBranch;
const isOnBaseBranch = currentBranch === baseRefLabel;
const shouldPromoteArchive =
isPaseoOwnedWorktree &&
!hasUncommittedChanges &&
(postShipArchiveSuggested || isMergedPullRequest);
const commitDisabled = actionsDisabled || commitStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled = actionsDisabled || mergeStatus === "pending";
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
const pushDisabled = actionsDisabled || pushStatus === "pending";
const archiveDisabled = actionsDisabled || archiveStatus === "pending";
let bodyContent: ReactElement;
if (isStatusLoading) {
bodyContent = (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
<Text style={styles.loadingText}>Checking repository...</Text>
</View>
);
} else if (statusErrorMessage) {
bodyContent = (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{statusErrorMessage}</Text>
</View>
);
} else if (notGit) {
bodyContent = (
<View style={styles.emptyContainer} testID="changes-not-git">
<Text style={styles.emptyText}>Not a git repository</Text>
</View>
);
} else if (isDiffLoading) {
bodyContent = (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
);
} else if (diffErrorMessage) {
bodyContent = (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{diffErrorMessage}</Text>
</View>
);
} else if (!hasChanges) {
bodyContent = (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>
{changesPreferences.hideWhitespace
? "No visible changes after hiding whitespace"
: diffMode === "uncommitted"
? "No uncommitted changes"
: `No changes vs ${baseRefLabel}`}
</Text>
</View>
);
} else {
bodyContent = (
<FlatList
ref={diffListRef}
data={flatItems}
renderItem={renderFlatItem}
keyExtractor={flatKeyExtractor}
stickyHeaderIndices={stickyHeaderIndices}
extraData={{ expandedPathsArray, effectiveLayout, wrapLines }}
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
testID="git-diff-scroll"
onLayout={handleDiffListLayout}
onScroll={handleDiffListScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
onRefresh={handleRefresh}
refreshing={isManualRefresh && isDiffFetching}
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
// Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll.
removeClippedSubviews={false}
initialNumToRender={12}
maxToRenderPerBatch={12}
windowSize={10}
/>
);
}
useEffect(() => {
setPostShipArchiveSuggested(false);
}, [cwd]);
// ==========================================================================
// Git Actions (Data-Oriented)
// ==========================================================================
// All possible actions are computed as data, then partitioned into:
// - primary: The main CTA button
// - secondary: Dropdown next to primary button
// - menu: Kebab overflow menu
// ==========================================================================
const gitActions: GitActions = useMemo(() => {
return buildGitActions({
isGit,
githubFeaturesEnabled,
hasPullRequest,
pullRequestUrl: prStatus?.url ?? null,
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
hasUncommittedChanges,
baseRefAvailable: Boolean(baseRef),
baseRefLabel,
aheadCount,
aheadOfOrigin,
behindOfOrigin,
shouldPromoteArchive,
shipDefault,
runtime: {
commit: {
disabled: commitDisabled,
status: commitStatus,
icon: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
handler: handleCommit,
},
push: {
disabled: pushDisabled,
status: pushStatus,
icon: <Upload size={16} color={theme.colors.foregroundMuted} />,
handler: handlePush,
},
pr: {
disabled: prDisabled,
status: hasPullRequest ? "idle" : prCreateStatus,
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
handler: () => {
if (prStatus?.url) {
openURLInNewTab(prStatus.url);
return;
}
handleCreatePr();
},
},
"merge-branch": {
disabled: mergeDisabled,
status: mergeStatus,
icon: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
handler: handleMergeBranch,
},
"merge-from-base": {
disabled: mergeFromBaseDisabled,
status: mergeFromBaseStatus,
icon: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
handler: handleMergeFromBase,
},
"archive-worktree": {
disabled: archiveDisabled,
status: archiveStatus,
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
handler: handleArchiveWorktree,
},
},
});
}, [
isGit,
hasRemote,
hasPullRequest,
prStatus?.url,
aheadCount,
isPaseoOwnedWorktree,
isOnBaseBranch,
githubFeaturesEnabled,
hasUncommittedChanges,
aheadOfOrigin,
behindOfOrigin,
shipDefault,
baseRefLabel,
shouldPromoteArchive,
commitDisabled,
pushDisabled,
prDisabled,
mergeDisabled,
mergeFromBaseDisabled,
archiveDisabled,
commitStatus,
pushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
handleCommit,
handlePush,
handleCreatePr,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorktree,
theme.colors.foregroundMuted,
]);
// Helper to get display label based on status
return (
<View style={styles.container}>
{!hideHeaderRow ? (
<View style={styles.header} testID="changes-header">
<View style={styles.headerLeft}>
<GitBranch size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
{branchLabel}
</Text>
</View>
{isGit ? <GitActionsSplitButton gitActions={gitActions} /> : null}
</View>
) : null}
{isGit ? (
<View style={styles.diffStatusContainer}>
<View style={styles.diffStatusInner}>
<DropdownMenu>
<DropdownMenuTrigger
style={({ hovered, pressed, open }) => [
styles.diffModeTrigger,
hovered && styles.diffModeTriggerHovered,
(pressed || open) && styles.diffModeTriggerPressed,
]}
testID="changes-diff-status"
accessibilityRole="button"
accessibilityLabel="Diff mode"
>
<Text style={styles.diffStatusText} numberOfLines={1}>
{diffMode === "uncommitted" ? "Uncommitted" : "Committed"}
</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" width={260} testID="changes-diff-status-menu">
<DropdownMenuItem
testID="changes-diff-mode-uncommitted"
selected={diffMode === "uncommitted"}
onSelect={() => setDiffModeOverride("uncommitted")}
>
Uncommitted
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="changes-diff-mode-committed"
selected={diffMode === "base"}
description={committedDiffDescription}
onSelect={() => setDiffModeOverride("base")}
>
Committed
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<View style={styles.diffStatusButtons}>
{canUseSplitLayout ? (
<View style={styles.toggleButtonGroup}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Unified diff"
testID="changes-layout-unified"
onPress={() => handleLayoutChange("unified")}
style={({ hovered, pressed }) => [
styles.toggleButton,
styles.toggleButtonGroupStart,
changesPreferences.layout === "unified" && styles.toggleButtonSelected,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
>
<AlignJustify
size={14}
color={
changesPreferences.layout === "unified"
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Unified diff</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Side-by-side diff"
testID="changes-layout-split"
onPress={() => handleLayoutChange("split")}
style={({ hovered, pressed }) => [
styles.toggleButton,
styles.toggleButtonGroupEnd,
changesPreferences.layout === "split" && styles.toggleButtonSelected,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
>
<Columns2
size={14}
color={
changesPreferences.layout === "split"
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Side-by-side diff</Text>
</TooltipContent>
</Tooltip>
</View>
) : null}
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Hide whitespace"
testID="changes-toggle-whitespace"
style={({ hovered, pressed }) => [
styles.expandAllButton,
changesPreferences.hideWhitespace && styles.toggleButtonSelected,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleHideWhitespace}
>
<Pilcrow
size={isMobile ? 18 : 14}
color={
changesPreferences.hideWhitespace
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Hide whitespace</Text>
</TooltipContent>
</Tooltip>
{files.length > 0 ? (
<View style={styles.diffStatusButtons}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={({ hovered, pressed }) => [
styles.expandAllButton,
wrapLines && styles.toggleButtonSelected,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleWrapLines}
>
<WrapText
size={isMobile ? 18 : 14}
color={wrapLines ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>
{wrapLines ? "Scroll long lines" : "Wrap long lines"}
</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={({ hovered, pressed }) => [
styles.expandAllButton,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleExpandAll}
>
{allExpanded ? (
<ListChevronsDownUp
size={isMobile ? 18 : 14}
color={theme.colors.foregroundMuted}
/>
) : (
<ListChevronsUpDown
size={isMobile ? 18 : 14}
color={theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>
{allExpanded ? "Collapse all files" : "Expand all files"}
</Text>
</TooltipContent>
</Tooltip>
</View>
) : null}
</View>
</View>
</View>
) : null}
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
<View style={styles.diffContainer}>
{bodyContent}
{hasChanges ? scrollbar.overlay : null}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
minHeight: 0,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
headerLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flex: 1,
minWidth: 0,
},
branchLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
flexShrink: 1,
},
diffStatusContainer: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
diffStatusInner: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingRight: theme.spacing[3],
},
diffModeTrigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
// Align text with header branch icon (at spacing[3] from edge, minus our horizontal padding)
marginLeft: theme.spacing[3] - theme.spacing[1],
paddingHorizontal: theme.spacing[1],
height: {
xs: 28,
sm: 28,
md: 24,
},
borderRadius: theme.borderRadius.base,
flexShrink: 0,
},
diffModeTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
diffModeTriggerPressed: {
backgroundColor: theme.colors.surface2,
},
diffStatusRowHovered: {
backgroundColor: theme.colors.surface2,
},
diffStatusText: {
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.25,
color: theme.colors.foregroundMuted,
},
diffStatusIconHidden: {
opacity: 0,
},
diffStatusButtons: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexWrap: "wrap",
},
toggleButtonGroup: {
flexDirection: "row",
alignItems: "center",
},
toggleButton: {
alignItems: "center",
justifyContent: "center",
minWidth: {
xs: 32,
sm: 32,
md: 24,
},
height: {
xs: 32,
sm: 32,
md: 24,
},
paddingHorizontal: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
},
toggleButtonGroupStart: {
borderTopLeftRadius: theme.borderRadius.base,
borderBottomLeftRadius: theme.borderRadius.base,
},
toggleButtonGroupEnd: {
borderTopRightRadius: theme.borderRadius.base,
borderBottomRightRadius: theme.borderRadius.base,
},
toggleButtonSelected: {
backgroundColor: theme.colors.surface2,
},
expandAllButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
minWidth: {
xs: 32,
sm: 32,
md: 24,
},
height: {
xs: 32,
sm: 32,
md: 24,
},
paddingHorizontal: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
borderRadius: theme.borderRadius.base,
flexShrink: 0,
},
actionErrorText: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[1],
fontSize: theme.fontSize.xs,
color: theme.colors.destructive,
},
diffContainer: {
flex: 1,
minHeight: 0,
position: "relative",
},
scrollView: {
flex: 1,
},
contentContainer: {
paddingBottom: theme.spacing[8],
},
loadingContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingTop: theme.spacing[16],
gap: theme.spacing[4],
},
loadingText: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
},
errorContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingTop: theme.spacing[16],
paddingHorizontal: theme.spacing[6],
},
errorText: {
fontSize: theme.fontSize.base,
color: theme.colors.destructive,
textAlign: "center",
},
emptyContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingTop: theme.spacing[16],
},
emptyText: {
fontSize: theme.fontSize.lg,
color: theme.colors.foregroundMuted,
},
fileSection: {
overflow: "hidden",
backgroundColor: theme.colors.surface2,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
fileSectionHeaderContainer: {
overflow: "hidden",
},
fileSectionHeaderExpanded: {
backgroundColor: theme.colors.surface1,
},
fileSectionBodyContainer: {
overflow: "hidden",
backgroundColor: theme.colors.surface2,
},
fileSectionBorder: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
fileHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingLeft: theme.spacing[3],
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[2],
gap: theme.spacing[1],
zIndex: 2,
elevation: 2,
},
fileHeaderPressed: {
opacity: 0.7,
},
fileHeaderLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
fileHeaderRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
fileName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
flexShrink: 0,
},
fileDir: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
flex: 1,
},
newBadge: {
backgroundColor: "rgba(46, 160, 67, 0.2)",
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
newBadgeText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[400],
},
deletedBadge: {
backgroundColor: "rgba(248, 81, 73, 0.2)",
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
deletedBadgeText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500],
},
additions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[400],
},
deletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500],
},
diffContent: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
},
diffContentRow: {
flexDirection: "row",
alignItems: "stretch",
},
diffContentInner: {
flexDirection: "column",
},
linesContainer: {
backgroundColor: theme.colors.surface1,
},
gutterColumn: {
backgroundColor: theme.colors.surface1,
},
gutterCell: {
borderRightWidth: theme.borderWidth[1],
borderRightColor: theme.colors.border,
justifyContent: "flex-start",
},
textLineContainer: {
flexDirection: "row",
alignItems: "stretch",
paddingLeft: theme.spacing[2],
},
splitRow: {
flexDirection: "row",
alignItems: "stretch",
},
splitColumnScroll: {
flex: 1,
},
splitHeaderRow: {
backgroundColor: theme.colors.surface2,
paddingHorizontal: theme.spacing[3],
},
splitCell: {
flex: 1,
flexBasis: 0,
backgroundColor: theme.colors.surface2,
},
splitCellRow: {
flexDirection: "row",
alignItems: "stretch",
},
emptySplitCell: {
backgroundColor: theme.colors.surfaceDiffEmpty,
},
splitCellWithDivider: {
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.border,
},
diffLineContainer: {
flexDirection: "row",
alignItems: "stretch",
},
lineNumberGutter: {
borderRightWidth: theme.borderWidth[1],
borderRightColor: theme.colors.border,
marginRight: theme.spacing[2],
alignSelf: "stretch",
justifyContent: "flex-start",
},
lineNumberText: {
textAlign: "right",
paddingRight: theme.spacing[2],
fontSize: theme.fontSize.xs,
lineHeight: theme.lineHeight.diff,
fontFamily: Fonts.mono,
color: theme.colors.foregroundMuted,
userSelect: "none",
},
addLineNumberText: {
color: theme.colors.palette.green[400],
},
removeLineNumberText: {
color: theme.colors.palette.red[500],
},
diffLineText: {
flex: 1,
paddingRight: theme.spacing[3],
fontSize: theme.fontSize.xs,
lineHeight: theme.lineHeight.diff,
fontFamily: Fonts.mono,
color: theme.colors.foreground,
userSelect: "text",
},
addLineContainer: {
backgroundColor: "rgba(46, 160, 67, 0.15)", // GitHub green
},
addLineText: {
color: theme.colors.foreground,
},
removeLineContainer: {
backgroundColor: "rgba(248, 81, 73, 0.1)", // GitHub red
},
removeLineText: {
color: theme.colors.foreground,
},
headerLineContainer: {
backgroundColor: theme.colors.surface2,
},
headerLineText: {
color: theme.colors.foregroundMuted,
},
contextLineContainer: {
backgroundColor: theme.colors.surface1,
},
contextLineText: {
color: theme.colors.foregroundMuted,
},
emptySplitCellText: {
color: "transparent",
},
statusMessageContainer: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[4],
},
statusMessageText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
fontStyle: "italic",
},
tooltipText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
},
}));