mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
* Add appearance settings for theme, fonts, and syntax highlighting New Appearance section in Settings to configure the app theme, UI and mono font family + size, and a syntax-highlighting theme chosen independently of light/dark. Font family fields default to empty and show the active system stack as the placeholder; a live split-diff preview reflects the choices as they change. The theme picker moves here from General. Preferences are client-only (AsyncStorage) and applied at runtime by patching every registered Unistyles theme via UnistylesRuntime.updateTheme, so existing StyleSheet consumers repaint with no per-component preference reads and no useUnistyles. No daemon/protocol change. * Expand syntax themes and move the preview under the chooser Replace the small fixed syntax-theme set with eight popular themes — GitHub, Catppuccin, Dracula, Tokyo Night, One, Nord, Gruvbox, Solarized — built from a compact per-theme role palette. The app theme and syntax theme stay separate; the only coupling is the light/dark axis: a theme with both variants uses its light palette on a light app and its dark palette on a dark app, while the code frame (gutter, line numbers, background) keeps following the app theme. Default is now GitHub (GitHub keeps its existing hand-tuned maps for an exact match). Move the live preview directly under the syntax-theme chooser, drop the "preview.ts" filename header, and use a clearer code snippet for it. * Update settings e2e for the moved theme picker and Appearance section The theme picker moved from General to the new Appearance section, so the General-content assertion no longer finds "Theme" — point it at "Default send" (which stays in General) and add Appearance to the e2e section map plus an expectAppearanceContent check, and exercise the Appearance section in the sidebar-navigation test. * Render the appearance preview as a unified diff Switch the live preview from side-by-side columns to a single unified diff: unchanged context lines plus "-"/"+" rows for the change, matching how diffs read elsewhere in the app. Same snippet, mono font, syntax colors, and live code-font draft behavior. * Fix code font size change shrinking all UI text applyAppearance scaled the live (already-patched) theme ramp instead of the authored FONT_SIZE ramp, so it was not idempotent: every appearance change re-scaled the current sizes, compounding the whole-UI font scale. With any non-default UI size, changing the code size — or any setting — shrank (or grew) all app text, and it persisted into the theme. Build the ramp from the canonical FONT_SIZE ramp each apply (code size still set absolutely), so applies are idempotent and a code-size change never touches the UI ramp. * Apply the interface font across the app on web react-native-web stamps a default font onto every text element, so the interface font only reached the few components that set a font explicitly. Inject one rule that points all text at a CSS variable (updated live as the setting changes), with high specificity (1,2,0) so it deterministically beats RN-web's base font and Unistyles' generated classes — no stylesheet-order fragility. Code, diff, and monospace surfaces carry a `data-pmono` marker (and their subtrees are excluded), so they keep their code font. Web-only: React Native has no global font cascade, so native still applies the UI font only where components opt in. Inline `code` within chat prose is a known minor gap (its render paths aren't tagged yet). * Keep preview code in the code font * Apply code font settings to terminals * Fix settings host picker import after rebase * Format terminal webview bundle
2629 lines
78 KiB
TypeScript
2629 lines
78 KiB
TypeScript
import {
|
|
useState,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
memo,
|
|
type ReactElement,
|
|
type ReactNode,
|
|
type RefObject,
|
|
} from "react";
|
|
import { DiffStat } from "@/components/diff-stat";
|
|
import {
|
|
View,
|
|
Text,
|
|
ActivityIndicator,
|
|
Pressable,
|
|
FlatList,
|
|
type LayoutChangeEvent,
|
|
type NativeSyntheticEvent,
|
|
type NativeScrollEvent,
|
|
type PressableStateCallbackType,
|
|
type FlatListProps,
|
|
type StyleProp,
|
|
type ViewStyle,
|
|
type TextStyle,
|
|
} from "react-native";
|
|
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
import {
|
|
AlignJustify,
|
|
Archive,
|
|
ArrowDownUp,
|
|
ChevronDown,
|
|
Columns2,
|
|
Download,
|
|
GitBranch,
|
|
GitCommitHorizontal,
|
|
GitMerge,
|
|
ListChevronsDownUp,
|
|
ListChevronsUpDown,
|
|
Pilcrow,
|
|
RefreshCcw,
|
|
RotateCw,
|
|
Upload,
|
|
WrapText,
|
|
} from "lucide-react-native";
|
|
import {
|
|
useCheckoutDiffQuery,
|
|
type ParsedDiffFile,
|
|
type DiffLine,
|
|
type HighlightToken,
|
|
} from "@/git/use-diff-query";
|
|
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
|
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
|
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
|
import { DiffScroll } from "@/components/diff-scroll";
|
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
|
import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll";
|
|
import {
|
|
buildSplitDiffRows,
|
|
buildUnifiedDiffLines,
|
|
type ReviewableDiffTarget,
|
|
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 { lineNumberGutterWidth } from "@/components/code-insets";
|
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
|
import { GitActionsSplitButton } from "@/git/actions-split-button";
|
|
import { useGitActions } from "@/git/use-actions";
|
|
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
|
import { useToast } from "@/contexts/toast-context";
|
|
import { useSessionStore } from "@/stores/session-store";
|
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
|
import { usePanelStore } from "@/stores/panel-store";
|
|
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
|
import {
|
|
formatDiffContentText,
|
|
formatDiffGutterText,
|
|
hasVisibleDiffTokens,
|
|
} from "@/utils/diff-rendering";
|
|
import { isWeb, isNative } from "@/constants/platform";
|
|
import {
|
|
buildWorkspaceAttachmentScopeKey,
|
|
useWorkspaceAttachmentsStore,
|
|
} from "@/attachments/workspace-attachments-store";
|
|
import {
|
|
buildReviewDraftScopeKey,
|
|
buildReviewDraftKey,
|
|
useActiveReviewDraftMode,
|
|
useReviewAttachmentSnapshot,
|
|
useSetActiveReviewDraftMode,
|
|
type ReviewDraftComment,
|
|
type ReviewDraftMode,
|
|
getInlineReviewThreadState,
|
|
getSplitInlineReviewThreadState,
|
|
InlineReviewGutterCell,
|
|
InlineReviewThread,
|
|
isInlineReviewEditorForTarget,
|
|
useInlineReviewController,
|
|
type InlineReviewActions,
|
|
} from "@/review";
|
|
|
|
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
|
|
|
function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
|
|
return [styles.fileHeader, pressed && styles.fileHeaderPressed];
|
|
}
|
|
|
|
interface HighlightedTextProps {
|
|
tokens: HighlightToken[];
|
|
wrapLines?: boolean;
|
|
}
|
|
|
|
type WrappedWebTextStyle = TextStyle & {
|
|
whiteSpace?: "pre" | "pre-wrap";
|
|
overflowWrap?: "normal" | "anywhere";
|
|
};
|
|
|
|
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
|
|
if (isNative) {
|
|
return undefined;
|
|
}
|
|
return wrapLines
|
|
? { whiteSpace: "pre-wrap", overflowWrap: "anywhere" }
|
|
: { whiteSpace: "pre", overflowWrap: "normal" };
|
|
}
|
|
|
|
function HighlightedToken({ token }: { token: HighlightToken }) {
|
|
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
|
}
|
|
|
|
function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
|
|
const { theme } = useUnistyles();
|
|
const lineHeight = theme.lineHeight.diff;
|
|
|
|
const containerStyle = useMemo(
|
|
() => [styles.diffLineText, { lineHeight, ...getWrappedTextStyle(wrapLines) }],
|
|
[lineHeight, wrapLines],
|
|
);
|
|
|
|
const keyedTokens = useMemo(
|
|
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
|
[tokens],
|
|
);
|
|
|
|
return (
|
|
<Text style={containerStyle}>
|
|
{keyedTokens.map(({ key, token }) => (
|
|
<HighlightedToken key={key} token={token} />
|
|
))}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
interface DiffFileSectionProps {
|
|
file: ParsedDiffFile;
|
|
isExpanded: boolean;
|
|
onToggle: (path: string) => void;
|
|
onHeaderHeightChange?: (path: string, height: number) => void;
|
|
testID?: string;
|
|
}
|
|
|
|
const EMPTY_COMMENTS: readonly ReviewDraftComment[] = [];
|
|
|
|
function noopStartComment(): void {}
|
|
|
|
const DIFF_LINE_HOVER_STYLE = isWeb ? ({ cursor: "auto" } as const) : null;
|
|
|
|
function LongPressableLine({
|
|
reviewTarget,
|
|
reviewActions,
|
|
onHoverChange,
|
|
hoverTargetKey,
|
|
onHoverTargetChange,
|
|
style,
|
|
children,
|
|
}: {
|
|
reviewTarget: ReviewableDiffTarget | null | undefined;
|
|
reviewActions: InlineReviewActions | undefined;
|
|
onHoverChange?: (hovered: boolean) => void;
|
|
hoverTargetKey?: string | null;
|
|
onHoverTargetChange?: (key: string | null) => void;
|
|
style: StyleProp<ViewStyle>;
|
|
children: ReactNode;
|
|
}) {
|
|
const onStartComment = reviewActions?.onStartComment;
|
|
const handlePress = useCallback(() => {
|
|
if (reviewTarget && onStartComment) {
|
|
onStartComment(reviewTarget);
|
|
}
|
|
}, [reviewTarget, onStartComment]);
|
|
|
|
const handleHoverIn = useCallback(() => {
|
|
onHoverChange?.(true);
|
|
if (hoverTargetKey) {
|
|
onHoverTargetChange?.(hoverTargetKey);
|
|
}
|
|
}, [hoverTargetKey, onHoverChange, onHoverTargetChange]);
|
|
const handleHoverOut = useCallback(() => {
|
|
onHoverChange?.(false);
|
|
if (hoverTargetKey) {
|
|
onHoverTargetChange?.(null);
|
|
}
|
|
}, [hoverTargetKey, onHoverChange, onHoverTargetChange]);
|
|
const hoverStyle = useMemo(() => [style, DIFF_LINE_HOVER_STYLE], [style]);
|
|
|
|
if (isWeb && (onHoverChange || onHoverTargetChange)) {
|
|
return (
|
|
<Pressable onHoverIn={handleHoverIn} onHoverOut={handleHoverOut} style={hoverStyle}>
|
|
{children}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
if (!isNative || !reviewTarget || !onStartComment) {
|
|
return <View style={style}>{children}</View>;
|
|
}
|
|
return (
|
|
<Pressable onPress={handlePress} style={style}>
|
|
{children}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
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,
|
|
reviewTarget,
|
|
reviewActions,
|
|
isLineHovered,
|
|
style,
|
|
}: {
|
|
lineNumber: number | null;
|
|
type: DiffLine["type"] | undefined | null;
|
|
gutterWidth: number;
|
|
reviewTarget?: ReviewableDiffTarget | null;
|
|
reviewActions?: InlineReviewActions;
|
|
isLineHovered?: boolean;
|
|
style?: StyleProp<ViewStyle>;
|
|
}) {
|
|
const containerStyle = useMemo(
|
|
() => [
|
|
styles.gutterCell,
|
|
lineTypeBackground(type),
|
|
inlineUnistylesStyle({ width: gutterWidth }),
|
|
style,
|
|
],
|
|
[type, gutterWidth, style],
|
|
);
|
|
const textStyle = useMemo(
|
|
() => [
|
|
styles.lineNumberText,
|
|
type === "add" && styles.addLineNumberText,
|
|
type === "remove" && styles.removeLineNumberText,
|
|
],
|
|
[type],
|
|
);
|
|
const comments = useMemo(
|
|
() =>
|
|
reviewTarget
|
|
? (reviewActions?.commentsByTarget.get(reviewTarget.key) ?? EMPTY_COMMENTS)
|
|
: EMPTY_COMMENTS,
|
|
[reviewTarget, reviewActions?.commentsByTarget],
|
|
);
|
|
const isEditorOpen = isInlineReviewEditorForTarget(reviewActions?.editor ?? null, reviewTarget);
|
|
const onStartComment = reviewActions?.onStartComment ?? noopStartComment;
|
|
|
|
return (
|
|
<InlineReviewGutterCell
|
|
reviewTarget={reviewTarget}
|
|
comments={comments}
|
|
isEditorOpen={isEditorOpen}
|
|
isLineHovered={isLineHovered}
|
|
onStartComment={onStartComment}
|
|
style={containerStyle}
|
|
>
|
|
<Text numberOfLines={1} style={textStyle}>
|
|
{formatDiffGutterText(lineNumber)}
|
|
</Text>
|
|
</InlineReviewGutterCell>
|
|
);
|
|
}
|
|
|
|
function DiffTextLine({
|
|
line,
|
|
wrapLines,
|
|
reviewTarget,
|
|
reviewActions,
|
|
onHoverChange,
|
|
hoverTargetKey,
|
|
onHoverTargetChange,
|
|
}: {
|
|
line: DiffLine;
|
|
wrapLines: boolean;
|
|
reviewTarget?: ReviewableDiffTarget | null;
|
|
reviewActions?: InlineReviewActions;
|
|
onHoverChange?: (hovered: boolean) => void;
|
|
hoverTargetKey?: string | null;
|
|
onHoverTargetChange?: (key: string | null) => void;
|
|
}) {
|
|
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
|
|
|
const containerStyle = useMemo(
|
|
() => [styles.textLineContainer, lineTypeBackground(line.type)],
|
|
[line.type],
|
|
);
|
|
const textStyle = useMemo(
|
|
() => [
|
|
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.type, wrapLines],
|
|
);
|
|
|
|
return (
|
|
<LongPressableLine
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
onHoverChange={onHoverChange}
|
|
hoverTargetKey={hoverTargetKey}
|
|
onHoverTargetChange={onHoverTargetChange}
|
|
style={containerStyle}
|
|
>
|
|
{line.type !== "header" && visibleTokens ? (
|
|
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
|
) : (
|
|
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text>
|
|
)}
|
|
</LongPressableLine>
|
|
);
|
|
}
|
|
|
|
function SplitTextLine({
|
|
line,
|
|
wrapLines,
|
|
reviewActions,
|
|
onHoverChange,
|
|
hoverTargetKey,
|
|
onHoverTargetChange,
|
|
}: {
|
|
line: SplitDiffDisplayLine | null;
|
|
wrapLines: boolean;
|
|
reviewActions?: InlineReviewActions;
|
|
onHoverChange?: (hovered: boolean) => void;
|
|
hoverTargetKey?: string | null;
|
|
onHoverTargetChange?: (key: string | null) => void;
|
|
}) {
|
|
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
|
|
|
const containerStyle = useMemo(
|
|
() => [styles.textLineContainer, lineTypeBackground(line?.type)],
|
|
[line?.type],
|
|
);
|
|
const textStyle = useMemo(
|
|
() => [
|
|
styles.diffLineText,
|
|
getWrappedTextStyle(wrapLines),
|
|
line?.type === "add" && styles.addLineText,
|
|
line?.type === "remove" && styles.removeLineText,
|
|
line?.type === "context" && styles.contextLineText,
|
|
!line && styles.emptySplitCellText,
|
|
],
|
|
[line, wrapLines],
|
|
);
|
|
|
|
return (
|
|
<LongPressableLine
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
onHoverChange={onHoverChange}
|
|
hoverTargetKey={hoverTargetKey}
|
|
onHoverTargetChange={onHoverTargetChange}
|
|
style={containerStyle}
|
|
>
|
|
{visibleTokens ? (
|
|
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
|
) : (
|
|
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
|
|
)}
|
|
</LongPressableLine>
|
|
);
|
|
}
|
|
|
|
function DiffLineView({
|
|
line,
|
|
lineNumber,
|
|
gutterWidth,
|
|
wrapLines,
|
|
reviewTarget,
|
|
reviewActions,
|
|
}: {
|
|
line: DiffLine;
|
|
lineNumber: number | null;
|
|
gutterWidth: number;
|
|
wrapLines: boolean;
|
|
reviewTarget?: ReviewableDiffTarget | null;
|
|
reviewActions?: InlineReviewActions;
|
|
}) {
|
|
const [isLineHovered, setIsLineHovered] = useState(false);
|
|
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
|
|
|
const containerStyle = useMemo(
|
|
() => [styles.diffLineContainer, lineTypeBackground(line.type)],
|
|
[line.type],
|
|
);
|
|
const textStyle = useMemo(
|
|
() => [
|
|
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.type, wrapLines],
|
|
);
|
|
|
|
return (
|
|
<LongPressableLine
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
onHoverChange={setIsLineHovered}
|
|
style={containerStyle}
|
|
>
|
|
<DiffGutterCell
|
|
lineNumber={lineNumber}
|
|
type={line.type}
|
|
gutterWidth={gutterWidth}
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
isLineHovered={isLineHovered}
|
|
style={styles.lineNumberGutter}
|
|
/>
|
|
{line.type !== "header" && visibleTokens ? (
|
|
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
|
) : (
|
|
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text>
|
|
)}
|
|
</LongPressableLine>
|
|
);
|
|
}
|
|
|
|
function SplitDiffLine({
|
|
line,
|
|
gutterWidth,
|
|
wrapLines,
|
|
reviewActions,
|
|
}: {
|
|
line: SplitDiffDisplayLine | null;
|
|
gutterWidth: number;
|
|
wrapLines: boolean;
|
|
reviewActions?: InlineReviewActions;
|
|
}) {
|
|
const [isLineHovered, setIsLineHovered] = useState(false);
|
|
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
|
|
|
const containerStyle = useMemo(
|
|
() => [styles.diffLineContainer, lineTypeBackground(line?.type)],
|
|
[line?.type],
|
|
);
|
|
const textStyle = useMemo(
|
|
() => [
|
|
styles.diffLineText,
|
|
getWrappedTextStyle(wrapLines),
|
|
line?.type === "add" && styles.addLineText,
|
|
line?.type === "remove" && styles.removeLineText,
|
|
line?.type === "context" && styles.contextLineText,
|
|
!line && styles.emptySplitCellText,
|
|
],
|
|
[line, wrapLines],
|
|
);
|
|
|
|
return (
|
|
<LongPressableLine
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
onHoverChange={setIsLineHovered}
|
|
style={containerStyle}
|
|
>
|
|
<DiffGutterCell
|
|
lineNumber={line?.lineNumber ?? null}
|
|
type={line?.type}
|
|
gutterWidth={gutterWidth}
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
isLineHovered={isLineHovered}
|
|
style={styles.lineNumberGutter}
|
|
/>
|
|
{visibleTokens ? (
|
|
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
|
) : (
|
|
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
|
|
)}
|
|
</LongPressableLine>
|
|
);
|
|
}
|
|
|
|
function InlineReviewThreadContent({
|
|
reviewTarget,
|
|
reviewActions,
|
|
reservedHeight,
|
|
viewportWidth,
|
|
pinToViewport,
|
|
}: {
|
|
reviewTarget: ReviewableDiffTarget | null | undefined;
|
|
reviewActions?: InlineReviewActions;
|
|
reservedHeight?: number;
|
|
viewportWidth?: number;
|
|
pinToViewport?: boolean;
|
|
}) {
|
|
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
|
|
const height = reservedHeight ?? threadState?.height ?? 0;
|
|
const placeholderStyle = useMemo<ViewStyle>(
|
|
() => inlineUnistylesStyle({ minHeight: height }),
|
|
[height],
|
|
);
|
|
if (height === 0) {
|
|
return null;
|
|
}
|
|
if (!reviewTarget || !reviewActions || !threadState) {
|
|
return <View style={placeholderStyle} />;
|
|
}
|
|
|
|
return (
|
|
<InlineReviewThread
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
height={height}
|
|
viewportWidth={viewportWidth}
|
|
pinToViewport={pinToViewport}
|
|
testID={`review-thread-${reviewTarget.key}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function InlineReviewGutterSpacer({
|
|
reviewTarget,
|
|
reviewActions,
|
|
gutterWidth,
|
|
reservedHeight,
|
|
style,
|
|
}: {
|
|
reviewTarget: ReviewableDiffTarget | null | undefined;
|
|
reviewActions?: InlineReviewActions;
|
|
gutterWidth: number;
|
|
reservedHeight?: number;
|
|
style?: StyleProp<ViewStyle>;
|
|
}) {
|
|
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
|
|
const height = reservedHeight ?? threadState?.height ?? 0;
|
|
const spacerStyle = useMemo<StyleProp<ViewStyle>>(
|
|
() => [
|
|
styles.inlineReviewGutterSpacer,
|
|
inlineUnistylesStyle({ width: gutterWidth, minHeight: height }),
|
|
style,
|
|
],
|
|
[gutterWidth, height, style],
|
|
);
|
|
if (height === 0) {
|
|
return null;
|
|
}
|
|
|
|
return <View style={spacerStyle} />;
|
|
}
|
|
|
|
function InlineReviewRow({
|
|
reviewTarget,
|
|
reviewActions,
|
|
gutterWidth,
|
|
reservedHeight,
|
|
}: {
|
|
reviewTarget: ReviewableDiffTarget | null | undefined;
|
|
reviewActions?: InlineReviewActions;
|
|
gutterWidth: number;
|
|
reservedHeight?: number;
|
|
}) {
|
|
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
|
|
const height = reservedHeight ?? threadState?.height ?? 0;
|
|
const gutterSpacerStyle = useMemo<StyleProp<ViewStyle>>(
|
|
() => [styles.inlineReviewGutterSpacer, inlineUnistylesStyle({ width: gutterWidth })],
|
|
[gutterWidth],
|
|
);
|
|
const placeholderStyle = useMemo<ViewStyle>(
|
|
() => inlineUnistylesStyle({ minHeight: height }),
|
|
[height],
|
|
);
|
|
if (height === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<View style={styles.inlineReviewRow}>
|
|
<View style={gutterSpacerStyle} />
|
|
{reviewTarget && reviewActions && threadState ? (
|
|
<InlineReviewThread
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
height={height}
|
|
testID={`review-thread-${reviewTarget.key}`}
|
|
/>
|
|
) : (
|
|
<View style={placeholderStyle} />
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SplitDiffColumn({
|
|
rows,
|
|
side,
|
|
gutterWidth,
|
|
wrapLines,
|
|
reviewActions,
|
|
showDivider = false,
|
|
}: {
|
|
rows: SplitDiffRow[];
|
|
side: "left" | "right";
|
|
gutterWidth: number;
|
|
wrapLines: boolean;
|
|
reviewActions?: InlineReviewActions;
|
|
showDivider?: boolean;
|
|
}) {
|
|
const [scrollWidth, setScrollWidth] = useState(0);
|
|
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null);
|
|
|
|
const wrapCellStyle = useMemo(
|
|
() => [styles.splitCell, showDivider && styles.splitCellWithDivider],
|
|
[showDivider],
|
|
);
|
|
const rowCellStyle = useMemo(
|
|
() => [styles.splitCell, showDivider && styles.splitCellWithDivider, styles.splitCellRow],
|
|
[showDivider],
|
|
);
|
|
const linesContainerRowStyle = useMemo(
|
|
() => [
|
|
styles.linesContainer,
|
|
scrollWidth > 0 && inlineUnistylesStyle({ minWidth: scrollWidth }),
|
|
],
|
|
[scrollWidth],
|
|
);
|
|
|
|
const keyedRows = useMemo(() => rows.map((row, i) => ({ key: `row-${i}`, row })), [rows]);
|
|
|
|
if (wrapLines) {
|
|
return (
|
|
<View style={wrapCellStyle}>
|
|
<View style={styles.linesContainer}>
|
|
{keyedRows.map(({ key, row }) => {
|
|
if (row.kind === "header") {
|
|
return (
|
|
<View key={key} style={styles.splitHeaderRow}>
|
|
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
const line = side === "left" ? row.left : row.right;
|
|
const reviewRowState = getSplitInlineReviewThreadState({
|
|
left: row.left?.reviewTarget,
|
|
right: row.right?.reviewTarget,
|
|
reviewActions,
|
|
});
|
|
return (
|
|
<View key={key}>
|
|
<SplitDiffLine
|
|
line={line}
|
|
gutterWidth={gutterWidth}
|
|
wrapLines={wrapLines}
|
|
reviewActions={reviewActions}
|
|
/>
|
|
<InlineReviewRow
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
gutterWidth={gutterWidth}
|
|
reservedHeight={reviewRowState?.height}
|
|
/>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={rowCellStyle}>
|
|
<View style={styles.gutterColumn}>
|
|
{keyedRows.map(({ key, row }) => {
|
|
if (row.kind === "header") {
|
|
return (
|
|
<DiffGutterCell key={key} lineNumber={null} type="header" gutterWidth={gutterWidth} />
|
|
);
|
|
}
|
|
const line = side === "left" ? row.left : row.right;
|
|
const reviewTargetKey = line?.reviewTarget?.key ?? null;
|
|
const reviewRowState = getSplitInlineReviewThreadState({
|
|
left: row.left?.reviewTarget,
|
|
right: row.right?.reviewTarget,
|
|
reviewActions,
|
|
});
|
|
return (
|
|
<View key={key}>
|
|
<DiffGutterCell
|
|
lineNumber={line?.lineNumber ?? null}
|
|
type={line?.type}
|
|
gutterWidth={gutterWidth}
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
isLineHovered={
|
|
reviewTargetKey !== null && hoveredReviewTargetKey === reviewTargetKey
|
|
}
|
|
/>
|
|
<InlineReviewGutterSpacer
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
gutterWidth={gutterWidth}
|
|
reservedHeight={reviewRowState?.height}
|
|
/>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
<DiffScroll
|
|
scrollViewWidth={scrollWidth}
|
|
onScrollViewWidthChange={setScrollWidth}
|
|
style={styles.splitColumnScroll}
|
|
contentContainerStyle={styles.diffContentInner}
|
|
>
|
|
<View style={linesContainerRowStyle}>
|
|
{keyedRows.map(({ key, row }) => {
|
|
if (row.kind === "header") {
|
|
return (
|
|
<View key={key} style={styles.splitHeaderRow}>
|
|
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
const line = side === "left" ? row.left : row.right;
|
|
const reviewTargetKey = line?.reviewTarget?.key ?? null;
|
|
const reviewRowState = getSplitInlineReviewThreadState({
|
|
left: row.left?.reviewTarget,
|
|
right: row.right?.reviewTarget,
|
|
reviewActions,
|
|
});
|
|
return (
|
|
<View key={key}>
|
|
<SplitTextLine
|
|
line={line}
|
|
wrapLines={false}
|
|
reviewActions={reviewActions}
|
|
hoverTargetKey={reviewTargetKey}
|
|
onHoverTargetChange={setHoveredReviewTargetKey}
|
|
/>
|
|
<InlineReviewThreadContent
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
reservedHeight={reviewRowState?.height}
|
|
viewportWidth={scrollWidth}
|
|
pinToViewport
|
|
/>
|
|
</View>
|
|
);
|
|
})}
|
|
</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]);
|
|
|
|
const handleLayout = useCallback(
|
|
(event: LayoutChangeEvent) => {
|
|
layoutYRef.current = event.nativeEvent.layout.y;
|
|
onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height);
|
|
},
|
|
[file.path, onHeaderHeightChange],
|
|
);
|
|
|
|
const handlePressIn = useCallback((event: { nativeEvent: { pageX: number; pageY: number } }) => {
|
|
pressHandledRef.current = false;
|
|
pressInRef.current = {
|
|
ts: Date.now(),
|
|
pageX: event.nativeEvent.pageX,
|
|
pageY: event.nativeEvent.pageY,
|
|
};
|
|
}, []);
|
|
|
|
const handlePressOut = useCallback(
|
|
(event: { nativeEvent: { pageX: number; pageY: number } }) => {
|
|
if (isNative && !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);
|
|
if (durationMs <= 500 && distance <= 12) {
|
|
toggleExpanded();
|
|
}
|
|
}
|
|
},
|
|
[toggleExpanded],
|
|
);
|
|
|
|
const containerStyle = useMemo(
|
|
() => [styles.fileSectionHeaderContainer, isExpanded && styles.fileSectionHeaderExpanded],
|
|
[isExpanded],
|
|
);
|
|
|
|
return (
|
|
<View style={containerStyle} onLayout={handleLayout} testID={testID}>
|
|
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable
|
|
testID={testID ? `${testID}-toggle` : undefined}
|
|
style={fileHeaderPressableStyle}
|
|
// Android: prevent parent pan/scroll gestures from canceling the tap release.
|
|
cancelable={false}
|
|
onPressIn={handlePressIn}
|
|
onPressOut={handlePressOut}
|
|
onPress={toggleExpanded}
|
|
>
|
|
<View style={styles.fileHeaderLeft}>
|
|
<Text style={styles.fileName} numberOfLines={1}>
|
|
{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}>
|
|
<DiffStat additions={file.additions} deletions={file.deletions} />
|
|
</View>
|
|
</Pressable>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom" align="start" offset={6} maxWidth={520}>
|
|
<Text style={styles.tooltipText}>{file.path}</Text>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</View>
|
|
);
|
|
});
|
|
|
|
function DiffFileBody({
|
|
file,
|
|
layout,
|
|
wrapLines,
|
|
reviewActions,
|
|
onBodyHeightChange,
|
|
testID,
|
|
}: {
|
|
file: ParsedDiffFile;
|
|
layout: "unified" | "split";
|
|
wrapLines: boolean;
|
|
reviewActions?: InlineReviewActions;
|
|
onBodyHeightChange?: (file: ParsedDiffFile, height: number) => void;
|
|
testID?: string;
|
|
}) {
|
|
const [scrollViewWidth, setScrollViewWidth] = useState(0);
|
|
const [bodyWidth, setBodyWidth] = useState(0);
|
|
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null);
|
|
const { theme } = useUnistyles();
|
|
|
|
const handleLayout = useCallback(
|
|
(event: LayoutChangeEvent) => {
|
|
setBodyWidth(event.nativeEvent.layout.width);
|
|
onBodyHeightChange?.(file, event.nativeEvent.layout.height);
|
|
},
|
|
[file, onBodyHeightChange],
|
|
);
|
|
|
|
const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth;
|
|
const linesContainerRowStyle = useMemo(
|
|
() => [
|
|
styles.linesContainer,
|
|
availableWidth > 0 && inlineUnistylesStyle({ minWidth: availableWidth }),
|
|
],
|
|
[availableWidth],
|
|
);
|
|
|
|
return (
|
|
<View style={FILE_SECTION_BODY_STYLE} onLayout={handleLayout} 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, theme.fontSize.code);
|
|
|
|
if (layout === "split") {
|
|
const rows = buildSplitDiffRows(file);
|
|
return (
|
|
<View style={DIFF_CONTENT_SPLIT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
|
|
<SplitDiffColumn
|
|
rows={rows}
|
|
side="left"
|
|
gutterWidth={gutterWidth}
|
|
wrapLines={wrapLines}
|
|
reviewActions={reviewActions}
|
|
/>
|
|
<SplitDiffColumn
|
|
rows={rows}
|
|
side="right"
|
|
gutterWidth={gutterWidth}
|
|
wrapLines={wrapLines}
|
|
reviewActions={reviewActions}
|
|
showDivider
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const computedLines = buildUnifiedDiffLines(file);
|
|
|
|
if (wrapLines) {
|
|
return (
|
|
<View style={styles.diffContent} dataSet={CODE_SURFACE_DATASET}>
|
|
<View style={styles.linesContainer}>
|
|
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
|
|
<View key={key}>
|
|
<DiffLineView
|
|
line={line}
|
|
lineNumber={lineNumber}
|
|
gutterWidth={gutterWidth}
|
|
wrapLines={wrapLines}
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
/>
|
|
<InlineReviewRow
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
gutterWidth={gutterWidth}
|
|
/>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const textViewportWidth =
|
|
scrollViewWidth > 0 ? scrollViewWidth : Math.max(0, bodyWidth - gutterWidth);
|
|
return (
|
|
<View style={DIFF_CONTENT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
|
|
<View style={styles.gutterColumn}>
|
|
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
|
|
<View key={key}>
|
|
<DiffGutterCell
|
|
lineNumber={lineNumber}
|
|
type={line.type}
|
|
gutterWidth={gutterWidth}
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
isLineHovered={
|
|
reviewTarget?.key !== undefined && hoveredReviewTargetKey === reviewTarget.key
|
|
}
|
|
/>
|
|
<InlineReviewGutterSpacer
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
gutterWidth={gutterWidth}
|
|
/>
|
|
</View>
|
|
))}
|
|
</View>
|
|
<DiffScroll
|
|
scrollViewWidth={scrollViewWidth}
|
|
onScrollViewWidthChange={setScrollViewWidth}
|
|
style={styles.splitColumnScroll}
|
|
contentContainerStyle={styles.diffContentInner}
|
|
>
|
|
<View style={linesContainerRowStyle}>
|
|
{computedLines.map(({ line, key, reviewTarget }) => (
|
|
<View key={key}>
|
|
<DiffTextLine
|
|
line={line}
|
|
wrapLines={false}
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
hoverTargetKey={reviewTarget?.key ?? null}
|
|
onHoverTargetChange={setHoveredReviewTargetKey}
|
|
/>
|
|
<InlineReviewThreadContent
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
viewportWidth={textViewportWidth}
|
|
pinToViewport
|
|
/>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</DiffScroll>
|
|
</View>
|
|
);
|
|
})()}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
interface GitDiffPaneProps {
|
|
serverId: string;
|
|
workspaceId?: string | null;
|
|
cwd: string;
|
|
hideHeaderRow?: boolean;
|
|
enabled?: boolean;
|
|
}
|
|
|
|
type PressableStyleFn = (
|
|
state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
|
|
) => StyleProp<ViewStyle>;
|
|
|
|
interface DiffLayoutToggleGroupProps {
|
|
layout: "unified" | "split";
|
|
unifiedToggleStyle: PressableStyleFn;
|
|
splitToggleStyle: PressableStyleFn;
|
|
onUnified: () => void;
|
|
onSplit: () => void;
|
|
}
|
|
|
|
function DiffLayoutToggleGroup({
|
|
layout,
|
|
unifiedToggleStyle,
|
|
splitToggleStyle,
|
|
onUnified,
|
|
onSplit,
|
|
}: DiffLayoutToggleGroupProps) {
|
|
const { theme } = useUnistyles();
|
|
return (
|
|
<View style={styles.toggleButtonGroup}>
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Unified diff"
|
|
testID="changes-layout-unified"
|
|
onPress={onUnified}
|
|
style={unifiedToggleStyle}
|
|
>
|
|
<AlignJustify
|
|
size={14}
|
|
color={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={onSplit}
|
|
style={splitToggleStyle}
|
|
>
|
|
<Columns2
|
|
size={14}
|
|
color={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>
|
|
);
|
|
}
|
|
|
|
interface DiffWhitespaceToggleProps {
|
|
hideWhitespace: boolean;
|
|
isMobile: boolean;
|
|
toggleStyle: PressableStyleFn;
|
|
onToggle: () => void;
|
|
}
|
|
|
|
function DiffWhitespaceToggle({
|
|
hideWhitespace,
|
|
isMobile,
|
|
toggleStyle,
|
|
onToggle,
|
|
}: DiffWhitespaceToggleProps) {
|
|
const { theme } = useUnistyles();
|
|
return (
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Hide whitespace"
|
|
testID="changes-toggle-whitespace"
|
|
style={toggleStyle}
|
|
onPress={onToggle}
|
|
>
|
|
<Pilcrow
|
|
size={isMobile ? 18 : 14}
|
|
color={hideWhitespace ? theme.colors.foreground : theme.colors.foregroundMuted}
|
|
/>
|
|
</Pressable>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<Text style={styles.tooltipText}>Hide whitespace</Text>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
interface DiffFilesToolbarProps {
|
|
wrapLines: boolean;
|
|
allExpanded: boolean;
|
|
isMobile: boolean;
|
|
wrapLinesToggleStyle: PressableStyleFn;
|
|
expandAllToggleStyle: PressableStyleFn;
|
|
onToggleWrapLines: () => void;
|
|
onToggleExpandAll: () => void;
|
|
}
|
|
|
|
function DiffFilesToolbar({
|
|
wrapLines,
|
|
allExpanded,
|
|
isMobile,
|
|
wrapLinesToggleStyle,
|
|
expandAllToggleStyle,
|
|
onToggleWrapLines,
|
|
onToggleExpandAll,
|
|
}: DiffFilesToolbarProps) {
|
|
const { theme } = useUnistyles();
|
|
return (
|
|
<View style={styles.diffStatusButtons}>
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable style={wrapLinesToggleStyle} onPress={onToggleWrapLines}>
|
|
<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={expandAllToggleStyle} onPress={onToggleExpandAll}>
|
|
{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>
|
|
);
|
|
}
|
|
|
|
interface DiffRefreshButtonProps {
|
|
isRefreshing: boolean;
|
|
toggleStyle: PressableStyleFn;
|
|
onPress: () => void;
|
|
}
|
|
|
|
const ThemedRotateCw = withUnistyles(RotateCw);
|
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|
const refreshIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
|
|
|
function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) {
|
|
return (
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={isRefreshing ? "Refreshing" : "Refresh git and GitHub state"}
|
|
testID="changes-refresh"
|
|
style={toggleStyle}
|
|
onPress={onPress}
|
|
disabled={isRefreshing}
|
|
>
|
|
<View style={styles.refreshIcon}>
|
|
{isRefreshing ? (
|
|
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} />
|
|
) : (
|
|
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} />
|
|
)}
|
|
</View>
|
|
</Pressable>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<Text style={styles.tooltipText}>Refresh</Text>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
type DiffFlatItem =
|
|
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
|
|
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
|
|
type DiffFlatItemLayoutGetter = NonNullable<FlatListProps<DiffFlatItem>["getItemLayout"]>;
|
|
|
|
function getUnifiedDiffLineCount(file: ParsedDiffFile): number {
|
|
let lineCount = 0;
|
|
for (const hunk of file.hunks) {
|
|
lineCount += hunk.lines.length;
|
|
}
|
|
return lineCount;
|
|
}
|
|
|
|
function getDiffContentLength(file: ParsedDiffFile): number {
|
|
let contentLength = 0;
|
|
for (const hunk of file.hunks) {
|
|
for (const line of hunk.lines) {
|
|
contentLength += line.content.length;
|
|
}
|
|
}
|
|
return contentLength;
|
|
}
|
|
|
|
function computeEmptyMessage(
|
|
hideWhitespace: boolean,
|
|
diffMode: "uncommitted" | "base",
|
|
baseRefLabel: string,
|
|
): string {
|
|
if (hideWhitespace) {
|
|
return "No visible changes after hiding whitespace";
|
|
}
|
|
if (diffMode === "uncommitted") {
|
|
return "No uncommitted changes";
|
|
}
|
|
return `No changes vs ${baseRefLabel}`;
|
|
}
|
|
|
|
interface DiffBodyContentProps {
|
|
isStatusLoading: boolean;
|
|
statusErrorMessage: string | null;
|
|
notGit: boolean;
|
|
isDiffLoading: boolean;
|
|
diffErrorMessage: string | null;
|
|
hasChanges: boolean;
|
|
emptyMessage: string;
|
|
flatItems: DiffFlatItem[];
|
|
stickyHeaderIndices: number[];
|
|
renderFlatItem: ({ item }: { item: DiffFlatItem }) => ReactElement;
|
|
flatKeyExtractor: (item: DiffFlatItem) => string;
|
|
getFlatItemLayout: DiffFlatItemLayoutGetter;
|
|
flatExtraData: unknown;
|
|
diffListRef: RefObject<FlatList<DiffFlatItem> | null>;
|
|
handleDiffListLayout: (event: LayoutChangeEvent) => void;
|
|
handleDiffListScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
|
onContentSizeChange: (width: number, height: number) => void;
|
|
showDesktopWebScrollbar: boolean;
|
|
foregroundMutedColor: string;
|
|
}
|
|
|
|
function DiffBodyContent({
|
|
isStatusLoading,
|
|
statusErrorMessage,
|
|
notGit,
|
|
isDiffLoading,
|
|
diffErrorMessage,
|
|
hasChanges,
|
|
emptyMessage,
|
|
flatItems,
|
|
stickyHeaderIndices,
|
|
renderFlatItem,
|
|
flatKeyExtractor,
|
|
getFlatItemLayout,
|
|
flatExtraData,
|
|
diffListRef,
|
|
handleDiffListLayout,
|
|
handleDiffListScroll,
|
|
onContentSizeChange,
|
|
showDesktopWebScrollbar,
|
|
foregroundMutedColor,
|
|
}: DiffBodyContentProps) {
|
|
if (isStatusLoading) {
|
|
return (
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator size="large" color={foregroundMutedColor} />
|
|
<Text style={styles.loadingText}>Checking repository...</Text>
|
|
</View>
|
|
);
|
|
}
|
|
if (statusErrorMessage) {
|
|
return (
|
|
<View style={styles.errorContainer}>
|
|
<Text style={styles.errorText}>{statusErrorMessage}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
if (notGit) {
|
|
return (
|
|
<View style={styles.emptyContainer} testID="changes-not-git">
|
|
<Text style={styles.emptyText}>Not a git repository</Text>
|
|
</View>
|
|
);
|
|
}
|
|
if (isDiffLoading) {
|
|
return (
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator size="large" color={foregroundMutedColor} />
|
|
</View>
|
|
);
|
|
}
|
|
if (diffErrorMessage) {
|
|
return (
|
|
<View style={styles.errorContainer}>
|
|
<Text style={styles.errorText}>{diffErrorMessage}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
if (!hasChanges) {
|
|
return (
|
|
<View style={styles.emptyContainer}>
|
|
<Text style={styles.emptyText}>{emptyMessage}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
return (
|
|
<FlatList
|
|
ref={diffListRef}
|
|
data={flatItems}
|
|
renderItem={renderFlatItem}
|
|
keyExtractor={flatKeyExtractor}
|
|
getItemLayout={getFlatItemLayout}
|
|
stickyHeaderIndices={stickyHeaderIndices}
|
|
extraData={flatExtraData}
|
|
style={styles.scrollView}
|
|
contentContainerStyle={styles.contentContainer}
|
|
testID="git-diff-scroll"
|
|
onLayout={handleDiffListLayout}
|
|
onScroll={handleDiffListScroll}
|
|
onContentSizeChange={onContentSizeChange}
|
|
scrollEventThrottle={16}
|
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
|
// 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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface DeriveStatusStateInputs {
|
|
status: ReturnType<typeof useCheckoutStatusQuery>["status"];
|
|
isStatusLoading: boolean;
|
|
isStatusError: boolean;
|
|
statusError: unknown;
|
|
}
|
|
|
|
interface DerivedStatusState {
|
|
gitStatus: NonNullable<ReturnType<typeof useCheckoutStatusQuery>["status"]> | null;
|
|
isGit: boolean;
|
|
notGit: boolean;
|
|
statusErrorMessage: string | null;
|
|
baseRef: string | undefined;
|
|
hasUncommittedChanges: boolean;
|
|
actionsDisabled: boolean;
|
|
}
|
|
|
|
function deriveStatusState({
|
|
status,
|
|
isStatusLoading,
|
|
isStatusError,
|
|
statusError,
|
|
}: DeriveStatusStateInputs): DerivedStatusState {
|
|
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;
|
|
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
|
|
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
|
return {
|
|
gitStatus,
|
|
isGit,
|
|
notGit,
|
|
statusErrorMessage,
|
|
baseRef,
|
|
hasUncommittedChanges,
|
|
actionsDisabled,
|
|
};
|
|
}
|
|
|
|
function computeBaseRefLabel(baseRef: string | undefined): string {
|
|
if (!baseRef) return "base";
|
|
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
|
|
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
|
|
}
|
|
|
|
function computeCommittedDiffDescription(
|
|
branchLabel: string,
|
|
baseRefLabel: string,
|
|
): string | undefined {
|
|
if (!branchLabel || !baseRefLabel) {
|
|
return undefined;
|
|
}
|
|
return branchLabel === baseRefLabel ? undefined : `${branchLabel} -> ${baseRefLabel}`;
|
|
}
|
|
|
|
function computePrErrorMessage(
|
|
githubFeaturesEnabled: boolean,
|
|
prPayloadError: { message?: string } | null | undefined,
|
|
): string | null {
|
|
if (!githubFeaturesEnabled) return null;
|
|
return prPayloadError?.message ?? null;
|
|
}
|
|
|
|
function buildDiffModeTriggerStyle(surfaceColor: string): PressableStyleFn {
|
|
return ({ hovered, pressed, open }) => [
|
|
styles.diffModeTrigger,
|
|
(Boolean(hovered) || pressed || Boolean(open)) &&
|
|
inlineUnistylesStyle({ backgroundColor: surfaceColor }),
|
|
];
|
|
}
|
|
|
|
function buildExpandAllButtonStyle(surfaceColor: string): PressableStyleFn {
|
|
return ({ hovered, pressed }) => [
|
|
styles.expandAllButton,
|
|
(Boolean(hovered) || pressed) && inlineUnistylesStyle({ backgroundColor: surfaceColor }),
|
|
];
|
|
}
|
|
|
|
function buildToggleButtonStyle(
|
|
selected: boolean,
|
|
baseStyles: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
|
|
surfaceColor: string,
|
|
): PressableStyleFn {
|
|
return ({ hovered, pressed }) => [
|
|
baseStyles,
|
|
(selected || Boolean(hovered) || pressed) &&
|
|
inlineUnistylesStyle({ backgroundColor: surfaceColor }),
|
|
];
|
|
}
|
|
|
|
function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean }): boolean {
|
|
return input.paneEnabled && input.isGit;
|
|
}
|
|
|
|
export function GitDiffPane({
|
|
serverId,
|
|
workspaceId,
|
|
cwd,
|
|
hideHeaderRow,
|
|
enabled,
|
|
}: GitDiffPaneProps) {
|
|
const { theme } = useUnistyles();
|
|
const isMobile = useIsCompactFormFactor();
|
|
const showDesktopWebScrollbar = isWeb && !isMobile;
|
|
const canUseSplitLayout = isWeb && !isMobile;
|
|
const [diffModeOverride, setDiffModeOverride] = useState<ReviewDraftMode | null>(null);
|
|
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]);
|
|
|
|
// handleSelectUncommitted/handleSelectBase are defined later, after reviewDraftScopeKey
|
|
// and setActiveReviewMode are available, so they can record the active review mode.
|
|
|
|
const handleLayoutUnified = useCallback(() => {
|
|
handleLayoutChange("unified");
|
|
}, [handleLayoutChange]);
|
|
|
|
const handleLayoutSplit = useCallback(() => {
|
|
handleLayoutChange("split");
|
|
}, [handleLayoutChange]);
|
|
|
|
const controlSurfaceColor = theme.colors.surface2;
|
|
const diffModeTriggerStyle = useMemo(
|
|
() => buildDiffModeTriggerStyle(controlSurfaceColor),
|
|
[controlSurfaceColor],
|
|
);
|
|
|
|
const unifiedToggleStyle = useMemo(
|
|
() =>
|
|
buildToggleButtonStyle(
|
|
changesPreferences.layout === "unified",
|
|
[styles.toggleButton, styles.toggleButtonGroupStart],
|
|
controlSurfaceColor,
|
|
),
|
|
[changesPreferences.layout, controlSurfaceColor],
|
|
);
|
|
|
|
const splitToggleStyle = useMemo(
|
|
() =>
|
|
buildToggleButtonStyle(
|
|
changesPreferences.layout === "split",
|
|
[styles.toggleButton, styles.toggleButtonGroupEnd],
|
|
controlSurfaceColor,
|
|
),
|
|
[changesPreferences.layout, controlSurfaceColor],
|
|
);
|
|
|
|
const hideWhitespaceToggleStyle = useMemo(
|
|
() =>
|
|
buildToggleButtonStyle(
|
|
changesPreferences.hideWhitespace,
|
|
styles.expandAllButton,
|
|
controlSurfaceColor,
|
|
),
|
|
[changesPreferences.hideWhitespace, controlSurfaceColor],
|
|
);
|
|
|
|
const wrapLinesToggleStyle = useMemo(
|
|
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton, controlSurfaceColor),
|
|
[wrapLines, controlSurfaceColor],
|
|
);
|
|
|
|
const expandAllToggleStyle = useMemo(
|
|
() => buildExpandAllButtonStyle(controlSurfaceColor),
|
|
[controlSurfaceColor],
|
|
);
|
|
|
|
const refreshToggleStyle = useMemo(
|
|
() => buildExpandAllButtonStyle(controlSurfaceColor),
|
|
[controlSurfaceColor],
|
|
);
|
|
|
|
const toast = useToast();
|
|
const refreshSupported = useSessionStore(
|
|
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
|
|
);
|
|
const runRefresh = useCheckoutGitActionsStore((s) => s.refresh);
|
|
const isRefreshing =
|
|
useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) ===
|
|
"pending";
|
|
|
|
const handleRefresh = useCallback(() => {
|
|
if (isRefreshing) {
|
|
return;
|
|
}
|
|
void runRefresh({ serverId, cwd }).catch((error) => {
|
|
toast.error(error instanceof Error ? error.message : "Failed to refresh git state.");
|
|
});
|
|
}, [cwd, isRefreshing, runRefresh, serverId, toast]);
|
|
|
|
const {
|
|
status,
|
|
isLoading: isStatusLoading,
|
|
isError: isStatusError,
|
|
error: statusError,
|
|
} = useCheckoutStatusQuery({ serverId, cwd });
|
|
const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError });
|
|
const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges } = statusState;
|
|
|
|
// Auto-select diff mode based on state: uncommitted when dirty, base when clean
|
|
const autoDiffMode: ReviewDraftMode = hasUncommittedChanges ? "uncommitted" : "base";
|
|
const reviewDraftScopeKey = useMemo(
|
|
() =>
|
|
buildReviewDraftScopeKey({
|
|
serverId,
|
|
workspaceId,
|
|
cwd,
|
|
baseRef,
|
|
ignoreWhitespace: changesPreferences.hideWhitespace,
|
|
}),
|
|
[baseRef, changesPreferences.hideWhitespace, cwd, serverId, workspaceId],
|
|
);
|
|
const activeReviewMode = useActiveReviewDraftMode({ scopeKey: reviewDraftScopeKey });
|
|
const diffMode = diffModeOverride ?? activeReviewMode ?? autoDiffMode;
|
|
|
|
const {
|
|
files,
|
|
payloadError: diffPayloadError,
|
|
isLoading: isDiffLoading,
|
|
} = useCheckoutDiffQuery({
|
|
serverId,
|
|
cwd,
|
|
mode: diffMode,
|
|
baseRef,
|
|
ignoreWhitespace: changesPreferences.hideWhitespace,
|
|
enabled: shouldEnableCheckoutDiff({ paneEnabled: enabled !== false, isGit }),
|
|
});
|
|
const reviewDraftKey = useMemo(
|
|
() =>
|
|
buildReviewDraftKey({
|
|
serverId,
|
|
workspaceId,
|
|
cwd,
|
|
mode: diffMode,
|
|
baseRef,
|
|
ignoreWhitespace: changesPreferences.hideWhitespace,
|
|
}),
|
|
[baseRef, changesPreferences.hideWhitespace, cwd, diffMode, serverId, workspaceId],
|
|
);
|
|
const setActiveReviewMode = useSetActiveReviewDraftMode();
|
|
|
|
const handleSelectUncommitted = useCallback(() => {
|
|
setDiffModeOverride("uncommitted");
|
|
setActiveReviewMode({ scopeKey: reviewDraftScopeKey, mode: "uncommitted" });
|
|
}, [reviewDraftScopeKey, setActiveReviewMode]);
|
|
|
|
const handleSelectBase = useCallback(() => {
|
|
setDiffModeOverride("base");
|
|
setActiveReviewMode({ scopeKey: reviewDraftScopeKey, mode: "base" });
|
|
}, [reviewDraftScopeKey, setActiveReviewMode]);
|
|
|
|
const reviewActions = useInlineReviewController({
|
|
reviewDraftKey,
|
|
});
|
|
const reviewAttachment = useReviewAttachmentSnapshot({
|
|
key: reviewDraftKey,
|
|
diffFiles: files,
|
|
cwd,
|
|
mode: diffMode,
|
|
baseRef,
|
|
});
|
|
const workspaceAttachmentScopeKey = useMemo(
|
|
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd }),
|
|
[cwd, serverId, workspaceId],
|
|
);
|
|
const setWorkspaceAttachments = useWorkspaceAttachmentsStore(
|
|
(state) => state.setWorkspaceAttachments,
|
|
);
|
|
const clearWorkspaceAttachments = useWorkspaceAttachmentsStore(
|
|
(state) => state.clearWorkspaceAttachments,
|
|
);
|
|
|
|
useEffect(() => {
|
|
setWorkspaceAttachments({
|
|
scopeKey: workspaceAttachmentScopeKey,
|
|
attachments: reviewAttachment ? [reviewAttachment] : [],
|
|
});
|
|
|
|
return () => {
|
|
clearWorkspaceAttachments({ scopeKey: workspaceAttachmentScopeKey });
|
|
};
|
|
}, [
|
|
clearWorkspaceAttachments,
|
|
reviewAttachment,
|
|
setWorkspaceAttachments,
|
|
workspaceAttachmentScopeKey,
|
|
]);
|
|
const { githubFeaturesEnabled, payloadError: prPayloadError } = useCheckoutPrStatusQuery({
|
|
serverId,
|
|
cwd,
|
|
enabled: isGit,
|
|
});
|
|
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 bodyHeightByKeyRef = useRef<Record<string, number>>({});
|
|
const defaultHeaderHeightRef = useRef<number>(44);
|
|
const [heightVersion, setHeightVersion] = useState(0);
|
|
const diffBodyLineHeight = theme.lineHeight.diff;
|
|
const diffBodyChromeHeight = theme.borderWidth[1] * 2;
|
|
const statusBodyHeightEstimate = diffBodyChromeHeight + theme.spacing[4] * 2 + diffBodyLineHeight;
|
|
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 getBodyHeightKey = useCallback(
|
|
(file: ParsedDiffFile): string => {
|
|
if (file.status === "too_large" || file.status === "binary") {
|
|
return `${effectiveLayout}:${wrapLines ? "wrap" : "scroll"}:${file.path}:${file.status}`;
|
|
}
|
|
|
|
return [
|
|
effectiveLayout,
|
|
wrapLines ? "wrap" : "scroll",
|
|
file.path,
|
|
file.status ?? "ok",
|
|
file.additions,
|
|
file.deletions,
|
|
file.hunks.length,
|
|
getUnifiedDiffLineCount(file),
|
|
getDiffContentLength(file),
|
|
].join(":");
|
|
},
|
|
[effectiveLayout, wrapLines],
|
|
);
|
|
|
|
const estimateBodyHeight = useCallback(
|
|
(file: ParsedDiffFile): number => {
|
|
if (file.status === "too_large" || file.status === "binary") {
|
|
return statusBodyHeightEstimate;
|
|
}
|
|
|
|
const lineCount =
|
|
effectiveLayout === "split"
|
|
? buildSplitDiffRows(file).length
|
|
: getUnifiedDiffLineCount(file);
|
|
return diffBodyChromeHeight + lineCount * diffBodyLineHeight;
|
|
},
|
|
[diffBodyChromeHeight, diffBodyLineHeight, effectiveLayout, statusBodyHeightEstimate],
|
|
);
|
|
|
|
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
|
|
if (!Number.isFinite(height) || height <= 0) {
|
|
return;
|
|
}
|
|
const previousHeight = headerHeightByPathRef.current[path];
|
|
if (
|
|
previousHeight !== undefined &&
|
|
Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON
|
|
) {
|
|
return;
|
|
}
|
|
headerHeightByPathRef.current[path] = height;
|
|
defaultHeaderHeightRef.current = height;
|
|
setHeightVersion((version) => version + 1);
|
|
}, []);
|
|
|
|
const handleBodyHeightChange = useCallback(
|
|
(file: ParsedDiffFile, height: number) => {
|
|
if (!Number.isFinite(height) || height < 0) {
|
|
return;
|
|
}
|
|
const heightKey = getBodyHeightKey(file);
|
|
const previousHeight = bodyHeightByKeyRef.current[heightKey];
|
|
if (
|
|
previousHeight !== undefined &&
|
|
Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON
|
|
) {
|
|
return;
|
|
}
|
|
bodyHeightByKeyRef.current[heightKey] = height;
|
|
setHeightVersion((version) => version + 1);
|
|
},
|
|
[getBodyHeightKey],
|
|
);
|
|
|
|
const handleDiffListScroll = useCallback(
|
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
|
scrollbar.onScroll(event);
|
|
},
|
|
[scrollbar],
|
|
);
|
|
|
|
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],
|
|
);
|
|
|
|
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)) {
|
|
const bodyHeightKey = getBodyHeightKey(file);
|
|
offset += bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(file);
|
|
}
|
|
}
|
|
return Math.max(0, offset);
|
|
},
|
|
[estimateBodyHeight, expandedPaths, files, getBodyHeightKey],
|
|
);
|
|
|
|
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]);
|
|
|
|
// Clear diff mode override when auto mode changes (e.g., after commit)
|
|
useEffect(() => {
|
|
setDiffModeOverride(null);
|
|
}, [autoDiffMode]);
|
|
|
|
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}
|
|
reviewActions={reviewActions}
|
|
onBodyHeightChange={handleBodyHeightChange}
|
|
testID={`diff-file-${item.fileIndex}-body`}
|
|
/>
|
|
);
|
|
},
|
|
[
|
|
effectiveLayout,
|
|
handleBodyHeightChange,
|
|
handleHeaderHeightChange,
|
|
handleToggleExpanded,
|
|
reviewActions,
|
|
wrapLines,
|
|
],
|
|
);
|
|
|
|
const flatKeyExtractor = useCallback(
|
|
(item: DiffFlatItem) => `${item.type}-${item.file.path}`,
|
|
[],
|
|
);
|
|
|
|
const getFlatItemHeight = useCallback(
|
|
(item: DiffFlatItem): number => {
|
|
if (item.type === "header") {
|
|
return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current;
|
|
}
|
|
|
|
const bodyHeightKey = getBodyHeightKey(item.file);
|
|
return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file);
|
|
},
|
|
[estimateBodyHeight, getBodyHeightKey],
|
|
);
|
|
|
|
const getFlatItemLayout = useCallback<DiffFlatItemLayoutGetter>(
|
|
(_data, index) => {
|
|
let offset = 0;
|
|
for (let itemIndex = 0; itemIndex < index; itemIndex += 1) {
|
|
const item = flatItems[itemIndex];
|
|
if (item) {
|
|
offset += getFlatItemHeight(item);
|
|
}
|
|
}
|
|
|
|
const item = flatItems[index];
|
|
const length = item ? getFlatItemHeight(item) : 0;
|
|
return { length, offset, index };
|
|
},
|
|
[flatItems, getFlatItemHeight],
|
|
);
|
|
|
|
const flatExtraData = useMemo(
|
|
() => ({
|
|
expandedPathsArray,
|
|
effectiveLayout,
|
|
heightVersion,
|
|
wrapLines,
|
|
reviewActions,
|
|
}),
|
|
[expandedPathsArray, effectiveLayout, heightVersion, wrapLines, reviewActions],
|
|
);
|
|
|
|
const hasChanges = files.length > 0;
|
|
const diffErrorMessage = diffPayloadError?.message ?? null;
|
|
const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError);
|
|
const baseRefLabel = useMemo(() => computeBaseRefLabel(baseRef), [baseRef]);
|
|
const iconColor = theme.colors.foregroundMuted;
|
|
const gitActionsIcons = useMemo(
|
|
() => ({
|
|
commit: <GitCommitHorizontal size={16} color={iconColor} />,
|
|
pull: <Download size={16} color={iconColor} />,
|
|
push: <Upload size={16} color={iconColor} />,
|
|
pullAndPush: <ArrowDownUp size={16} color={iconColor} />,
|
|
viewPr: <GitHubIcon size={16} color={iconColor} />,
|
|
createPr: <GitHubIcon size={16} color={iconColor} />,
|
|
mergePrSquash: <GitHubIcon size={16} color={iconColor} />,
|
|
mergePrMerge: <GitHubIcon size={16} color={iconColor} />,
|
|
mergePrRebase: <GitHubIcon size={16} color={iconColor} />,
|
|
merge: <GitMerge size={16} color={iconColor} />,
|
|
mergeFromBase: <RefreshCcw size={16} color={iconColor} />,
|
|
archive: <Archive size={16} color={iconColor} />,
|
|
}),
|
|
[iconColor],
|
|
);
|
|
const { gitActions, branchLabel } = useGitActions({ serverId, cwd, icons: gitActionsIcons });
|
|
const committedDiffDescription = useMemo(
|
|
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
|
|
[baseRefLabel, branchLabel],
|
|
);
|
|
|
|
const emptyMessage = computeEmptyMessage(
|
|
changesPreferences.hideWhitespace,
|
|
diffMode,
|
|
baseRefLabel,
|
|
);
|
|
|
|
const bodyContent: ReactElement = (
|
|
<DiffBodyContent
|
|
isStatusLoading={isStatusLoading}
|
|
statusErrorMessage={statusErrorMessage}
|
|
notGit={notGit}
|
|
isDiffLoading={isDiffLoading}
|
|
diffErrorMessage={diffErrorMessage}
|
|
hasChanges={hasChanges}
|
|
emptyMessage={emptyMessage}
|
|
flatItems={flatItems}
|
|
stickyHeaderIndices={stickyHeaderIndices}
|
|
renderFlatItem={renderFlatItem}
|
|
flatKeyExtractor={flatKeyExtractor}
|
|
getFlatItemLayout={getFlatItemLayout}
|
|
flatExtraData={flatExtraData}
|
|
diffListRef={diffListRef}
|
|
handleDiffListLayout={handleDiffListLayout}
|
|
handleDiffListScroll={handleDiffListScroll}
|
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
|
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
|
foregroundMutedColor={theme.colors.foregroundMuted}
|
|
/>
|
|
);
|
|
|
|
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={diffModeTriggerStyle}
|
|
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={handleSelectUncommitted}
|
|
>
|
|
Uncommitted
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
testID="changes-diff-mode-committed"
|
|
selected={diffMode === "base"}
|
|
description={committedDiffDescription}
|
|
onSelect={handleSelectBase}
|
|
>
|
|
Committed
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<View style={styles.diffStatusButtons}>
|
|
{canUseSplitLayout ? (
|
|
<DiffLayoutToggleGroup
|
|
layout={changesPreferences.layout}
|
|
unifiedToggleStyle={unifiedToggleStyle}
|
|
splitToggleStyle={splitToggleStyle}
|
|
onUnified={handleLayoutUnified}
|
|
onSplit={handleLayoutSplit}
|
|
/>
|
|
) : null}
|
|
<DiffWhitespaceToggle
|
|
hideWhitespace={changesPreferences.hideWhitespace}
|
|
isMobile={isMobile}
|
|
toggleStyle={hideWhitespaceToggleStyle}
|
|
onToggle={handleToggleHideWhitespace}
|
|
/>
|
|
{files.length > 0 ? (
|
|
<DiffFilesToolbar
|
|
wrapLines={wrapLines}
|
|
allExpanded={allExpanded}
|
|
isMobile={isMobile}
|
|
wrapLinesToggleStyle={wrapLinesToggleStyle}
|
|
expandAllToggleStyle={expandAllToggleStyle}
|
|
onToggleWrapLines={handleToggleWrapLines}
|
|
onToggleExpandAll={handleToggleExpandAll}
|
|
/>
|
|
) : null}
|
|
{refreshSupported ? (
|
|
<DiffRefreshButton
|
|
isRefreshing={isRefreshing}
|
|
toggleStyle={refreshToggleStyle}
|
|
onPress={handleRefresh}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
) : 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,
|
|
},
|
|
refreshIcon: {
|
|
width: ICON_SIZE.md,
|
|
height: ICON_SIZE.md,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
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",
|
|
paddingLeft: theme.spacing[3],
|
|
paddingRight: theme.spacing[2],
|
|
paddingVertical: theme.spacing[2],
|
|
gap: theme.spacing[1],
|
|
minWidth: 0,
|
|
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: 1,
|
|
minWidth: 0,
|
|
},
|
|
fileDir: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.foregroundMuted,
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
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.diffAddition,
|
|
},
|
|
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.diffDeletion,
|
|
},
|
|
additions: {
|
|
fontSize: theme.fontSize.xs,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.diffAddition,
|
|
},
|
|
deletions: {
|
|
fontSize: theme.fontSize.xs,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.diffDeletion,
|
|
},
|
|
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,
|
|
zIndex: 4,
|
|
elevation: 4,
|
|
overflow: "visible",
|
|
},
|
|
gutterCell: {
|
|
borderRightWidth: theme.borderWidth[1],
|
|
borderRightColor: theme.colors.border,
|
|
justifyContent: "flex-start",
|
|
zIndex: 4,
|
|
elevation: 4,
|
|
overflow: "visible",
|
|
},
|
|
inlineReviewRow: {
|
|
flexDirection: "row",
|
|
alignItems: "stretch",
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
inlineReviewGutterSpacer: {
|
|
borderRightWidth: theme.borderWidth[1],
|
|
borderRightColor: theme.colors.border,
|
|
backgroundColor: theme.colors.surface1,
|
|
flexShrink: 0,
|
|
},
|
|
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",
|
|
overflow: "visible",
|
|
},
|
|
lineNumberGutter: {
|
|
borderRightWidth: theme.borderWidth[1],
|
|
borderRightColor: theme.colors.border,
|
|
marginRight: theme.spacing[2],
|
|
alignSelf: "stretch",
|
|
justifyContent: "flex-start",
|
|
zIndex: 4,
|
|
elevation: 4,
|
|
overflow: "visible",
|
|
},
|
|
lineNumberText: {
|
|
width: "100%",
|
|
textAlign: "right",
|
|
paddingRight: theme.spacing[2],
|
|
fontSize: theme.fontSize.code,
|
|
lineHeight: theme.lineHeight.diff,
|
|
fontFamily: theme.fontFamily.mono,
|
|
color: theme.colors.foregroundMuted,
|
|
userSelect: "none",
|
|
},
|
|
addLineNumberText: {
|
|
color: theme.colors.diffAddition,
|
|
},
|
|
removeLineNumberText: {
|
|
color: theme.colors.diffDeletion,
|
|
},
|
|
diffLineText: {
|
|
flex: 1,
|
|
paddingRight: theme.spacing[3],
|
|
fontSize: theme.fontSize.code,
|
|
lineHeight: theme.lineHeight.diff,
|
|
fontFamily: theme.fontFamily.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,
|
|
},
|
|
}));
|
|
|
|
const HEADER_LINE_TEXT_STYLE = [styles.diffLineText, styles.headerLineText];
|
|
const FILE_SECTION_BODY_STYLE = [styles.fileSectionBodyContainer, styles.fileSectionBorder];
|
|
const DIFF_CONTENT_SPLIT_ROW_STYLE = [styles.diffContent, styles.splitRow];
|
|
const DIFF_CONTENT_ROW_STYLE = [styles.diffContent, styles.diffContentRow];
|
|
const DIFF_HEIGHT_CHANGE_EPSILON = 0.5;
|