* feat(app): refine tool call summaries and scrolling Keep summary groups stable from the first tool call, preserve live activity feedback, and scroll expanded groups to the latest entry. Consolidate web scrollbar styling around the native browser mechanism. * refactor(app): reuse tool call height constant * fix(app): preserve tool call summary state * fix(app): refresh web tool groups on expand * fix(app): preserve grouped tool errors
2873 lines
89 KiB
TypeScript
2873 lines
89 KiB
TypeScript
import {
|
|
useState,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
memo,
|
|
type ReactElement,
|
|
type ReactNode,
|
|
type RefObject,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
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, withUnistyles } from "react-native-unistyles";
|
|
import { BORDER_WIDTH, ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
|
|
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
|
import {
|
|
AlignJustify,
|
|
Archive,
|
|
ArrowDownUp,
|
|
ChevronDown,
|
|
Columns2,
|
|
Download,
|
|
FolderTree,
|
|
GitCommitHorizontal,
|
|
GitMerge,
|
|
List,
|
|
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 { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items";
|
|
import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree";
|
|
import { DiffFolderRow } from "@/git/diff-folder-row";
|
|
import { TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
|
|
import { SvgXml } from "react-native-svg";
|
|
import { getFileIconSvg } from "@/components/material-file-icons";
|
|
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
|
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
|
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
|
import { useAppSettings } from "@/hooks/use-settings";
|
|
import { DiffScroll } from "@/components/diff-scroll";
|
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
|
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 { GitActionsSplitButton } from "@/git/actions-split-button";
|
|
import { BranchSwitcher } from "@/components/branch-switcher";
|
|
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,
|
|
useReviewAttachmentSnapshot,
|
|
useResolvedDiffMode,
|
|
useSetDiffModeOverride,
|
|
type ReviewDraftComment,
|
|
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[];
|
|
textMetricsStyle: TextStyle;
|
|
wrapLines?: boolean;
|
|
testID?: string;
|
|
}
|
|
|
|
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 getNumericLineHeight(textMetricsStyle: TextStyle): number | undefined {
|
|
const { lineHeight } = textMetricsStyle;
|
|
return typeof lineHeight === "number" && Number.isFinite(lineHeight) ? lineHeight : undefined;
|
|
}
|
|
|
|
function useDiffRowMetricsStyle(textMetricsStyle: TextStyle): StyleProp<ViewStyle> {
|
|
const lineHeight = getNumericLineHeight(textMetricsStyle);
|
|
return useMemo(
|
|
() => (lineHeight !== undefined ? inlineUnistylesStyle({ minHeight: lineHeight }) : null),
|
|
[lineHeight],
|
|
);
|
|
}
|
|
|
|
function HighlightedToken({ token }: { token: HighlightToken }) {
|
|
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
|
}
|
|
|
|
function HighlightedText({
|
|
tokens,
|
|
textMetricsStyle,
|
|
wrapLines = false,
|
|
testID,
|
|
}: HighlightedTextProps) {
|
|
const containerStyle = useMemo(
|
|
() => [
|
|
styles.diffTextMetrics,
|
|
textMetricsStyle,
|
|
styles.diffLineText,
|
|
getWrappedTextStyle(wrapLines),
|
|
],
|
|
[textMetricsStyle, wrapLines],
|
|
);
|
|
|
|
const keyedTokens = useMemo(
|
|
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
|
[tokens],
|
|
);
|
|
|
|
return (
|
|
<Text style={containerStyle} testID={testID}>
|
|
{keyedTokens.map(({ key, token }) => (
|
|
<HighlightedToken key={key} token={token} />
|
|
))}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
interface DiffFileSectionProps {
|
|
file: ParsedDiffFile;
|
|
isExpanded: boolean;
|
|
/** Tree indentation level (0 on the flat/mobile path). */
|
|
depth?: number;
|
|
/** Show the muted directory suffix (flat list); false inside the folder tree. */
|
|
showDir?: 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,
|
|
textMetricsStyle,
|
|
reviewTarget,
|
|
reviewActions,
|
|
isLineHovered,
|
|
style,
|
|
textTestID,
|
|
actionTestID,
|
|
}: {
|
|
lineNumber: number | null;
|
|
type: DiffLine["type"] | undefined | null;
|
|
gutterWidth: number;
|
|
textMetricsStyle: TextStyle;
|
|
reviewTarget?: ReviewableDiffTarget | null;
|
|
reviewActions?: InlineReviewActions;
|
|
isLineHovered?: boolean;
|
|
style?: StyleProp<ViewStyle>;
|
|
textTestID?: string;
|
|
actionTestID?: string;
|
|
}) {
|
|
const lineHeight = getNumericLineHeight(textMetricsStyle);
|
|
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
|
|
const containerStyle = useMemo(
|
|
() => [
|
|
styles.gutterCell,
|
|
lineTypeBackground(type),
|
|
rowMetricsStyle,
|
|
inlineUnistylesStyle({ width: gutterWidth }),
|
|
style,
|
|
],
|
|
[type, rowMetricsStyle, gutterWidth, style],
|
|
);
|
|
const textStyle = useMemo(
|
|
() => [
|
|
styles.diffTextMetrics,
|
|
textMetricsStyle,
|
|
styles.lineNumberText,
|
|
type === "add" && styles.addLineNumberText,
|
|
type === "remove" && styles.removeLineNumberText,
|
|
],
|
|
[textMetricsStyle, 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}
|
|
lineHeight={lineHeight}
|
|
onStartComment={onStartComment}
|
|
style={containerStyle}
|
|
actionTestID={actionTestID}
|
|
>
|
|
<Text numberOfLines={1} style={textStyle} testID={textTestID}>
|
|
{formatDiffGutterText(lineNumber)}
|
|
</Text>
|
|
</InlineReviewGutterCell>
|
|
);
|
|
}
|
|
|
|
function DiffTextLine({
|
|
line,
|
|
wrapLines,
|
|
textMetricsStyle,
|
|
reviewTarget,
|
|
reviewActions,
|
|
onHoverChange,
|
|
hoverTargetKey,
|
|
onHoverTargetChange,
|
|
textTestID,
|
|
}: {
|
|
line: DiffLine;
|
|
wrapLines: boolean;
|
|
textMetricsStyle: TextStyle;
|
|
reviewTarget?: ReviewableDiffTarget | null;
|
|
reviewActions?: InlineReviewActions;
|
|
onHoverChange?: (hovered: boolean) => void;
|
|
hoverTargetKey?: string | null;
|
|
onHoverTargetChange?: (key: string | null) => void;
|
|
textTestID?: string;
|
|
}) {
|
|
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
|
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
|
|
|
|
const containerStyle = useMemo(
|
|
() => [styles.textLineContainer, lineTypeBackground(line.type), rowMetricsStyle],
|
|
[line.type, rowMetricsStyle],
|
|
);
|
|
const textStyle = useMemo(
|
|
() => [
|
|
styles.diffTextMetrics,
|
|
textMetricsStyle,
|
|
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, textMetricsStyle, wrapLines],
|
|
);
|
|
|
|
return (
|
|
<LongPressableLine
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
onHoverChange={onHoverChange}
|
|
hoverTargetKey={hoverTargetKey}
|
|
onHoverTargetChange={onHoverTargetChange}
|
|
style={containerStyle}
|
|
>
|
|
{line.type !== "header" && visibleTokens ? (
|
|
<HighlightedText
|
|
tokens={visibleTokens}
|
|
textMetricsStyle={textMetricsStyle}
|
|
wrapLines={wrapLines}
|
|
testID={textTestID}
|
|
/>
|
|
) : (
|
|
<Text style={textStyle} testID={textTestID}>
|
|
{formatDiffContentText(line.content)}
|
|
</Text>
|
|
)}
|
|
</LongPressableLine>
|
|
);
|
|
}
|
|
|
|
function SplitTextLine({
|
|
line,
|
|
wrapLines,
|
|
textMetricsStyle,
|
|
reviewActions,
|
|
onHoverChange,
|
|
hoverTargetKey,
|
|
onHoverTargetChange,
|
|
}: {
|
|
line: SplitDiffDisplayLine | null;
|
|
wrapLines: boolean;
|
|
textMetricsStyle: TextStyle;
|
|
reviewActions?: InlineReviewActions;
|
|
onHoverChange?: (hovered: boolean) => void;
|
|
hoverTargetKey?: string | null;
|
|
onHoverTargetChange?: (key: string | null) => void;
|
|
}) {
|
|
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
|
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
|
|
|
|
const containerStyle = useMemo(
|
|
() => [styles.textLineContainer, lineTypeBackground(line?.type), rowMetricsStyle],
|
|
[line?.type, rowMetricsStyle],
|
|
);
|
|
const textStyle = useMemo(
|
|
() => [
|
|
styles.diffTextMetrics,
|
|
textMetricsStyle,
|
|
styles.diffLineText,
|
|
getWrappedTextStyle(wrapLines),
|
|
line?.type === "add" && styles.addLineText,
|
|
line?.type === "remove" && styles.removeLineText,
|
|
line?.type === "context" && styles.contextLineText,
|
|
!line && styles.emptySplitCellText,
|
|
],
|
|
[line, textMetricsStyle, wrapLines],
|
|
);
|
|
|
|
return (
|
|
<LongPressableLine
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
onHoverChange={onHoverChange}
|
|
hoverTargetKey={hoverTargetKey}
|
|
onHoverTargetChange={onHoverTargetChange}
|
|
style={containerStyle}
|
|
>
|
|
{visibleTokens ? (
|
|
<HighlightedText
|
|
tokens={visibleTokens}
|
|
textMetricsStyle={textMetricsStyle}
|
|
wrapLines={wrapLines}
|
|
/>
|
|
) : (
|
|
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
|
|
)}
|
|
</LongPressableLine>
|
|
);
|
|
}
|
|
|
|
function DiffLineView({
|
|
line,
|
|
lineNumber,
|
|
gutterWidth,
|
|
wrapLines,
|
|
textMetricsStyle,
|
|
reviewTarget,
|
|
reviewActions,
|
|
}: {
|
|
line: DiffLine;
|
|
lineNumber: number | null;
|
|
gutterWidth: number;
|
|
wrapLines: boolean;
|
|
textMetricsStyle: TextStyle;
|
|
reviewTarget?: ReviewableDiffTarget | null;
|
|
reviewActions?: InlineReviewActions;
|
|
}) {
|
|
const [isLineHovered, setIsLineHovered] = useState(false);
|
|
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
|
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
|
|
|
|
const containerStyle = useMemo(
|
|
() => [styles.diffLineContainer, lineTypeBackground(line.type), rowMetricsStyle],
|
|
[line.type, rowMetricsStyle],
|
|
);
|
|
const textStyle = useMemo(
|
|
() => [
|
|
styles.diffTextMetrics,
|
|
textMetricsStyle,
|
|
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, textMetricsStyle, wrapLines],
|
|
);
|
|
|
|
return (
|
|
<LongPressableLine
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
onHoverChange={setIsLineHovered}
|
|
style={containerStyle}
|
|
>
|
|
<DiffGutterCell
|
|
lineNumber={lineNumber}
|
|
type={line.type}
|
|
gutterWidth={gutterWidth}
|
|
textMetricsStyle={textMetricsStyle}
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
isLineHovered={isLineHovered}
|
|
style={styles.lineNumberGutter}
|
|
/>
|
|
{line.type !== "header" && visibleTokens ? (
|
|
<HighlightedText
|
|
tokens={visibleTokens}
|
|
textMetricsStyle={textMetricsStyle}
|
|
wrapLines={wrapLines}
|
|
/>
|
|
) : (
|
|
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text>
|
|
)}
|
|
</LongPressableLine>
|
|
);
|
|
}
|
|
|
|
function SplitDiffLine({
|
|
line,
|
|
gutterWidth,
|
|
wrapLines,
|
|
textMetricsStyle,
|
|
reviewActions,
|
|
}: {
|
|
line: SplitDiffDisplayLine | null;
|
|
gutterWidth: number;
|
|
wrapLines: boolean;
|
|
textMetricsStyle: TextStyle;
|
|
reviewActions?: InlineReviewActions;
|
|
}) {
|
|
const [isLineHovered, setIsLineHovered] = useState(false);
|
|
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
|
const rowMetricsStyle = useDiffRowMetricsStyle(textMetricsStyle);
|
|
|
|
const containerStyle = useMemo(
|
|
() => [styles.diffLineContainer, lineTypeBackground(line?.type), rowMetricsStyle],
|
|
[line?.type, rowMetricsStyle],
|
|
);
|
|
const textStyle = useMemo(
|
|
() => [
|
|
styles.diffTextMetrics,
|
|
textMetricsStyle,
|
|
styles.diffLineText,
|
|
getWrappedTextStyle(wrapLines),
|
|
line?.type === "add" && styles.addLineText,
|
|
line?.type === "remove" && styles.removeLineText,
|
|
line?.type === "context" && styles.contextLineText,
|
|
!line && styles.emptySplitCellText,
|
|
],
|
|
[line, textMetricsStyle, wrapLines],
|
|
);
|
|
|
|
return (
|
|
<LongPressableLine
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
onHoverChange={setIsLineHovered}
|
|
style={containerStyle}
|
|
>
|
|
<DiffGutterCell
|
|
lineNumber={line?.lineNumber ?? null}
|
|
type={line?.type}
|
|
gutterWidth={gutterWidth}
|
|
textMetricsStyle={textMetricsStyle}
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
isLineHovered={isLineHovered}
|
|
style={styles.lineNumberGutter}
|
|
/>
|
|
{visibleTokens ? (
|
|
<HighlightedText
|
|
tokens={visibleTokens}
|
|
textMetricsStyle={textMetricsStyle}
|
|
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,
|
|
textMetricsStyle,
|
|
reviewActions,
|
|
showDivider = false,
|
|
}: {
|
|
rows: SplitDiffRow[];
|
|
side: "left" | "right";
|
|
gutterWidth: number;
|
|
wrapLines: boolean;
|
|
textMetricsStyle: TextStyle;
|
|
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 headerLineTextStyle = useMemo(
|
|
() => [styles.diffTextMetrics, textMetricsStyle, styles.diffLineText, styles.headerLineText],
|
|
[textMetricsStyle],
|
|
);
|
|
|
|
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={headerLineTextStyle}>{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}
|
|
textMetricsStyle={textMetricsStyle}
|
|
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}
|
|
textMetricsStyle={textMetricsStyle}
|
|
/>
|
|
);
|
|
}
|
|
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}
|
|
textMetricsStyle={textMetricsStyle}
|
|
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={headerLineTextStyle}>{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}
|
|
textMetricsStyle={textMetricsStyle}
|
|
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,
|
|
depth = 0,
|
|
showDir = true,
|
|
onToggle,
|
|
onHeaderHeightChange,
|
|
testID,
|
|
}: DiffFileSectionProps) {
|
|
const { t } = useTranslation();
|
|
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],
|
|
);
|
|
|
|
const headerPressableStyle = useCallback(
|
|
(state: PressableStateCallbackType) =>
|
|
depth > 0
|
|
? [
|
|
fileHeaderPressableStyle(state),
|
|
inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }),
|
|
]
|
|
: fileHeaderPressableStyle(state),
|
|
[depth],
|
|
);
|
|
|
|
const fileName = file.path.split("/").pop() ?? file.path;
|
|
|
|
return (
|
|
<View style={containerStyle} onLayout={handleLayout} testID={testID}>
|
|
<TreeIndentGuides depth={depth} />
|
|
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable
|
|
testID={testID ? `${testID}-toggle` : undefined}
|
|
style={headerPressableStyle}
|
|
// Android: prevent parent pan/scroll gestures from canceling the tap release.
|
|
cancelable={false}
|
|
onPressIn={handlePressIn}
|
|
onPressOut={handlePressOut}
|
|
onPress={toggleExpanded}
|
|
>
|
|
<View style={styles.fileHeaderLeft}>
|
|
{showDir ? null : (
|
|
<View style={styles.fileIcon}>
|
|
<SvgXml xml={getFileIconSvg(fileName)} width={16} height={16} />
|
|
</View>
|
|
)}
|
|
<Text style={styles.fileName} numberOfLines={1}>
|
|
{fileName}
|
|
</Text>
|
|
{showDir ? (
|
|
<Text style={styles.fileDir} numberOfLines={1}>
|
|
{file.path.includes("/")
|
|
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
|
|
: ""}
|
|
</Text>
|
|
) : (
|
|
// Flex spacer in tree mode (no dir suffix) so the New/Deleted badge
|
|
// stays right-aligned next to the diff stats, as in the flat list.
|
|
<View style={styles.fileDirSpacer} />
|
|
)}
|
|
{file.isNew && (
|
|
<View style={styles.newBadge}>
|
|
<Text style={styles.newBadgeText}>{t("workspace.git.diff.newFile")}</Text>
|
|
</View>
|
|
)}
|
|
{file.isDeleted && (
|
|
<View style={styles.deletedBadge}>
|
|
<Text style={styles.deletedBadgeText}>{t("workspace.git.diff.deletedFile")}</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,
|
|
codeFontSize,
|
|
textMetricsStyle,
|
|
reviewActions,
|
|
onBodyHeightChange,
|
|
testID,
|
|
}: {
|
|
file: ParsedDiffFile;
|
|
layout: "unified" | "split";
|
|
wrapLines: boolean;
|
|
codeFontSize: number;
|
|
textMetricsStyle: TextStyle;
|
|
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 { t } = useTranslation();
|
|
|
|
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"
|
|
? t("workspace.git.diff.binaryFile")
|
|
: t("workspace.git.diff.tooLarge")}
|
|
</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, codeFontSize);
|
|
|
|
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}
|
|
textMetricsStyle={textMetricsStyle}
|
|
reviewActions={reviewActions}
|
|
/>
|
|
<SplitDiffColumn
|
|
rows={rows}
|
|
side="right"
|
|
gutterWidth={gutterWidth}
|
|
wrapLines={wrapLines}
|
|
textMetricsStyle={textMetricsStyle}
|
|
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 }, index) => (
|
|
<View key={key} testID={`diff-wrapped-row-${index}`}>
|
|
<DiffLineView
|
|
line={line}
|
|
lineNumber={lineNumber}
|
|
gutterWidth={gutterWidth}
|
|
wrapLines={wrapLines}
|
|
textMetricsStyle={textMetricsStyle}
|
|
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 }, index) => (
|
|
<View key={key} testID={`diff-gutter-row-${index}`}>
|
|
<DiffGutterCell
|
|
lineNumber={lineNumber}
|
|
type={line.type}
|
|
gutterWidth={gutterWidth}
|
|
textMetricsStyle={textMetricsStyle}
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
isLineHovered={
|
|
reviewTarget?.key !== undefined && hoveredReviewTargetKey === reviewTarget.key
|
|
}
|
|
textTestID={`diff-gutter-text-${index}`}
|
|
actionTestID={`diff-gutter-action-${index}`}
|
|
/>
|
|
<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 }, index) => (
|
|
<View key={key} testID={`diff-code-row-${index}`}>
|
|
<DiffTextLine
|
|
line={line}
|
|
wrapLines={false}
|
|
textMetricsStyle={textMetricsStyle}
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
hoverTargetKey={reviewTarget?.key ?? null}
|
|
onHoverTargetChange={setHoveredReviewTargetKey}
|
|
textTestID={`diff-code-text-${index}`}
|
|
/>
|
|
<InlineReviewThreadContent
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
viewportWidth={textViewportWidth}
|
|
pinToViewport
|
|
/>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</DiffScroll>
|
|
</View>
|
|
);
|
|
})()}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
interface GitDiffPaneProps {
|
|
serverId: string;
|
|
workspaceId?: string | null;
|
|
cwd: string;
|
|
enabled?: boolean;
|
|
}
|
|
|
|
type PressableStyleFn = (
|
|
state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
|
|
) => StyleProp<ViewStyle>;
|
|
|
|
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
|
|
|
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
|
const ThemedAlignJustify = withUnistyles(AlignJustify);
|
|
const ThemedColumns2 = withUnistyles(Columns2);
|
|
const ThemedPilcrow = withUnistyles(Pilcrow);
|
|
const ThemedWrapText = withUnistyles(WrapText);
|
|
const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp);
|
|
const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
|
|
const ThemedFolderTree = withUnistyles(FolderTree);
|
|
const ThemedList = withUnistyles(List);
|
|
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
|
const ThemedDownload = withUnistyles(Download);
|
|
const ThemedUpload = withUnistyles(Upload);
|
|
const ThemedArrowDownUp = withUnistyles(ArrowDownUp);
|
|
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
|
const ThemedGitMerge = withUnistyles(GitMerge);
|
|
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
|
|
const ThemedArchive = withUnistyles(Archive);
|
|
const ThemedChevronDown = withUnistyles(ChevronDown);
|
|
|
|
const DIFF_OPTIONS_WHITESPACE_ICON = (
|
|
<ThemedPilcrow size={14} uniProps={foregroundMutedIconColorMapping} />
|
|
);
|
|
const DIFF_OPTIONS_WRAP_ICON = (
|
|
<ThemedWrapText size={14} uniProps={foregroundMutedIconColorMapping} />
|
|
);
|
|
|
|
interface DiffLayoutToggleProps {
|
|
layout: "unified" | "split";
|
|
isMobile: boolean;
|
|
toggleStyle: PressableStyleFn;
|
|
onToggle: () => void;
|
|
}
|
|
|
|
function DiffLayoutToggle({ layout, isMobile, toggleStyle, onToggle }: DiffLayoutToggleProps) {
|
|
const { t } = useTranslation();
|
|
const label =
|
|
layout === "unified"
|
|
? t("workspace.git.diff.switchToSplit")
|
|
: t("workspace.git.diff.switchToUnified");
|
|
return (
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={label}
|
|
testID="changes-toggle-layout"
|
|
onPress={onToggle}
|
|
style={toggleStyle}
|
|
>
|
|
{layout === "unified" ? (
|
|
<ThemedColumns2 size={isMobile ? 18 : 14} uniProps={foregroundMutedIconColorMapping} />
|
|
) : (
|
|
<ThemedAlignJustify
|
|
size={isMobile ? 18 : 14}
|
|
uniProps={foregroundMutedIconColorMapping}
|
|
/>
|
|
)}
|
|
</Pressable>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<Text style={styles.tooltipText}>{label}</Text>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
interface DiffViewModeToggleProps {
|
|
viewMode: "flat" | "tree";
|
|
isMobile: boolean;
|
|
toggleStyle: PressableStyleFn;
|
|
onToggle: () => void;
|
|
}
|
|
|
|
function DiffViewModeToggle({
|
|
viewMode,
|
|
isMobile,
|
|
toggleStyle,
|
|
onToggle,
|
|
}: DiffViewModeToggleProps) {
|
|
const { t } = useTranslation();
|
|
const label =
|
|
viewMode === "flat"
|
|
? t("workspace.git.diff.showTreeView")
|
|
: t("workspace.git.diff.showFlatView");
|
|
return (
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={label}
|
|
testID="changes-toggle-view-mode"
|
|
style={toggleStyle}
|
|
onPress={onToggle}
|
|
>
|
|
{viewMode === "flat" ? (
|
|
<ThemedFolderTree
|
|
size={isMobile ? 18 : 14}
|
|
uniProps={foregroundMutedIconColorMapping}
|
|
/>
|
|
) : (
|
|
<ThemedList size={isMobile ? 18 : 14} uniProps={foregroundMutedIconColorMapping} />
|
|
)}
|
|
</Pressable>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<Text style={styles.tooltipText}>{label}</Text>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
interface DiffFilesToolbarProps {
|
|
allFileDiffsExpanded: boolean;
|
|
isMobile: boolean;
|
|
expandAllToggleStyle: PressableStyleFn;
|
|
onToggleExpandAll: () => void;
|
|
}
|
|
|
|
function DiffFilesToolbar({
|
|
allFileDiffsExpanded,
|
|
isMobile,
|
|
expandAllToggleStyle,
|
|
onToggleExpandAll,
|
|
}: DiffFilesToolbarProps) {
|
|
const { t } = useTranslation();
|
|
const expandAllLabel = allFileDiffsExpanded
|
|
? t("workspace.git.diff.collapseAll")
|
|
: t("workspace.git.diff.expandAll");
|
|
return (
|
|
<View style={styles.diffStatusButtons}>
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={expandAllLabel}
|
|
style={expandAllToggleStyle}
|
|
onPress={onToggleExpandAll}
|
|
>
|
|
{allFileDiffsExpanded ? (
|
|
<ThemedListChevronsDownUp
|
|
size={isMobile ? 18 : 14}
|
|
uniProps={foregroundMutedIconColorMapping}
|
|
/>
|
|
) : (
|
|
<ThemedListChevronsUpDown
|
|
size={isMobile ? 18 : 14}
|
|
uniProps={foregroundMutedIconColorMapping}
|
|
/>
|
|
)}
|
|
</Pressable>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<Text style={styles.tooltipText}>{expandAllLabel}</Text>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
interface DiffOptionsMenuProps {
|
|
hideWhitespace: boolean;
|
|
isMobile: boolean;
|
|
isRefreshing: boolean;
|
|
overflowToggleStyle: PressableStyleFn;
|
|
refreshSupported: boolean;
|
|
wrapLines: boolean;
|
|
onRefresh: () => void;
|
|
onToggleHideWhitespace: () => void;
|
|
onToggleWrapLines: () => void;
|
|
}
|
|
|
|
function DiffOptionsMenu({
|
|
hideWhitespace,
|
|
isMobile,
|
|
isRefreshing,
|
|
overflowToggleStyle,
|
|
refreshSupported,
|
|
wrapLines,
|
|
onRefresh,
|
|
onToggleHideWhitespace,
|
|
onToggleWrapLines,
|
|
}: DiffOptionsMenuProps) {
|
|
const { t } = useTranslation();
|
|
const whitespaceLabel = hideWhitespace
|
|
? t("workspace.git.diff.showWhitespace")
|
|
: t("workspace.git.diff.hideWhitespace");
|
|
const wrapLinesLabel = wrapLines
|
|
? t("workspace.git.diff.scrollLongLines")
|
|
: t("workspace.git.diff.wrapLongLines");
|
|
const optionsLabel = t("workspace.git.diff.options");
|
|
const refreshIcon = useMemo(
|
|
() =>
|
|
isRefreshing ? (
|
|
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
|
|
) : (
|
|
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
|
|
),
|
|
[isRefreshing],
|
|
);
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<DropdownMenuTrigger
|
|
accessibilityRole="button"
|
|
accessibilityLabel={optionsLabel}
|
|
testID="changes-options-menu"
|
|
style={overflowToggleStyle}
|
|
>
|
|
<ThemedChevronDown
|
|
size={isMobile ? 18 : 14}
|
|
uniProps={foregroundMutedIconColorMapping}
|
|
/>
|
|
</DropdownMenuTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<Text style={styles.tooltipText}>{optionsLabel}</Text>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<DropdownMenuContent align="end" width={240} testID="changes-options-menu-content">
|
|
<DropdownMenuItem
|
|
leading={DIFF_OPTIONS_WHITESPACE_ICON}
|
|
selected={hideWhitespace}
|
|
testID="changes-toggle-whitespace"
|
|
onSelect={onToggleHideWhitespace}
|
|
>
|
|
{whitespaceLabel}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
leading={DIFF_OPTIONS_WRAP_ICON}
|
|
selected={wrapLines}
|
|
testID="changes-toggle-wrap-lines"
|
|
onSelect={onToggleWrapLines}
|
|
>
|
|
{wrapLinesLabel}
|
|
</DropdownMenuItem>
|
|
{refreshSupported ? (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
leading={refreshIcon}
|
|
disabled={isRefreshing}
|
|
testID="changes-refresh"
|
|
onSelect={onRefresh}
|
|
>
|
|
{isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refresh")}
|
|
</DropdownMenuItem>
|
|
</>
|
|
) : null}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
const ThemedRotateCw = withUnistyles(RotateCw);
|
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|
|
|
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,
|
|
labels: {
|
|
hiddenWhitespace: string;
|
|
uncommitted: string;
|
|
againstBase: (baseRefLabel: string) => string;
|
|
},
|
|
): string {
|
|
if (hideWhitespace) {
|
|
return labels.hiddenWhitespace;
|
|
}
|
|
if (diffMode === "uncommitted") {
|
|
return labels.uncommitted;
|
|
}
|
|
return labels.againstBase(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;
|
|
checkingRepositoryLabel: string;
|
|
notRepositoryLabel: string;
|
|
}
|
|
|
|
function DiffBodyContent({
|
|
isStatusLoading,
|
|
statusErrorMessage,
|
|
notGit,
|
|
isDiffLoading,
|
|
diffErrorMessage,
|
|
hasChanges,
|
|
emptyMessage,
|
|
flatItems,
|
|
stickyHeaderIndices,
|
|
renderFlatItem,
|
|
flatKeyExtractor,
|
|
getFlatItemLayout,
|
|
flatExtraData,
|
|
diffListRef,
|
|
handleDiffListLayout,
|
|
handleDiffListScroll,
|
|
checkingRepositoryLabel,
|
|
notRepositoryLabel,
|
|
}: DiffBodyContentProps) {
|
|
if (isStatusLoading) {
|
|
return (
|
|
<View style={styles.loadingContainer}>
|
|
<ThemedActivityIndicator size="large" uniProps={foregroundMutedIconColorMapping} />
|
|
<Text style={styles.loadingText}>{checkingRepositoryLabel}</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}>{notRepositoryLabel}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
if (isDiffLoading) {
|
|
return (
|
|
<View style={styles.loadingContainer}>
|
|
<ThemedActivityIndicator size="large" uniProps={foregroundMutedIconColorMapping} />
|
|
</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}
|
|
scrollEventThrottle={16}
|
|
showsVerticalScrollIndicator
|
|
// 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;
|
|
currentBranchName: string | null;
|
|
}
|
|
|
|
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;
|
|
const currentBranchName =
|
|
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
|
|
return {
|
|
gitStatus,
|
|
isGit,
|
|
notGit,
|
|
statusErrorMessage,
|
|
baseRef,
|
|
hasUncommittedChanges,
|
|
actionsDisabled,
|
|
currentBranchName,
|
|
};
|
|
}
|
|
|
|
function computeBaseRefLabel(baseRef: string | undefined, fallbackLabel: string): string {
|
|
if (!baseRef) return fallbackLabel;
|
|
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(): PressableStyleFn {
|
|
return ({ hovered, pressed, open }) => [
|
|
styles.diffModeTrigger,
|
|
(Boolean(hovered) || pressed || Boolean(open)) && styles.diffModeTriggerHovered,
|
|
];
|
|
}
|
|
|
|
function buildExpandAllButtonStyle(): PressableStyleFn {
|
|
return ({ hovered, pressed }) => [
|
|
styles.expandAllButton,
|
|
(Boolean(hovered) || pressed) && styles.toggleButtonSelected,
|
|
];
|
|
}
|
|
|
|
function buildToggleButtonStyle(
|
|
selected: boolean,
|
|
baseStyles: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
|
|
): PressableStyleFn {
|
|
return ({ hovered, pressed }) => [
|
|
baseStyles,
|
|
(selected || Boolean(hovered) || pressed) && styles.toggleButtonSelected,
|
|
];
|
|
}
|
|
|
|
function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean }): boolean {
|
|
return input.paneEnabled && input.isGit;
|
|
}
|
|
|
|
export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPaneProps) {
|
|
const { settings: appSettings } = useAppSettings();
|
|
const { t } = useTranslation();
|
|
const isMobile = useIsCompactFormFactor();
|
|
const canUseSplitLayout = isWeb && !isMobile;
|
|
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
|
useChangesPreferences();
|
|
const wrapLines = changesPreferences.wrapLines;
|
|
const viewMode = changesPreferences.viewMode;
|
|
const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
|
|
|
|
const handleToggleWrapLines = useCallback(() => {
|
|
void updateChangesPreferences({ wrapLines: !wrapLines });
|
|
}, [updateChangesPreferences, wrapLines]);
|
|
|
|
const handleToggleHideWhitespace = useCallback(() => {
|
|
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
|
|
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
|
|
|
|
const handleToggleLayout = useCallback(() => {
|
|
void updateChangesPreferences({
|
|
layout: changesPreferences.layout === "unified" ? "split" : "unified",
|
|
});
|
|
}, [changesPreferences.layout, updateChangesPreferences]);
|
|
|
|
const codeFontSize = appSettings.codeFontSize;
|
|
const diffBodyLineHeight = Math.round(codeFontSize * 1.5);
|
|
const diffBodyTypographyKey = [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight].join(
|
|
":",
|
|
);
|
|
const diffTextMetricsStyle = useMemo<TextStyle>(() => {
|
|
const monoFontFamily = appSettings.monoFontFamily.trim();
|
|
return {
|
|
fontSize: codeFontSize,
|
|
lineHeight: diffBodyLineHeight,
|
|
...(monoFontFamily ? { fontFamily: monoFontFamily } : null),
|
|
};
|
|
}, [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight]);
|
|
const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []);
|
|
|
|
const layoutToggleStyle = useMemo(
|
|
() => buildToggleButtonStyle(false, styles.expandAllButton),
|
|
[],
|
|
);
|
|
|
|
const viewModeToggleStyle = useMemo(
|
|
() => buildToggleButtonStyle(viewMode === "tree", styles.expandAllButton),
|
|
[viewMode],
|
|
);
|
|
|
|
const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
|
|
|
|
const overflowToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
|
|
|
|
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 : t("workspace.git.diff.failedRefresh"));
|
|
});
|
|
}, [cwd, isRefreshing, runRefresh, serverId, t, 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, currentBranchName } =
|
|
statusState;
|
|
|
|
const reviewDraftScopeKey = useMemo(
|
|
() =>
|
|
buildReviewDraftScopeKey({
|
|
serverId,
|
|
workspaceId,
|
|
cwd,
|
|
baseRef,
|
|
ignoreWhitespace: changesPreferences.hideWhitespace,
|
|
}),
|
|
[baseRef, changesPreferences.hideWhitespace, cwd, serverId, workspaceId],
|
|
);
|
|
const diffMode = useResolvedDiffMode({
|
|
scopeKey: reviewDraftScopeKey,
|
|
hasUncommittedChanges,
|
|
});
|
|
const setDiffModeOverride = useSetDiffModeOverride();
|
|
|
|
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 handleSelectUncommitted = useCallback(() => {
|
|
setDiffModeOverride({
|
|
scopeKey: reviewDraftScopeKey,
|
|
override: { serverId, cwd, mode: "uncommitted", isDirtyAtSelection: hasUncommittedChanges },
|
|
});
|
|
}, [cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride]);
|
|
|
|
const handleSelectBase = useCallback(() => {
|
|
setDiffModeOverride({
|
|
scopeKey: reviewDraftScopeKey,
|
|
override: { serverId, cwd, mode: "base", isDirtyAtSelection: hasUncommittedChanges },
|
|
});
|
|
}, [cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride]);
|
|
|
|
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]);
|
|
// The Changes view groups files into a directory tree on every form factor,
|
|
// consistent with the Files explorer (which is also a tree on mobile).
|
|
const collapsedFoldersArray = usePanelStore((state) =>
|
|
workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined,
|
|
);
|
|
const setDiffCollapsedFoldersForWorkspace = usePanelStore(
|
|
(state) => state.setDiffCollapsedFoldersForWorkspace,
|
|
);
|
|
// Build the directory tree once per files-change; collapse/expand toggles only
|
|
// re-flatten it (they don't change tree shape).
|
|
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
|
|
// Every directory path currently in the tree — used by "collapse all folders" and to
|
|
// filter stale collapse state.
|
|
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
|
|
const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]);
|
|
// Effective collapsed set: intersect the persisted paths with the folders actually
|
|
// present, purely at render (no store-syncing effect). A folder that left the diff and
|
|
// reappears defaults to expanded; toggles write back this pruned set, so the stored
|
|
// array stays bounded. (empty = all folders expanded, the default)
|
|
const collapsedFolders = useMemo(
|
|
() => new Set((collapsedFoldersArray ?? []).filter((path) => allFolderPathSet.has(path))),
|
|
[collapsedFoldersArray, allFolderPathSet],
|
|
);
|
|
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
|
const handleToggleViewMode = useCallback(() => {
|
|
const nextViewMode = viewMode === "flat" ? "tree" : "flat";
|
|
if (nextViewMode === "tree") {
|
|
diffListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
|
if (workspaceStateKey) {
|
|
setDiffCollapsedFoldersForWorkspace(workspaceStateKey, []);
|
|
}
|
|
}
|
|
void updateChangesPreferences({ viewMode: nextViewMode });
|
|
}, [setDiffCollapsedFoldersForWorkspace, updateChangesPreferences, viewMode, workspaceStateKey]);
|
|
const diffListScrollOffsetRef = useRef(0);
|
|
const diffListViewportHeightRef = useRef(0);
|
|
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
|
const bodyHeightByKeyRef = useRef<Record<string, number>>({});
|
|
// Folder rows are a distinct kind; keep their height out of headerHeightByPathRef
|
|
// (Codex item 6) so file/folder heights can't collide by path.
|
|
const folderRowHeightRef = useRef<number>(0);
|
|
const defaultHeaderHeightRef = useRef<number>(44);
|
|
const [heightVersion, setHeightVersion] = useState(0);
|
|
const diffBodyChromeHeight = BORDER_WIDTH[1] * 2;
|
|
const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight;
|
|
const { flatItems, stickyHeaderIndices } = useMemo(() => {
|
|
const { items, stickyHeaderIndices: stickyIndices } = buildDiffFlatItems({
|
|
files,
|
|
viewMode,
|
|
tree: compressedTree,
|
|
collapsedFolders,
|
|
expandedPaths,
|
|
});
|
|
return { flatItems: items, stickyHeaderIndices: stickyIndices };
|
|
}, [compressedTree, collapsedFolders, expandedPaths, files, viewMode]);
|
|
|
|
const getBodyHeightKey = useCallback(
|
|
(file: ParsedDiffFile): string => {
|
|
if (file.status === "too_large" || file.status === "binary") {
|
|
return `${effectiveLayout}:${wrapLines ? "wrap" : "scroll"}:${diffBodyTypographyKey}:${file.path}:${file.status}`;
|
|
}
|
|
|
|
return [
|
|
effectiveLayout,
|
|
wrapLines ? "wrap" : "scroll",
|
|
diffBodyTypographyKey,
|
|
file.path,
|
|
file.status ?? "ok",
|
|
file.additions,
|
|
file.deletions,
|
|
file.hunks.length,
|
|
getUnifiedDiffLineCount(file),
|
|
getDiffContentLength(file),
|
|
].join(":");
|
|
},
|
|
[diffBodyTypographyKey, 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],
|
|
);
|
|
|
|
// Single height source of truth for both getItemLayout and the collapse
|
|
// scroll-anchor math. Folder rows use their own measured height (Codex item 6),
|
|
// falling back to the default header height before first measurement.
|
|
const getFlatItemHeight = useCallback(
|
|
(item: DiffFlatItem): number => {
|
|
if (item.type === "folder") {
|
|
return folderRowHeightRef.current || defaultHeaderHeightRef.current;
|
|
}
|
|
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 handleFolderRowHeightChange = useCallback((height: number) => {
|
|
if (!Number.isFinite(height) || height <= 0) {
|
|
return;
|
|
}
|
|
const previousHeight = folderRowHeightRef.current;
|
|
if (previousHeight > 0 && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON) {
|
|
return;
|
|
}
|
|
folderRowHeightRef.current = height;
|
|
setHeightVersion((version) => version + 1);
|
|
}, []);
|
|
|
|
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;
|
|
}, []);
|
|
|
|
const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => {
|
|
const height = event.nativeEvent.layout.height;
|
|
if (!Number.isFinite(height) || height <= 0) {
|
|
return;
|
|
}
|
|
diffListViewportHeightRef.current = height;
|
|
}, []);
|
|
|
|
// Offset of the first item matching `predicate`, walking the SAME flatItems
|
|
// list getFlatItemLayout uses so folder rows are counted (single source of
|
|
// truth — Codex item 5 / finding 2).
|
|
const computeItemOffset = useCallback(
|
|
(predicate: (item: DiffFlatItem) => boolean): number | null => {
|
|
const index = flatItems.findIndex(predicate);
|
|
if (index < 0) {
|
|
return null;
|
|
}
|
|
return sumHeightsBefore(flatItems, index, getFlatItemHeight);
|
|
},
|
|
[flatItems, getFlatItemHeight],
|
|
);
|
|
|
|
const computeHeaderOffset = useCallback(
|
|
(path: string): number =>
|
|
computeItemOffset((item) => item.type === "header" && item.file.path === path) ?? 0,
|
|
[computeItemOffset],
|
|
);
|
|
|
|
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 handleToggleFolder = useCallback(
|
|
(dirPath: string) => {
|
|
if (!workspaceStateKey) {
|
|
return;
|
|
}
|
|
const isCurrentlyCollapsed = collapsedFolders.has(dirPath);
|
|
// Collapsing hides the subtree below this row; anchor to the folder row
|
|
// first so the viewport doesn't jump to a dead offset (Codex item 5).
|
|
if (!isCurrentlyCollapsed) {
|
|
const targetOffset = computeItemOffset(
|
|
(item) => item.type === "folder" && item.dirPath === dirPath,
|
|
);
|
|
const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current;
|
|
if (
|
|
targetOffset !== null &&
|
|
shouldAnchorHeaderBeforeCollapse({
|
|
headerOffset: targetOffset,
|
|
headerHeight: folderHeight,
|
|
viewportOffset: diffListScrollOffsetRef.current,
|
|
viewportHeight: diffListViewportHeightRef.current,
|
|
})
|
|
) {
|
|
diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false });
|
|
}
|
|
}
|
|
|
|
const nextCollapsed = isCurrentlyCollapsed
|
|
? Array.from(collapsedFolders).filter((path) => path !== dirPath)
|
|
: [...collapsedFolders, dirPath];
|
|
setDiffCollapsedFoldersForWorkspace(workspaceStateKey, nextCollapsed);
|
|
},
|
|
[collapsedFolders, computeItemOffset, setDiffCollapsedFoldersForWorkspace, workspaceStateKey],
|
|
);
|
|
|
|
const allFileDiffsExpanded = useMemo(() => {
|
|
if (files.length === 0) return false;
|
|
return files.every((file) => expandedPaths.has(file.path));
|
|
}, [expandedPaths, files]);
|
|
|
|
const handleToggleExpandAll = useCallback(() => {
|
|
if (!workspaceStateKey) {
|
|
return;
|
|
}
|
|
if (allFileDiffsExpanded) {
|
|
setDiffExpandedPathsForWorkspace(workspaceStateKey, []);
|
|
} else {
|
|
setDiffExpandedPathsForWorkspace(
|
|
workspaceStateKey,
|
|
files.map((file) => file.path),
|
|
);
|
|
}
|
|
}, [allFileDiffsExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
|
|
|
|
const renderFlatItem = useCallback(
|
|
({ item }: { item: DiffFlatItem }) => {
|
|
if (item.type === "folder") {
|
|
return (
|
|
<DiffFolderRow
|
|
dirPath={item.dirPath}
|
|
displayName={item.displayName}
|
|
depth={item.depth}
|
|
collapsed={item.collapsed}
|
|
additions={item.additions}
|
|
deletions={item.deletions}
|
|
onToggle={handleToggleFolder}
|
|
onHeightChange={handleFolderRowHeightChange}
|
|
testID={`diff-folder-${item.dirPath}`}
|
|
/>
|
|
);
|
|
}
|
|
if (item.type === "header") {
|
|
return (
|
|
<DiffFileHeader
|
|
file={item.file}
|
|
isExpanded={item.isExpanded}
|
|
depth={item.depth}
|
|
showDir={viewMode === "flat"}
|
|
onToggle={handleToggleExpanded}
|
|
onHeaderHeightChange={handleHeaderHeightChange}
|
|
testID={`diff-file-${item.fileIndex}`}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<DiffFileBody
|
|
file={item.file}
|
|
layout={effectiveLayout}
|
|
wrapLines={wrapLines}
|
|
codeFontSize={codeFontSize}
|
|
textMetricsStyle={diffTextMetricsStyle}
|
|
reviewActions={reviewActions}
|
|
onBodyHeightChange={handleBodyHeightChange}
|
|
testID={`diff-file-${item.fileIndex}-body`}
|
|
/>
|
|
);
|
|
},
|
|
[
|
|
codeFontSize,
|
|
diffTextMetricsStyle,
|
|
effectiveLayout,
|
|
handleBodyHeightChange,
|
|
handleFolderRowHeightChange,
|
|
handleHeaderHeightChange,
|
|
handleToggleExpanded,
|
|
handleToggleFolder,
|
|
reviewActions,
|
|
viewMode,
|
|
wrapLines,
|
|
],
|
|
);
|
|
|
|
const flatKeyExtractor = useCallback(
|
|
(item: DiffFlatItem) =>
|
|
item.type === "folder" ? `folder-${item.dirPath}` : `${item.type}-${item.file.path}`,
|
|
[],
|
|
);
|
|
|
|
const getFlatItemLayout = useCallback<DiffFlatItemLayoutGetter>(
|
|
(_data, index) => {
|
|
const offset = sumHeightsBefore(flatItems, index, getFlatItemHeight);
|
|
const item = flatItems[index];
|
|
const length = item ? getFlatItemHeight(item) : 0;
|
|
return { length, offset, index };
|
|
},
|
|
[flatItems, getFlatItemHeight],
|
|
);
|
|
|
|
const flatExtraData = useMemo(
|
|
() => ({
|
|
expandedPathsArray,
|
|
collapsedFoldersArray,
|
|
effectiveLayout,
|
|
diffBodyTypographyKey,
|
|
heightVersion,
|
|
viewMode,
|
|
wrapLines,
|
|
reviewActions,
|
|
}),
|
|
[
|
|
expandedPathsArray,
|
|
collapsedFoldersArray,
|
|
effectiveLayout,
|
|
diffBodyTypographyKey,
|
|
heightVersion,
|
|
viewMode,
|
|
wrapLines,
|
|
reviewActions,
|
|
],
|
|
);
|
|
|
|
const hasChanges = files.length > 0;
|
|
const diffErrorMessage = diffPayloadError?.message ?? null;
|
|
const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError);
|
|
const baseRefLabel = useMemo(
|
|
() => computeBaseRefLabel(baseRef, t("workspace.git.diff.base")),
|
|
[baseRef, t],
|
|
);
|
|
const gitActionsIcons = useMemo(
|
|
() => ({
|
|
commit: <ThemedGitCommitHorizontal size={16} uniProps={foregroundMutedIconColorMapping} />,
|
|
pull: <ThemedDownload size={16} uniProps={foregroundMutedIconColorMapping} />,
|
|
push: <ThemedUpload size={16} uniProps={foregroundMutedIconColorMapping} />,
|
|
pullAndPush: <ThemedArrowDownUp size={16} uniProps={foregroundMutedIconColorMapping} />,
|
|
viewPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
|
|
createPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
|
|
mergePrSquash: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
|
|
mergePrMerge: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
|
|
mergePrRebase: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
|
|
merge: <ThemedGitMerge size={16} uniProps={foregroundMutedIconColorMapping} />,
|
|
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={foregroundMutedIconColorMapping} />,
|
|
archive: <ThemedArchive size={16} uniProps={foregroundMutedIconColorMapping} />,
|
|
}),
|
|
[],
|
|
);
|
|
const { gitActions, branchLabel } = useGitActions({
|
|
serverId,
|
|
cwd,
|
|
icons: gitActionsIcons,
|
|
});
|
|
const committedDiffDescription = useMemo(
|
|
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
|
|
[baseRefLabel, branchLabel],
|
|
);
|
|
const uncommittedLabel = t("workspace.git.diff.uncommitted");
|
|
const committedLabel = t("workspace.git.diff.committed");
|
|
|
|
const emptyMessage = computeEmptyMessage(
|
|
changesPreferences.hideWhitespace,
|
|
diffMode,
|
|
baseRefLabel,
|
|
{
|
|
hiddenWhitespace: t("workspace.git.diff.emptyHiddenWhitespace"),
|
|
uncommitted: t("workspace.git.diff.emptyUncommitted"),
|
|
againstBase: (label) => t("workspace.git.diff.emptyAgainstBase", { baseRef: label }),
|
|
},
|
|
);
|
|
|
|
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}
|
|
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
|
|
notRepositoryLabel={t("workspace.git.diff.notRepository")}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{isGit && (currentBranchName || isMobile) ? (
|
|
<View style={styles.header} testID="changes-header">
|
|
<BranchSwitcher
|
|
currentBranchName={currentBranchName}
|
|
serverId={serverId}
|
|
workspaceId={workspaceId ?? cwd}
|
|
workspaceDirectory={cwd}
|
|
isGitCheckout={isGit}
|
|
testID="changes-branch-switcher"
|
|
/>
|
|
{isMobile ? <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={t("workspace.git.diff.diffMode")}
|
|
>
|
|
<Text style={styles.diffStatusText} numberOfLines={1}>
|
|
{diffMode === "uncommitted" ? uncommittedLabel : committedLabel}
|
|
</Text>
|
|
<ThemedChevronDown size={12} uniProps={foregroundMutedIconColorMapping} />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" width={260} testID="changes-diff-status-menu">
|
|
<DropdownMenuItem
|
|
testID="changes-diff-mode-uncommitted"
|
|
selected={diffMode === "uncommitted"}
|
|
onSelect={handleSelectUncommitted}
|
|
>
|
|
{uncommittedLabel}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
testID="changes-diff-mode-committed"
|
|
selected={diffMode === "base"}
|
|
description={committedDiffDescription}
|
|
onSelect={handleSelectBase}
|
|
>
|
|
{committedLabel}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<View style={styles.diffStatusButtons}>
|
|
{canUseSplitLayout ? (
|
|
<DiffLayoutToggle
|
|
layout={changesPreferences.layout}
|
|
isMobile={isMobile}
|
|
toggleStyle={layoutToggleStyle}
|
|
onToggle={handleToggleLayout}
|
|
/>
|
|
) : null}
|
|
{files.length > 0 ? (
|
|
<DiffViewModeToggle
|
|
viewMode={viewMode}
|
|
isMobile={isMobile}
|
|
toggleStyle={viewModeToggleStyle}
|
|
onToggle={handleToggleViewMode}
|
|
/>
|
|
) : null}
|
|
{files.length > 0 ? (
|
|
<DiffFilesToolbar
|
|
allFileDiffsExpanded={allFileDiffsExpanded}
|
|
isMobile={isMobile}
|
|
expandAllToggleStyle={expandAllToggleStyle}
|
|
onToggleExpandAll={handleToggleExpandAll}
|
|
/>
|
|
) : null}
|
|
<DiffOptionsMenu
|
|
hideWhitespace={changesPreferences.hideWhitespace}
|
|
isMobile={isMobile}
|
|
isRefreshing={isRefreshing}
|
|
overflowToggleStyle={overflowToggleStyle}
|
|
refreshSupported={refreshSupported}
|
|
wrapLines={wrapLines}
|
|
onRefresh={handleRefresh}
|
|
onToggleHideWhitespace={handleToggleHideWhitespace}
|
|
onToggleWrapLines={handleToggleWrapLines}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
|
|
|
|
<View style={styles.diffContainer}>{bodyContent}</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,
|
|
},
|
|
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",
|
|
},
|
|
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",
|
|
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,
|
|
},
|
|
fileIcon: {
|
|
flexShrink: 0,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
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,
|
|
},
|
|
fileDirSpacer: {
|
|
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",
|
|
},
|
|
diffTextMetrics: {
|
|
fontSize: theme.fontSize.code,
|
|
lineHeight: theme.lineHeight.diff,
|
|
fontFamily: theme.fontFamily.mono,
|
|
},
|
|
lineNumberText: {
|
|
width: "100%",
|
|
textAlign: "right",
|
|
paddingRight: theme.spacing[2],
|
|
color: theme.colors.foregroundMuted,
|
|
userSelect: "none",
|
|
},
|
|
addLineNumberText: {
|
|
color: theme.colors.diffAddition,
|
|
},
|
|
removeLineNumberText: {
|
|
color: theme.colors.diffDeletion,
|
|
},
|
|
diffLineText: {
|
|
flex: 1,
|
|
paddingRight: theme.spacing[3],
|
|
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 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;
|