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

1662 lines
50 KiB
TypeScript

import { useState, useCallback, useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { TreeRail } from "@/components/tree-rail";
import { DiffStat } from "@/components/diff-stat";
import {
View,
Text,
Pressable,
FlatList,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import {
AlignJustify,
ChevronDown,
Columns2,
FolderTree,
ListChevronsDownUp,
ListChevronsUpDown,
Maximize2,
MoreHorizontal,
Pilcrow,
RotateCw,
WrapText,
} from "lucide-react-native";
import { type ParsedDiffFile } from "@/git/use-diff-query";
import { DiffDocument, type WorkingDiffMode } from "@/git/diff-document";
import { FileHeader } from "@/git/file-header";
import {
buildDiffTree,
collectDirPaths,
compressSingleChildChains,
flattenDiffTree,
type DiffTreeRow,
} from "@/git/diff-tree";
import { DiffFolderRow } from "@/git/diff-folder-row";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { CommitsSection } from "@/git/commits-section/commits-section";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useAppSettings } from "@/hooks/use-settings";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import * as Clipboard from "expo-clipboard";
import { useFileDownload } from "@/hooks/use-file-download";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { GitActionsSplitButton } from "@/git/actions-split-button";
import type { GitActions } from "@/git/policy";
import { BranchSwitcher } from "@/components/branch-switcher";
import { useGitActions } from "@/git/use-actions";
import { GIT_ACTION_ICONS } from "@/git/action-icons";
import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import type { ForgeAuthState } from "@getpaseo/protocol/messages";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
import { confirmDialog } from "@/utils/confirm-dialog";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { usePanelStore } from "@/stores/panel-store";
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { isWeb } from "@/constants/platform";
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
import { DiffTooLargeState } from "@/git/diff-too-large-state";
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
export function resolveDiffLayout(
layout: "unified" | "split",
canUseSplitLayout: boolean,
): "unified" | "split" {
return canUseSplitLayout ? layout : "unified";
}
function computeSelectedDiffStat(
files: ParsedDiffFile[],
isLoading: boolean,
): { additions: number; deletions: number } | null {
if (isLoading) {
return null;
}
return files.reduce(
(total, file) => ({
additions: total.additions + file.additions,
deletions: total.deletions + file.deletions,
}),
{ additions: 0, deletions: 0 },
);
}
function useDiscardChangesAction({
serverId,
cwd,
diffMode,
}: {
serverId: string;
cwd: string;
diffMode: "uncommitted" | "base";
}): ((path: string, oldPath?: string) => void) | undefined {
const { t } = useTranslation();
const toast = useToast();
const discardChanges = useCheckoutGitActionsStore((state) => state.discardChanges);
// COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08.
const discardSupported = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutDiscardChanges === true,
);
const discardPath = useCallback(
async (path: string, oldPath?: string) => {
const confirmed = await confirmDialog({
title: t("workspace.fileActions.confirmRevert.title"),
message: t("workspace.fileActions.confirmRevert.message", { name: path }),
confirmLabel: t("workspace.fileActions.confirmRevert.confirm"),
cancelLabel: t("workspace.fileActions.confirmRevert.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
try {
await discardChanges({
serverId,
cwd,
paths: oldPath ? [path, oldPath] : [path],
});
} catch (cause) {
toast.error(
cause instanceof Error ? cause.message : t("workspace.fileActions.confirmRevert.failed"),
);
}
},
[cwd, discardChanges, serverId, t, toast],
);
const handleDiscardPath = useCallback(
(path: string, oldPath?: string) => {
void discardPath(path, oldPath);
},
[discardPath],
);
return discardSupported && diffMode === "uncommitted" ? handleDiscardPath : undefined;
}
interface ChangesSurfaceProps {
serverId: string;
workspaceId?: string | null;
cwd: string;
enabled?: boolean;
host: "explorer" | "panel";
focusPath?: string;
focusRequestId?: number;
onOpenFile?: (path: string) => void;
onAddToChat?: (path: string) => void;
}
type PressableStyleFn = (
state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
) => StyleProp<ViewStyle>;
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedAlignJustify = withUnistyles(AlignJustify);
const ThemedColumns2 = withUnistyles(Columns2);
const ThemedPilcrow = withUnistyles(Pilcrow);
const ThemedWrapText = withUnistyles(WrapText);
const ThemedFolderTree = withUnistyles(FolderTree);
const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp);
const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
const ThemedMaximize2 = withUnistyles(Maximize2);
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedMoreHorizontal = withUnistyles(MoreHorizontal);
const DIFF_OPTIONS_WHITESPACE_ICON = (
<ThemedPilcrow size={14} uniProps={foregroundMutedIconColorMapping} />
);
const DIFF_OPTIONS_WRAP_ICON = (
<ThemedWrapText size={14} uniProps={foregroundMutedIconColorMapping} />
);
const DIFF_OPTIONS_SPLIT_ICON = (
<ThemedColumns2 size={14} uniProps={foregroundMutedIconColorMapping} />
);
const DIFF_OPTIONS_COLLAPSE_ICON = (
<ThemedListChevronsDownUp size={14} uniProps={foregroundMutedIconColorMapping} />
);
const DIFF_OPTIONS_EXPAND_ICON = (
<ThemedListChevronsUpDown size={14} uniProps={foregroundMutedIconColorMapping} />
);
const DIFF_OPTIONS_CHANGES_TAB_ICON = (
<ThemedMaximize2 size={14} uniProps={foregroundMutedIconColorMapping} />
);
interface DiffLayoutToggleProps {
layout: "unified" | "split";
isMobile: boolean;
testID?: string;
toggleStyle?: PressableStyleFn;
onToggle: () => void;
}
export function DiffLayoutToggle({
layout,
isMobile,
testID = "changes-toggle-layout",
toggleStyle,
onToggle,
}: DiffLayoutToggleProps) {
const defaultToggleStyle = useMemo(
() => buildToggleButtonStyle(false, styles.toolbarIconButton),
[],
);
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={testID}
onPress={onToggle}
style={toggleStyle ?? defaultToggleStyle}
>
{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>
);
}
function resolveChangesTabOpen(host: "explorer" | "panel", changesTabOpen: boolean): boolean {
return host === "explorer" ? changesTabOpen : false;
}
function resolveChangesFilePress(
host: "explorer" | "panel",
onChangesFilePress: ((path?: string) => void) | undefined,
): ((path?: string) => void) | undefined {
return host === "explorer" ? onChangesFilePress : undefined;
}
interface DiffModeMenuProps {
diffMode: "uncommitted" | "base";
committedDescription?: string;
testIDPrefix?: string;
onSelectUncommitted: () => void;
onSelectBase: () => void;
}
export function DiffModeMenu({
diffMode,
committedDescription,
testIDPrefix = "changes-diff",
onSelectUncommitted,
onSelectBase,
}: DiffModeMenuProps) {
const { t } = useTranslation();
const triggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []);
const uncommittedLabel = t("workspace.git.diff.uncommitted");
const committedLabel = t("workspace.git.diff.committed");
return (
<DropdownMenu>
<DropdownMenuTrigger
testID={`${testIDPrefix}-status-trigger`}
style={triggerStyle}
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={`${testIDPrefix}-status-menu`}>
<DropdownMenuItem
testID={`${testIDPrefix}-mode-uncommitted`}
selected={diffMode === "uncommitted"}
onSelect={onSelectUncommitted}
>
{uncommittedLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID={`${testIDPrefix}-mode-committed`}
selected={diffMode === "base"}
description={committedDescription}
onSelect={onSelectBase}
>
{committedLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface DesktopTreeToggleProps {
visible: boolean;
toggleStyle?: PressableStyleFn;
onToggle: () => void;
}
function DesktopTreeToggle({ visible, toggleStyle, onToggle }: DesktopTreeToggleProps) {
const { t } = useTranslation();
const defaultToggleStyle = useMemo(
() => buildToggleButtonStyle(visible, styles.toolbarIconButton),
[visible],
);
const label = t(visible ? "workspace.git.diff.hideTreeView" : "workspace.git.diff.showTreeView");
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
testID="changes-toggle-tree"
style={toggleStyle ?? defaultToggleStyle}
onPress={onToggle}
>
<ThemedFolderTree size={14} uniProps={foregroundMutedIconColorMapping} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
}
interface ChangesToolbarProps {
branchName: string | null;
allFilesCollapsed: boolean;
canUseSplitLayout: boolean;
changesTabOpen: boolean;
committedDescription?: string;
cwd: string;
desktopTreeVisible: boolean;
diffMode: "uncommitted" | "base";
gitActions: GitActions;
hasFiles: boolean;
hideWhitespace: boolean;
host: "explorer" | "panel";
isMobile: boolean;
isRefreshing: boolean;
layout: "unified" | "split";
overflowToggleStyle: PressableStyleFn;
refreshSupported: boolean;
selectedDiffStat: { additions: number; deletions: number } | null;
serverId: string;
treeToggleStyle: PressableStyleFn;
workspaceId?: string | null;
wrapLines: boolean;
onRefresh: () => void;
onCollapseAll: () => void;
onExpandAll: () => void;
onSelectBase: () => void;
onSelectUncommitted: () => void;
onToggleChangesTab: () => void;
onToggleDesktopTree: () => void;
onToggleHideWhitespace: () => void;
onToggleLayout: () => void;
onToggleWrapLines: () => void;
}
// One row: the diff-mode and branch pickers lead, the git actions and the
// overflow menu trail. The tree toggle is the only icon action that stays out
// of the menu, and it only exists on desktop, so a phone-width row holds two
// pickers, the split button, and the trigger without wrapping.
function ChangesToolbar(props: ChangesToolbarProps) {
const {
branchName,
committedDescription,
cwd,
desktopTreeVisible,
diffMode,
gitActions,
hasFiles,
isMobile,
selectedDiffStat,
serverId,
treeToggleStyle,
workspaceId,
onSelectBase,
onSelectUncommitted,
onToggleDesktopTree,
} = props;
return (
<View style={styles.changesToolbar} testID="changes-header">
<View style={styles.changesToolbarIdentity}>
<DiffModeMenu
diffMode={diffMode}
committedDescription={committedDescription}
onSelectUncommitted={onSelectUncommitted}
onSelectBase={onSelectBase}
/>
<BranchSwitcher
currentBranchName={branchName}
serverId={serverId}
workspaceId={workspaceId ?? cwd}
workspaceDirectory={cwd}
isGitCheckout
testID="changes-branch-switcher"
/>
{!isMobile && selectedDiffStat ? (
<DiffStat
additions={selectedDiffStat.additions}
deletions={selectedDiffStat.deletions}
testID="changes-selected-diff-stat"
/>
) : null}
</View>
<View style={styles.changesToolbarControls}>
{!isMobile && hasFiles ? (
<DesktopTreeToggle
visible={desktopTreeVisible}
toggleStyle={treeToggleStyle}
onToggle={onToggleDesktopTree}
/>
) : null}
{isMobile ? <GitActionsSplitButton gitActions={gitActions} /> : null}
<ChangesOptionsMenu {...props} />
</View>
</View>
);
}
type ChangesOptionsMenuProps = Pick<
ChangesToolbarProps,
| "allFilesCollapsed"
| "canUseSplitLayout"
| "changesTabOpen"
| "hasFiles"
| "hideWhitespace"
| "host"
| "isMobile"
| "isRefreshing"
| "layout"
| "overflowToggleStyle"
| "refreshSupported"
| "wrapLines"
| "onCollapseAll"
| "onExpandAll"
| "onRefresh"
| "onToggleChangesTab"
| "onToggleHideWhitespace"
| "onToggleLayout"
| "onToggleWrapLines"
>;
function ChangesOptionsMenu({
allFilesCollapsed,
canUseSplitLayout,
changesTabOpen,
hasFiles,
hideWhitespace,
host,
isMobile,
isRefreshing,
layout,
overflowToggleStyle,
refreshSupported,
wrapLines,
onCollapseAll,
onExpandAll,
onRefresh,
onToggleChangesTab,
onToggleHideWhitespace,
onToggleLayout,
onToggleWrapLines,
}: ChangesOptionsMenuProps) {
const { t } = useTranslation();
const optionsLabel = t("workspace.git.diff.options");
const collapseLabel = t(
allFilesCollapsed ? "workspace.git.diff.expandAllFiles" : "workspace.git.diff.collapseAllFiles",
);
const changesTabLabel = t(
changesTabOpen ? "workspace.git.diff.closeChangesTab" : "workspace.git.diff.openChangesTab",
);
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 refreshLabel = isRefreshing
? t("workspace.git.diff.refreshing")
: t("workspace.git.diff.refresh");
const refreshIcon = useMemo(
() =>
isRefreshing ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
),
[isRefreshing],
);
const showChangesTab = host === "explorer" && !isMobile;
const showLayout = canUseSplitLayout && !changesTabOpen;
return (
<DropdownMenu>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<DropdownMenuTrigger
accessibilityRole="button"
accessibilityLabel={optionsLabel}
testID="changes-options-menu"
style={overflowToggleStyle}
>
<ThemedMoreHorizontal
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">
{hasFiles ? (
<DropdownMenuItem
leading={allFilesCollapsed ? DIFF_OPTIONS_EXPAND_ICON : DIFF_OPTIONS_COLLAPSE_ICON}
testID="changes-toggle-collapse-all"
onSelect={allFilesCollapsed ? onExpandAll : onCollapseAll}
>
{collapseLabel}
</DropdownMenuItem>
) : null}
{showChangesTab ? (
<DropdownMenuItem
leading={DIFF_OPTIONS_CHANGES_TAB_ICON}
testID="changes-open-tab"
onSelect={onToggleChangesTab}
>
{changesTabLabel}
</DropdownMenuItem>
) : null}
{hasFiles || showChangesTab ? <DropdownMenuSeparator /> : null}
{showLayout ? (
<DropdownMenuItem
leading={DIFF_OPTIONS_SPLIT_ICON}
selected={layout === "split"}
testID="changes-toggle-layout"
onSelect={onToggleLayout}
>
{t("workspace.git.diff.split")}
</DropdownMenuItem>
) : null}
<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}
>
{refreshLabel}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
const ThemedRotateCw = withUnistyles(RotateCw);
const EMPTY_PATH_LIST: string[] = [];
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;
diffTooLarge: boolean;
hasChanges: boolean;
emptyMessage: string;
children: ReactElement;
checkingRepositoryLabel: string;
notRepositoryLabel: string;
}
function DiffBodyContent({
isStatusLoading,
statusErrorMessage,
notGit,
isDiffLoading,
diffErrorMessage,
diffTooLarge,
hasChanges,
emptyMessage,
children,
checkingRepositoryLabel,
notRepositoryLabel,
}: DiffBodyContentProps) {
if (isStatusLoading) {
return (
<View style={styles.loadingContainer}>
<ThemedLoadingSpinner 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}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedIconColorMapping} />
</View>
);
}
if (diffTooLarge) {
return <DiffTooLargeState />;
}
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 children;
}
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;
}
// The precise setup step a workspace needs before its forge features work, or
// null when nothing is actionable (authenticated, or no forge remote at all).
type ForgeSetupAction = "install_cli" | "sign_in" | null;
// Drive the onboarding callout from the forge's auth state so the message names
// the exact next step (install the CLI vs sign in) for whichever forge backs the
// workspace — GitHub included. GitLab additionally requires the host to advertise
// GitLab support, matching the rest of the GitLab UI.
function computeForgeSetupAction(input: {
forge: Forge;
forgeProvidersSupported: boolean;
authState: ForgeAuthState | undefined;
}): ForgeSetupAction {
// A daemon without pluggable forge support can't operate any non-GitHub forge,
// so don't offer a setup action for one it can't drive.
if (input.forge !== "github" && !input.forgeProvidersSupported) {
return null;
}
switch (input.authState) {
case "cli_missing":
return "install_cli";
case "unauthenticated":
return "sign_in";
case "authenticated":
case "no_remote":
case "error":
return null;
default:
return null;
}
}
function parseForgeHost(url: string | null | undefined): string | null {
return url ? (parseGitRemoteLocation(url)?.host ?? null) : null;
}
function buildForgeSetupMessage(input: {
action: ForgeSetupAction;
forge: Forge;
host: string | null;
t: TFunction;
}): string | null {
if (!input.action) {
return null;
}
const { brandLabel, signInCli } = getForgePresentation(input.forge);
// A forge with no known CLI (an unknown/third-party forge rendered neutrally)
// has no install/sign-in command to interpolate — show neutral guidance
// rather than the GitLab-specific callout or a null command.
if (signInCli === null) {
return input.t("workspace.git.forgeSetup.generic", { brand: brandLabel });
}
if (input.action === "install_cli") {
return input.t("workspace.git.forgeSetup.installCli", { cli: signInCli, brand: brandLabel });
}
const command = buildForgeSignInCommand(input.forge, input.host);
return input.t("workspace.git.forgeSetup.signIn", { command, brand: brandLabel });
}
function buildDiffModeTriggerStyle(): PressableStyleFn {
return ({ hovered, pressed, open }) => [
styles.diffModeTrigger,
(Boolean(hovered) || pressed || Boolean(open)) && styles.diffModeTriggerHovered,
];
}
function buildOverflowButtonStyle(): PressableStyleFn {
return ({ hovered, pressed }) => [
styles.overflowButton,
(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 useCollapsedDiffFolders({
workspaceId,
cwd,
}: {
workspaceId?: string | null;
cwd: string;
}) {
const workspaceStateKey = useMemo(
() =>
buildWorkspaceExplorerStateKey({
workspaceId,
workspaceRoot: cwd.trim(),
}),
[cwd, workspaceId],
);
const collapsedFolders = usePanelStore((state) =>
workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined,
);
const setCollapsedFolders = usePanelStore((state) => state.setDiffCollapsedFoldersForWorkspace);
const stableCollapsedFolders = collapsedFolders ?? EMPTY_PATH_LIST;
const updateCollapsedFolders = useCallback(
(paths: string[]) => {
if (workspaceStateKey) {
setCollapsedFolders(workspaceStateKey, paths);
}
},
[setCollapsedFolders, workspaceStateKey],
);
return {
collapsedFolders: stableCollapsedFolders,
updateCollapsedFolders,
};
}
function ChangedFilesTree({
workspaceId,
cwd,
files,
mode,
onSelectFile,
}: {
workspaceId?: string | null;
cwd: string;
files: ParsedDiffFile[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
}) {
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const { collapsedFolders: collapsedFolderPaths, updateCollapsedFolders } =
useCollapsedDiffFolders({ workspaceId, cwd });
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
const collapsedFolders = useMemo(() => new Set(collapsedFolderPaths), [collapsedFolderPaths]);
const items = useMemo(
() => flattenDiffTree(compressedTree, collapsedFolders),
[collapsedFolders, compressedTree],
);
const handleSelectPath = useCallback((path: string) => setSelectedPath(path), []);
const handleSelectFile = useCallback(
(path: string) => {
setSelectedPath(path);
onSelectFile(path);
},
[onSelectFile],
);
const handleToggleFolder = useCallback(
(dirPath: string) => {
const next = collapsedFolders.has(dirPath)
? Array.from(collapsedFolders).filter((path) => path !== dirPath)
: [...collapsedFolders, dirPath];
updateCollapsedFolders(next);
},
[collapsedFolders, updateCollapsedFolders],
);
const handleCollapseFolder = useCallback(
(dirPath: string) => {
const prefix = `${dirPath}/`;
updateCollapsedFolders([
...new Set([
...collapsedFolders,
...allFolderPaths.filter(
(folderPath) => folderPath === dirPath || folderPath.startsWith(prefix),
),
]),
]);
},
[allFolderPaths, collapsedFolders, updateCollapsedFolders],
);
const renderItem = useCallback(
({ item }: { item: DiffTreeRow }) => {
if (item.kind === "folder") {
return (
<DiffFolderRow
dirPath={item.dirPath}
displayName={item.displayName}
depth={item.depth}
collapsed={collapsedFolders.has(item.dirPath)}
isSelected={selectedPath === item.dirPath}
additions={item.additions}
deletions={item.deletions}
onToggle={handleToggleFolder}
onCollapse={handleCollapseFolder}
onSelect={handleSelectPath}
onCopyPath={mode.onCopyPath}
onCopyRelativePath={mode.onCopyRelativePath}
onReveal={mode.onReveal}
revealTargetName={mode.revealTargetName}
onDuplicate={mode.onDuplicate}
onRevert={mode.onRevert}
testID={`diff-folder-${item.dirPath}`}
/>
);
}
return (
<FileHeader
file={item.file}
workspaceFileDragScope={mode.workspaceFileDragScope}
bodyVisible={false}
showsBodyState={false}
isSelected={selectedPath === item.file.path}
depth={item.depth}
showDir={false}
onActivate={handleSelectFile}
onSelect={handleSelectPath}
onOpenFile={mode.onOpenFile}
onAddToChat={mode.onAddToChat}
onCopyPath={mode.onCopyPath}
onCopyRelativePath={mode.onCopyRelativePath}
onReveal={mode.onReveal}
revealTargetName={mode.revealTargetName}
onDownload={mode.onDownload}
onDuplicate={mode.onDuplicate}
onRevert={mode.onRevert}
testID={`diff-tree-file-${item.fileIndex}`}
/>
);
},
[
handleCollapseFolder,
handleSelectFile,
handleSelectPath,
handleToggleFolder,
collapsedFolders,
mode,
selectedPath,
],
);
const keyExtractor = useCallback(
(item: DiffTreeRow) =>
item.kind === "folder" ? `folder-${item.dirPath}` : `file-${item.file.path}`,
[],
);
return (
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
testID="changes-file-tree"
/>
);
}
function ChangesTreeRail({
shown,
children,
workspaceId,
cwd,
files,
mode,
onSelectFile,
}: {
shown: boolean;
children: ReactElement;
workspaceId?: string | null;
cwd: string;
files: ParsedDiffFile[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
}) {
if (!shown) return children;
return (
<TreeRail testID="changes-tree-rail">
{children}
<ChangedFilesTree
workspaceId={workspaceId}
cwd={cwd}
files={files}
mode={mode}
onSelectFile={onSelectFile}
/>
</TreeRail>
);
}
function useDiffTabNavigation({
serverId,
workspaceId,
cwd,
isMobile,
}: {
serverId: string;
workspaceId?: string | null;
cwd: string;
isMobile: boolean;
}) {
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const persistenceKey = useMemo(
() => buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? cwd }),
[cwd, serverId, workspaceId],
);
const changesTabId = useWorkspaceLayoutStore((state) => {
if (!persistenceKey) {
return null;
}
const layout = state.layoutByWorkspace[persistenceKey];
return (
layout && collectAllTabs(layout.root).find((tab) => tab.target.kind === "working_diff")?.tabId
);
});
const changesTabOpen = !isMobile && Boolean(changesTabId);
const openChanges = useCallback(
(path?: string) => {
if (!persistenceKey || isMobile) {
return;
}
openWorkspaceTabFocused(persistenceKey, {
kind: "working_diff",
...(path ? { focusPath: path, focusRequestId: Date.now() } : {}),
});
},
[isMobile, openWorkspaceTabFocused, persistenceKey],
);
const toggleChanges = useCallback(() => {
if (!persistenceKey || isMobile) {
return;
}
if (changesTabId) {
closeWorkspaceTab(persistenceKey, changesTabId);
return;
}
openChanges();
}, [changesTabId, closeWorkspaceTab, isMobile, openChanges, persistenceKey]);
const openCommit = useCallback(
(sha: string) => {
if (persistenceKey) {
openWorkspaceTabFocused(persistenceKey, { kind: "commit_diff", sha });
}
},
[openWorkspaceTabFocused, persistenceKey],
);
return {
changesTabOpen,
openChanges,
toggleChanges,
openCommit,
onChangesFilePress: changesTabOpen ? openChanges : undefined,
};
}
export function ChangesSurface({
serverId,
workspaceId,
cwd,
enabled,
host,
focusPath,
focusRequestId,
onOpenFile,
onAddToChat,
}: ChangesSurfaceProps) {
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 desktopTreeVisible = changesPreferences.desktopTreeVisible;
const effectiveLayout = resolveDiffLayout(changesPreferences.layout, canUseSplitLayout);
const collapsedFilesWorkspaceKey = `${serverId}:${workspaceId ?? cwd}`;
const persistedCollapsedFilePaths = usePanelStore(
(state) => state.collapsedFilePathsByWorkspace[collapsedFilesWorkspaceKey],
);
const setCollapsedFilePaths = usePanelStore((state) => state.setCollapsedFilePathsForWorkspace);
const collapsedFilePaths = persistedCollapsedFilePaths ?? EMPTY_PATH_LIST;
const updateCollapsedFilePaths = useCallback(
(paths: string[]) => {
setCollapsedFilePaths(collapsedFilesWorkspaceKey, paths);
},
[collapsedFilesWorkspaceKey, setCollapsedFilePaths],
);
const collapseState = useMemo(
() => ({ paths: collapsedFilePaths, onChange: updateCollapsedFilePaths }),
[collapsedFilePaths, updateCollapsedFilePaths],
);
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 treeToggleStyle = useMemo(
() => buildToggleButtonStyle(desktopTreeVisible, styles.toolbarIconButton),
[desktopTreeVisible],
);
const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []);
const toast = useToast();
const isLocalDaemon = useIsLocalDaemon(serverId);
const { targets: desktopOpenTargets } = useDesktopOpenTargets({
isLocalExecution: isLocalDaemon,
});
const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager");
const {
changesTabOpen: workspaceChangesTabOpen,
toggleChanges: handleToggleChangesTab,
openCommit: handleCommitPress,
onChangesFilePress: workspaceOnChangesFilePress,
} = useDiffTabNavigation({ serverId, workspaceId, cwd, isMobile });
const changesTabOpen = resolveChangesTabOpen(host, workspaceChangesTabOpen);
const onChangesFilePress = resolveChangesFilePress(host, workspaceOnChangesFilePress);
const refreshSupported = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
);
const client = useSessionStore((state) => state.sessions[serverId]?.client);
// COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09.
const fsEntryDuplicateEnabled = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryDuplicate === 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,
isStatusLoading,
isGit,
notGit,
statusErrorMessage,
baseRef,
currentBranchName,
diffMode,
selectUncommitted: handleSelectUncommitted,
selectBase: handleSelectBase,
files,
diffPayloadError,
diffTooLarge,
isDiffLoading,
reviewActions,
reviewAttachment,
} = useWorkingDiff({
serverId,
workspaceId: workspaceId ?? undefined,
cwd,
ignoreWhitespace: changesPreferences.hideWhitespace,
enabled: enabled !== false,
});
usePublishWorkingDiffAttachment({
serverId,
workspaceId: workspaceId ?? undefined,
cwd,
attachment: reviewAttachment,
enabled: !changesTabOpen,
});
const {
githubFeaturesEnabled,
forge,
authState,
payloadError: prPayloadError,
} = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isGit,
});
const forgeProvidersSupported = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.forgeProviders === true,
);
const forgeSetupAction = computeForgeSetupAction({
forge,
forgeProvidersSupported,
authState,
});
const forgeSetupMessage = useMemo(
() =>
buildForgeSetupMessage({
action: forgeSetupAction,
forge,
host: parseForgeHost(status?.remoteUrl),
t,
}),
[forgeSetupAction, forge, status?.remoteUrl, t],
);
const handleToggleDesktopTree = useCallback(() => {
void updateChangesPreferences({ desktopTreeVisible: !desktopTreeVisible });
}, [desktopTreeVisible, updateChangesPreferences]);
const sharedDisplayPreferences = useMemo(
() => ({
layout: effectiveLayout,
wrapLines,
codeFontSize,
monoFontFamily: appSettings.monoFontFamily,
}),
[appSettings.monoFontFamily, codeFontSize, effectiveLayout, wrapLines],
);
const downloadFile = useFileDownload({ serverId, workspaceId, workspaceRoot: cwd });
const handleCopyPath = useCallback(
(path: string) => {
void Clipboard.setStringAsync(
buildAbsoluteExplorerPath({ workspaceRoot: cwd, entryPath: path }),
);
},
[cwd],
);
const handleCopyRelativePath = useCallback((path: string) => {
void Clipboard.setStringAsync(path);
}, []);
const handleRevealPath = useCallback(
async (path: string) => {
if (!fileManagerTarget) {
return;
}
try {
await openDesktopTarget({
editorId: fileManagerTarget.id,
workspacePath: cwd,
filePath: buildAbsoluteExplorerPath({ workspaceRoot: cwd, entryPath: path }),
});
} catch (cause) {
toast.error(
cause instanceof Error ? cause.message : t("workspace.fileExplorer.errors.revealFailed"),
);
}
},
[cwd, fileManagerTarget, t, toast],
);
const handleDownloadPath = useCallback(
(path: string) => {
downloadFile({ fileName: path.split("/").pop() ?? path, path });
},
[downloadFile],
);
const handleDuplicatePath = useCallback(
async (path: string) => {
if (!client) {
return;
}
try {
const payload = await client.duplicateFileEntry({ cwd, path });
if (!payload.success) {
toast.error(payload.error ?? t("workspace.fileExplorer.errors.duplicateFailed"));
}
} catch (cause) {
toast.error(cause instanceof Error ? cause.message : String(cause));
}
},
[client, cwd, t, toast],
);
const onRevertPath = useDiscardChangesAction({ serverId, cwd, diffMode });
const [localFocusRequest, setLocalFocusRequest] = useState<{
path: string;
revision: number;
} | null>(null);
const externalFocusRequest = useMemo(
() => (focusPath ? { path: focusPath, revision: focusRequestId ?? 0 } : null),
[focusPath, focusRequestId],
);
const documentFocusRequest =
localFocusRequest &&
(!externalFocusRequest || localFocusRequest.revision >= externalFocusRequest.revision)
? localFocusRequest
: externalFocusRequest;
const handleSelectTreeFile = useCallback((path: string) => {
setLocalFocusRequest((current) => ({
path,
revision: Math.max(Date.now(), (current?.revision ?? 0) + 1),
}));
}, []);
const workingMode = useMemo(
() => ({
kind: "working" as const,
reviewActions,
onFilePress: onChangesFilePress,
focusPath: documentFocusRequest?.path,
focusRequestId: documentFocusRequest?.revision,
workspaceFileDragScope: workspaceId ? { serverId, workspaceId } : undefined,
onOpenFile,
onAddToChat,
onCopyPath: handleCopyPath,
onCopyRelativePath: handleCopyRelativePath,
onReveal: fileManagerTarget ? handleRevealPath : undefined,
revealTargetName: fileManagerTarget?.label,
onDownload: handleDownloadPath,
onDuplicate: fsEntryDuplicateEnabled ? handleDuplicatePath : undefined,
onRevert: onRevertPath,
}),
[
reviewActions,
onChangesFilePress,
documentFocusRequest?.path,
documentFocusRequest?.revision,
serverId,
workspaceId,
onOpenFile,
onAddToChat,
handleCopyPath,
handleCopyRelativePath,
handleDownloadPath,
handleDuplicatePath,
handleRevealPath,
fileManagerTarget,
fsEntryDuplicateEnabled,
onRevertPath,
],
);
const hasChanges = files.length > 0;
const selectedDiffStat = useMemo(
() => computeSelectedDiffStat(files, isDiffLoading),
[files, isDiffLoading],
);
const allFilesCollapsed =
hasChanges && files.every((file) => collapsedFilePaths.includes(file.path));
const handleCollapseAllFiles = useCallback(
() => updateCollapsedFilePaths(files.map((file) => file.path)),
[files, updateCollapsedFilePaths],
);
const handleExpandAllFiles = useCallback(
() => updateCollapsedFilePaths([]),
[updateCollapsedFilePaths],
);
const diffErrorMessage = diffPayloadError?.message ?? null;
const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError);
const baseRefLabel = useMemo(
() => computeBaseRefLabel(baseRef, t("workspace.git.diff.base")),
[baseRef, t],
);
const { gitActions, branchLabel } = useGitActions({
serverId,
cwd,
icons: GIT_ACTION_ICONS,
});
const committedDiffDescription = useMemo(
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
[baseRefLabel, branchLabel],
);
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 diffContent: ReactElement = (
<DiffBodyContent
isStatusLoading={isStatusLoading}
statusErrorMessage={statusErrorMessage}
notGit={notGit}
isDiffLoading={isDiffLoading}
diffErrorMessage={diffErrorMessage}
diffTooLarge={diffTooLarge}
hasChanges={hasChanges}
emptyMessage={emptyMessage}
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
notRepositoryLabel={t("workspace.git.diff.notRepository")}
>
<DiffDocument
files={files}
collapseState={collapseState}
displayPreferences={sharedDisplayPreferences}
mode={workingMode}
/>
</DiffBodyContent>
);
const bodyContent = (
<ChangesTreeRail
shown={desktopTreeVisible && !isMobile && files.length > 0}
workspaceId={workspaceId}
cwd={cwd}
files={files}
mode={workingMode}
onSelectFile={handleSelectTreeFile}
>
{diffContent}
</ChangesTreeRail>
);
return (
<View
{...{
onContextMenu: (event: { preventDefault?: () => void }) => event.preventDefault?.(),
}}
style={styles.container}
>
{isGit ? (
<ChangesToolbar
branchName={currentBranchName}
allFilesCollapsed={allFilesCollapsed}
canUseSplitLayout={canUseSplitLayout}
changesTabOpen={changesTabOpen}
committedDescription={committedDiffDescription}
cwd={cwd}
desktopTreeVisible={desktopTreeVisible}
diffMode={diffMode}
gitActions={gitActions}
hasFiles={hasChanges}
hideWhitespace={changesPreferences.hideWhitespace}
host={host}
isMobile={isMobile}
isRefreshing={isRefreshing}
layout={changesPreferences.layout}
overflowToggleStyle={overflowToggleStyle}
refreshSupported={refreshSupported}
selectedDiffStat={selectedDiffStat}
serverId={serverId}
treeToggleStyle={treeToggleStyle}
workspaceId={workspaceId}
wrapLines={wrapLines}
onCollapseAll={handleCollapseAllFiles}
onExpandAll={handleExpandAllFiles}
onRefresh={handleRefresh}
onSelectBase={handleSelectBase}
onSelectUncommitted={handleSelectUncommitted}
onToggleChangesTab={handleToggleChangesTab}
onToggleDesktopTree={handleToggleDesktopTree}
onToggleHideWhitespace={handleToggleHideWhitespace}
onToggleLayout={handleToggleLayout}
onToggleWrapLines={handleToggleWrapLines}
/>
) : null}
{forgeSetupMessage ? (
<View style={styles.forgeSetupCallout} testID="forge-setup-callout">
<Text style={styles.forgeSetupCalloutText}>{forgeSetupMessage}</Text>
</View>
) : null}
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
<View style={styles.diffContainer}>{bodyContent}</View>
<CommitsSection serverId={serverId} cwd={cwd} onCommitPress={handleCommitPress} />
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
minHeight: 0,
},
changesToolbar: {
minHeight: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingLeft: theme.spacing[2],
paddingRight: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
flexShrink: 0,
},
changesToolbarIdentity: {
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
changesToolbarControls: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
gap: theme.spacing[1],
flexShrink: 0,
},
diffModeTrigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 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,
},
toggleButtonSelected: {
backgroundColor: theme.colors.surface2,
},
toolbarIconButton: {
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,
},
overflowButton: {
width: ICON_SIZE.sm + 2 * SPACING[1],
height: {
xs: 32,
sm: 32,
md: 24,
},
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.base,
flexShrink: 0,
},
actionErrorText: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[1],
fontSize: theme.fontSize.xs,
color: theme.colors.destructive,
},
forgeSetupCallout: {
marginHorizontal: theme.spacing[3],
marginBottom: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
},
forgeSetupCalloutText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
diffContainer: {
flex: 1,
minHeight: 0,
position: "relative",
},
scrollView: {
flex: 1,
},
scrollContainer: {
flex: 1,
minHeight: 0,
position: "relative",
},
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,
},
tooltipText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
},
}));