import { useSafeAreaInsets } from "react-native-safe-area-context"; import { floatingActionsClearance } from "@/components/ui/floating-action-button"; import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { TreeRail } from "@/components/tree-rail"; import { TreeRailToggle } from "@/components/tree-rail-toggle"; import { DiffStat } from "@/components/diff-stat"; import { View, Text, Pressable, type PressableStateCallbackType, type StyleProp, type ViewStyle, } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import { useIsCompactFormFactor } from "@/constants/layout"; import { AlignJustify, ChevronDown, Columns2, ExternalLink, ListChevronsDownUp, ListChevronsUpDown, Maximize, MoreHorizontal, Pilcrow, RotateCw, WrapText, } from "lucide-react-native"; import { type ParsedDiffFile } from "@/git/use-diff-query"; import type { ChangesState } from "@/panels/changes/state"; import { defaultChangesState } from "@/panels/changes/state"; import { ReviewCommentSheet, ReviewFeedbackAction } from "@/review"; import { DiffDocument, type WorkingDiffMode } from "@/git/diff-document"; import { ChangedFilesTree } from "@/git/changed-files-tree"; import { JUMP_TO_FILE_CLEARANCE, JumpToFile } from "@/git/jump-to-file"; import { selectPrHintFromStatus, type PrHint, useCheckoutPrStatusQuery, } from "@/git/use-pr-status-query"; import { CommitsSection } from "@/git/commits-section/commits-section"; import { useAppSettings } from "@/hooks/use-settings"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; 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 { Button } from "@/components/ui/button"; import { PaneContentToolbar, paneContentToolbarIconSize, paneContentToolbarIconButtonStyle, paneContentToolbarTrailingPadding, type ToolbarTrailingControl, ToolbarButton, ToolbarControls, } from "@/components/ui/pane-content-toolbar"; import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; import { isToolbarLabelTriggerHighlighted, ToolbarLabelTriggerIcon, toolbarLabelTriggerTextStyle, toolbarLabelTriggerStyle, } from "@/components/ui/toolbar-label-trigger"; import { FOCUSED_PANE_PLACEMENT, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import type { WorkspaceTabPlacement } from "@/stores/workspace-layout-actions"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { isWeb } from "@/constants/platform"; import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff"; import type { CheckoutStatusPayload } from "@/git/use-status-query"; import { DiffTooLargeState } from "@/git/diff-too-large-state"; import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; import { PullRequestStateIcon } from "@/git/pull-request-state-icon"; import { openExternalUrl } from "@/utils/open-external-url"; import { openWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view"; import type { PullRequestOpenLocation } from "@/hooks/use-settings"; 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; presentation?: ChangesPresentation; focusPath?: string; focusRequestId?: number; onOpenFile?: (path: string) => void; onOpenToSide?: (path: string) => void; onSelectDiffFile?: (path: string) => void; onAddToChat?: (path: string) => void; state?: ChangesState; onStateChange?: (state: ChangesState) => void; } type PressableStyleFn = ( state: PressableStateCallbackType & { hovered?: boolean; open?: boolean }, ) => StyleProp; 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 ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp); const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown); const ThemedMaximize = withUnistyles(Maximize); const noopStateChange = () => {}; const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedMoreHorizontal = withUnistyles(MoreHorizontal); const ThemedExternalLink = withUnistyles(ExternalLink); const DIFF_OPTIONS_WHITESPACE_ICON = ( ); const DIFF_OPTIONS_WRAP_ICON = ( ); const DIFF_OPTIONS_SPLIT_ICON = ( ); const DIFF_OPTIONS_COLLAPSE_ICON = ( ); const DIFF_OPTIONS_EXPAND_ICON = ( ); const DIFF_OPTIONS_CHANGES_TAB_ICON = ( ); 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, undefined, isMobile), [isMobile], ); const { t } = useTranslation(); const label = layout === "unified" ? t("workspace.git.diff.switchToSplit") : t("workspace.git.diff.switchToUnified"); return ( {layout === "unified" ? ( ) : ( )} {label} ); } 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 uncommittedLabel = t("workspace.git.diff.uncommitted"); const committedLabel = t("workspace.git.diff.committed"); return ( {(state) => { const highlighted = isToolbarLabelTriggerHighlighted(state); return ( <> {diffMode === "uncommitted" ? uncommittedLabel : committedLabel} ); }} {uncommittedLabel} {committedLabel} ); } type ChangesPresentation = "combined" | "tree" | "diff"; interface ChangesToolbarRefreshAction { isRefreshing: boolean; onRefresh: () => void; } interface ChangesToolbarInlineDiffToggle { value: boolean; onToggle: () => void; } interface ChangesToolbarDiffOptions { collapse: { allFilesCollapsed: boolean; onCollapseAll: () => void; onExpandAll: () => void; } | null; layout: { value: "unified" | "split"; onToggle: () => void; } | null; hideWhitespace: boolean; wrapLines: boolean; onToggleHideWhitespace: () => void; onToggleWrapLines: () => void; } type ChangesToolbarMode = | { kind: "tree"; onOpenDiff: () => void; inlineDiff: ChangesToolbarInlineDiffToggle | null; refresh: ChangesToolbarRefreshAction | null; } | { kind: "diff"; options: ChangesToolbarDiffOptions; refresh: ChangesToolbarRefreshAction | null; } | { kind: "combined"; options: ChangesToolbarDiffOptions; refresh: ChangesToolbarRefreshAction | null; treeToggle: { visible: boolean; onToggle: () => void } | null; inlineDiff: ChangesToolbarInlineDiffToggle | null; }; function buildChangesToolbarMode(input: { presentation: ChangesPresentation; compact: boolean; hasChanges: boolean; canUseSplitLayout: boolean; refreshSupported: boolean; isRefreshing: boolean; layout: "unified" | "split"; hideWhitespace: boolean; wrapLines: boolean; treeVisible: boolean; onOpenDiff: () => void; inlineDiff: ChangesToolbarInlineDiffToggle | null; onRefresh: () => void; onCollapseAll: () => void; onExpandAll: () => void; allFilesCollapsed: boolean; onToggleLayout: () => void; onToggleHideWhitespace: () => void; onToggleWrapLines: () => void; onToggleTree: () => void; }): ChangesToolbarMode { const refresh = input.refreshSupported ? { isRefreshing: input.isRefreshing, onRefresh: input.onRefresh } : null; if (input.presentation === "tree") { return { kind: "tree", onOpenDiff: input.onOpenDiff, inlineDiff: input.inlineDiff, refresh, }; } const options: ChangesToolbarDiffOptions = { collapse: input.hasChanges ? { allFilesCollapsed: input.allFilesCollapsed, onCollapseAll: input.onCollapseAll, onExpandAll: input.onExpandAll, } : null, layout: input.canUseSplitLayout ? { value: input.layout, onToggle: input.onToggleLayout } : null, hideWhitespace: input.hideWhitespace, wrapLines: input.wrapLines, onToggleHideWhitespace: input.onToggleHideWhitespace, onToggleWrapLines: input.onToggleWrapLines, }; if (input.presentation === "diff") { return { kind: "diff", options, refresh }; } return { kind: "combined", options, refresh, treeToggle: !input.compact && input.hasChanges ? { visible: input.treeVisible, onToggle: input.onToggleTree } : null, inlineDiff: input.inlineDiff, }; } interface ChangesPullRequestLinkModel extends Pick { onOpen: () => void; } interface ChangesRepositoryToolbarModel { branchName: string | null; cwd: string; gitActions: GitActions | null; pullRequest: ChangesPullRequestLinkModel | null; serverId: string; workspaceId?: string | null; } interface ChangesComparisonToolbarModel { committedDescription?: string; diffMode: "uncommitted" | "base"; mode: ChangesToolbarMode; selectedDiffStat: { additions: number; deletions: number } | null; onSelectBase: () => void; onSelectUncommitted: () => void; } interface ChangesHeaderProps { compact: boolean; repository: ChangesRepositoryToolbarModel; comparison: ChangesComparisonToolbarModel; sidebarSurface: boolean; } interface BuildChangesHeaderModelInput { branchName: string | null; committedDescription?: string; compact: boolean; cwd: string; diffMode: "uncommitted" | "base"; gitActions: GitActions; mode: ChangesToolbarMode; onOpenPullRequest: () => void; onSelectBase: () => void; onSelectUncommitted: () => void; pullRequest: PrHint | null; selectedDiffStat: { additions: number; deletions: number } | null; serverId: string; workspaceId?: string | null; } function buildChangesHeaderModel(input: BuildChangesHeaderModelInput): { repository: ChangesRepositoryToolbarModel; comparison: ChangesComparisonToolbarModel; } { return { repository: { branchName: input.branchName, cwd: input.cwd, gitActions: input.compact ? input.gitActions : null, pullRequest: input.pullRequest ? { ...input.pullRequest, onOpen: input.onOpenPullRequest } : null, serverId: input.serverId, workspaceId: input.workspaceId, }, comparison: { committedDescription: input.committedDescription, diffMode: input.diffMode, mode: input.mode, selectedDiffStat: input.selectedDiffStat, onSelectBase: input.onSelectBase, onSelectUncommitted: input.onSelectUncommitted, }, }; } // Presentation resolves into these two capability models before rendering. The rows // never infer which host or Changes presentation produced them. function ChangesHeader({ compact, repository, comparison, sidebarSurface }: ChangesHeaderProps) { if (comparison.mode.kind === "diff") { return ( ); } return ( ); } function ChangesDiffOnlyToolbar({ compact, mode, sidebarSurface, }: { compact: boolean; mode: Extract; sidebarSurface: boolean; }) { return ( ); } function ChangesToolbarRow({ children, compact, sidebarSurface, testID, trailing, }: { children: ReactNode; compact: boolean; sidebarSurface: boolean; testID: string; /** What the row's last control paints, so it can pad to the shared trailing rail. */ trailing: ToolbarTrailingControl; }) { const toolbarStyle = useMemo( () => [ styles.changesToolbar, { paddingRight: paneContentToolbarTrailingPadding(compact, trailing) }, sidebarSurface ? styles.changesToolbarSidebar : null, ], [compact, sidebarSurface, trailing], ); return ( {children} ); } function ChangesToolbarLeading({ children }: { children?: ReactNode }) { return {children}; } function ChangesToolbarTrailing({ children }: { children: ReactNode }) { return {children}; } function ChangesRepositoryToolbar({ compact, model, sidebarSurface, }: { compact: boolean; model: ChangesRepositoryToolbarModel; sidebarSurface: boolean; }) { return ( {model.pullRequest ? ( <> ) : null} {model.gitActions ? : null} ); } function ChangesPullRequestLink({ model }: { model: ChangesPullRequestLinkModel }) { const { t } = useTranslation(); const presentation = getForgePresentation(model.forge); const label = `${presentation.numberPrefix}${model.number}`; return ( {(state) => { const highlighted = isToolbarLabelTriggerHighlighted(state); return ( <> {label} ); }} {t("panels.pullRequest.label")} ); } function ChangesPullRequestExternalLink({ compact, model, }: { compact: boolean; model: ChangesPullRequestLinkModel; }) { const { t } = useTranslation(); const presentation = getForgePresentation(model.forge); const label = t("workspace.git.pr.actions.openOn", { brand: presentation.brandLabel }); const handlePress = useCallback(() => { void openExternalUrl(model.url); }, [model.url]); return ( ); } function ChangesComparisonToolbar({ compact, model, sidebarSurface, }: { compact: boolean; model: ChangesComparisonToolbarModel; sidebarSurface: boolean; }) { return ( {model.selectedDiffStat ? ( ) : null} ); } function ChangesToolbarActions({ mode, compact }: { mode: ChangesToolbarMode; compact: boolean }) { if (mode.kind === "tree") { return ( <> {mode.refresh ? : null} ); } if (mode.kind === "diff") { return ( <> {mode.refresh ? : null} ); } return ( <> {mode.treeToggle ? ( ) : null} {mode.refresh ? : null} ); } function ChangesDiffToolbar({ options, compact, }: { options: ChangesToolbarDiffOptions; compact: boolean; }) { const { t } = useTranslation(); const iconSize = paneContentToolbarIconSize(compact); const collapseLabel = t( options.collapse?.allFilesCollapsed ? "workspace.git.diff.expandAllFiles" : "workspace.git.diff.collapseAllFiles", ); const whitespaceLabel = options.hideWhitespace ? t("workspace.git.diff.showWhitespace") : t("workspace.git.diff.hideWhitespace"); const wrapLinesLabel = options.wrapLines ? t("workspace.git.diff.scrollLongLines") : t("workspace.git.diff.wrapLongLines"); const layoutLabel = options.layout?.value === "split" ? t("workspace.git.diff.switchToUnified") : t("workspace.git.diff.switchToSplit"); return ( <> {options.collapse ? ( {options.collapse.allFilesCollapsed ? ( ) : ( )} ) : null} {options.layout ? ( ) : null} ); } function ChangesRefreshButton({ refresh, compact, }: { refresh: ChangesToolbarRefreshAction; compact: boolean; }) { const { t } = useTranslation(); const refreshLabel = refresh.isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refresh"); return ( {refresh.isRefreshing ? ( ) : ( )} ); } type ChangesOptionsMenuMode = Extract; function ChangesOptionsMenu({ mode, compact }: { mode: ChangesOptionsMenuMode; compact: boolean }) { const { t } = useTranslation(); const optionsLabel = t("workspace.git.diff.options"); const content = mode.kind === "tree" ? ( ) : ( <> {mode.inlineDiff ? ( <> ) : null} ); return ( {content} ); } function ChangesTreeOptions({ onOpenDiff, inlineDiff, }: { onOpenDiff: () => void; inlineDiff: ChangesToolbarInlineDiffToggle | null; }) { const { t } = useTranslation(); return ( <> {t("workspace.git.diff.openDiffTab")} {inlineDiff ? ( <> ) : null} ); } function ChangesInlineDiffOption({ toggle }: { toggle: ChangesToolbarInlineDiffToggle }) { const { t } = useTranslation(); return ( {t("workspace.git.diff.inlineDiff")} ); } function ChangesDiffOptions({ options }: { options: ChangesToolbarDiffOptions }) { const { t } = useTranslation(); const collapseLabel = t( options.collapse?.allFilesCollapsed ? "workspace.git.diff.expandAllFiles" : "workspace.git.diff.collapseAllFiles", ); const whitespaceLabel = options.hideWhitespace ? t("workspace.git.diff.showWhitespace") : t("workspace.git.diff.hideWhitespace"); const wrapLinesLabel = options.wrapLines ? t("workspace.git.diff.scrollLongLines") : t("workspace.git.diff.wrapLongLines"); return ( <> {options.collapse ? ( <> {collapseLabel} ) : null} {options.layout ? ( {t("workspace.git.diff.split")} ) : null} {whitespaceLabel} {wrapLinesLabel} ); } const ThemedRotateCw = withUnistyles(RotateCw); interface DiffBodyContentProps { isStatusLoading: boolean; statusErrorMessage: string | null; notGit: boolean; isDiffLoading: boolean; diffErrorMessage: string | null; diffTooLarge: boolean; hasChanges: boolean; emptyMessage: string; emptyAction: ChangesEmptyAction | null; children: ReactElement; checkingRepositoryLabel: string; notRepositoryLabel: string; } function DiffBodyContent({ isStatusLoading, statusErrorMessage, notGit, isDiffLoading, diffErrorMessage, diffTooLarge, hasChanges, emptyMessage, emptyAction, children, checkingRepositoryLabel, notRepositoryLabel, }: DiffBodyContentProps) { if (isStatusLoading) { return ( {checkingRepositoryLabel} ); } if (statusErrorMessage) { return ( {statusErrorMessage} ); } if (notGit) { return ( {notRepositoryLabel} ); } if (isDiffLoading) { return ( ); } if (diffTooLarge) { return ; } if (diffErrorMessage) { return ( {diffErrorMessage} ); } if (!hasChanges) { return ( {emptyMessage} {emptyAction ? ( ) : null} ); } 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}`; } interface ChangesEmptyAction { label: string; onPress: () => void; } function computeChangesEmptyAction(input: { hideWhitespace: boolean; diffMode: "uncommitted" | "base"; status: CheckoutStatusPayload | null; seeUncommittedLabel: string; seeCommittedLabel: string; selectUncommitted: () => void; selectBase: () => void; }): ChangesEmptyAction | null { if (input.hideWhitespace || !input.status?.isGit) { return null; } if (input.diffMode === "base" && input.status.isDirty) { return { label: input.seeUncommittedLabel, onPress: input.selectUncommitted }; } if (input.diffMode === "uncommitted" && (input.status.aheadBehind?.ahead ?? 0) > 0) { return { label: input.seeCommittedLabel, onPress: input.selectBase }; } return null; } 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 buildToggleButtonStyle( selected: boolean, baseStyles?: StyleProp | StyleProp[], isMobile = false, ): PressableStyleFn { return (state) => [baseStyles, paneContentToolbarIconButtonStyle(state, selected, isMobile)]; } interface JumpToFileVisibility { isCompact: boolean; presentation: ChangesPresentation; hasChanges: boolean; isDiffLoading: boolean; } /** * The room the floating Jump to file action needs over the diff, and zero where * it is not offered. Compact users never see the tree rail, so the diff is their * only view of what changed; the tree and diff presentations are already halves * of a desktop split. */ function jumpToFileClearance(visibility: JumpToFileVisibility): number { if (!visibility.isCompact || visibility.presentation !== "combined") return 0; if (!visibility.hasChanges || visibility.isDiffLoading) return 0; return JUMP_TO_FILE_CLEARANCE; } function ChangesTreeRail({ shown, children, files, mode, onSelectFile, treeWidth, onTreeWidthChange, collapsedFolderPaths, onCollapsedFolderPathsChange, }: { shown: boolean; children: ReactElement; files: ParsedDiffFile[]; mode: WorkingDiffMode; onSelectFile: (path: string) => void; treeWidth?: number; onTreeWidthChange: (width: number) => void; collapsedFolderPaths: string[]; onCollapsedFolderPathsChange: (paths: string[]) => void; }) { if (!shown) return children; return ( {children} ); } function ChangesBody({ presentation, children, desktopTreeVisible, isMobile, files, mode, onSelectFile, treeWidth, onTreeWidthChange, collapsedFolderPaths, onCollapsedFolderPathsChange, }: { presentation: ChangesPresentation; children: ReactElement; desktopTreeVisible: boolean; isMobile: boolean; files: ParsedDiffFile[]; mode: WorkingDiffMode; onSelectFile: (path: string) => void; treeWidth?: number; onTreeWidthChange: (width: number) => void; collapsedFolderPaths: string[]; onCollapsedFolderPathsChange: (paths: string[]) => void; }) { if (presentation === "tree") { if (files.length === 0) return children; return ( ); } if (presentation === "diff") return children; return ( 0} files={files} mode={mode} onSelectFile={onSelectFile} treeWidth={treeWidth} onTreeWidthChange={onTreeWidthChange} collapsedFolderPaths={collapsedFolderPaths} onCollapsedFolderPathsChange={onCollapsedFolderPathsChange} > {children} ); } function ChangesCommits({ presentation, serverId, cwd, collapsed, onCommitPress, onCollapsedChange, }: { presentation: ChangesPresentation; serverId: string; cwd: string; collapsed: boolean; onCommitPress: (sha: string) => void; onCollapsedChange: (collapsed: boolean) => void; }) { if (presentation === "diff") return null; return ( ); } function useDiffTabNavigation({ serverId, workspaceId, cwd, isMobile, pullRequestOpenLocation, }: { serverId: string; workspaceId?: string | null; cwd: string; isMobile: boolean; pullRequestOpenLocation: PullRequestOpenLocation; }) { const openTab = useWorkspaceLayoutStore((state) => state.openTab); const openWorkspaceTab = useCallback( (workspaceKey: string, target: WorkspaceTabTarget, placement?: WorkspaceTabPlacement) => openTab({ workspaceKey, target, intent: "reveal", placement }), [openTab], ); const persistenceKey = useMemo( () => buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? cwd }), [cwd, serverId, workspaceId], ); const openDiff = useCallback(() => { if (!persistenceKey || isMobile) { return; } openWorkspaceTab(persistenceKey, { kind: "working_diff" }, FOCUSED_PANE_PLACEMENT); }, [isMobile, openWorkspaceTab, persistenceKey]); const openCommit = useCallback( (sha: string) => { if (persistenceKey) { openWorkspaceTab(persistenceKey, { kind: "commit_diff", sha }, FOCUSED_PANE_PLACEMENT); } }, [openWorkspaceTab, persistenceKey], ); const openPullRequest = useCallback(() => { if (!persistenceKey) return; openWorkspacePullRequest({ isCompact: isMobile, workspaceKey: persistenceKey, checkout: { serverId, cwd, isGit: true }, destination: pullRequestOpenLocation, }); }, [cwd, isMobile, persistenceKey, pullRequestOpenLocation, serverId]); return { openDiff, openCommit, openPullRequest, }; } export function ChangesSurface({ serverId, workspaceId, cwd, enabled, presentation = "combined", focusPath, focusRequestId, onOpenFile, onOpenToSide, onSelectDiffFile, onAddToChat, state: changesState, onStateChange, }: ChangesSurfaceProps) { const { settings: appSettings } = useAppSettings(); const { preferences, updatePreferences } = useChangesPreferences(); const { t } = useTranslation(); const isMobile = useIsCompactFormFactor(); const canUseSplitLayout = isWeb && !isMobile; const instanceState = changesState ?? defaultChangesState; const updateState = onStateChange ?? noopStateChange; const wrapLines = preferences.wrapLines; const desktopTreeVisible = instanceState.treeVisible; const effectiveLayout = resolveDiffLayout(preferences.layout, canUseSplitLayout); const collapsedFilePaths = instanceState.collapsedFilePaths; const updateCollapsedFilePaths = useCallback( (paths: string[]) => updateState({ ...instanceState, collapsedFilePaths: paths }), [instanceState, updateState], ); const updateCollapsedFolderPaths = useCallback( (paths: string[]) => updateState({ ...instanceState, collapsedFolderPaths: paths }), [instanceState, updateState], ); const collapseState = useMemo( () => ({ paths: collapsedFilePaths, onChange: updateCollapsedFilePaths }), [collapsedFilePaths, updateCollapsedFilePaths], ); const handleToggleWrapLines = useCallback(() => { void updatePreferences({ wrapLines: !wrapLines }); }, [updatePreferences, wrapLines]); const handleToggleHideWhitespace = useCallback(() => { void updatePreferences({ hideWhitespace: !preferences.hideWhitespace }); }, [preferences.hideWhitespace, updatePreferences]); const handleToggleInlineDiff = useCallback(() => { void updatePreferences({ inlineDiff: !preferences.inlineDiff }); }, [preferences.inlineDiff, updatePreferences]); const handleToggleLayout = useCallback(() => { const layout = preferences.layout === "unified" ? "split" : "unified"; void updatePreferences({ layout }); }, [preferences.layout, updatePreferences]); const codeFontSize = appSettings.codeFontSize; const toast = useToast(); const isLocalDaemon = useIsLocalDaemon(serverId); const { targets: desktopOpenTargets } = useDesktopOpenTargets({ isLocalExecution: isLocalDaemon, }); const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager"); const { openDiff: handleOpenDiff, openCommit: handleCommitPress, openPullRequest: handleOpenPullRequest, } = useDiffTabNavigation({ serverId, workspaceId, cwd, isMobile, pullRequestOpenLocation: appSettings.pullRequestOpenLocation, }); 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, reviewFeedback, } = useWorkingDiff({ presentation, serverId, workspaceId: workspaceId ?? undefined, cwd, ignoreWhitespace: preferences.hideWhitespace, enabled: enabled !== false, }); usePublishWorkingDiffAttachment({ serverId, workspaceId: workspaceId ?? undefined, cwd, attachment: reviewAttachment, enabled: true, }); const { status: pullRequestStatus, 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(() => { updateState({ ...instanceState, treeVisible: !desktopTreeVisible }); }, [desktopTreeVisible, instanceState, updateState]); const handleCommitsCollapsedChange = useCallback( (commitsCollapsed: boolean) => updateState({ ...instanceState, commitsCollapsed }), [instanceState, updateState], ); const handleChangesTreeWidth = useCallback( (treeWidth: number) => updateState({ ...instanceState, treeWidth }), [instanceState, updateState], ); 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) => { if (presentation === "tree" && onSelectDiffFile) { onSelectDiffFile(path); return; } setLocalFocusRequest((current) => ({ path, revision: Math.max(Date.now(), (current?.revision ?? 0) + 1), })); }, [onSelectDiffFile, presentation], ); const workingMode = useMemo( () => ({ kind: "working" as const, reviewActions, focusPath: documentFocusRequest?.path, focusRequestId: documentFocusRequest?.revision, workspaceFileDragScope: workspaceId ? { serverId, workspaceId } : undefined, onOpenFile, onOpenToSide, onAddToChat, onCopyPath: handleCopyPath, onCopyRelativePath: handleCopyRelativePath, onReveal: fileManagerTarget ? handleRevealPath : undefined, revealTargetName: fileManagerTarget?.label, onDownload: handleDownloadPath, onDuplicate: fsEntryDuplicateEnabled ? handleDuplicatePath : undefined, onRevert: onRevertPath, }), [ reviewActions, documentFocusRequest?.path, documentFocusRequest?.revision, serverId, workspaceId, onOpenFile, onOpenToSide, onAddToChat, handleCopyPath, handleCopyRelativePath, handleDownloadPath, handleDuplicatePath, handleRevealPath, fileManagerTarget, fsEntryDuplicateEnabled, onRevertPath, ], ); const { bottom: bottomInset } = useSafeAreaInsets(); const hasChanges = files.length > 0; const jumpToFileInset = jumpToFileClearance({ isCompact: isMobile, presentation, hasChanges, isDiffLoading, }); const showJumpToFile = jumpToFileInset > 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 = t("diffViewer.empty"); const emptyAction = computeChangesEmptyAction({ hideWhitespace: preferences.hideWhitespace, diffMode, status, seeUncommittedLabel: t("workspace.git.diff.seeUncommittedChanges"), seeCommittedLabel: t("workspace.git.diff.seeCommittedChanges"), selectUncommitted: handleSelectUncommitted, selectBase: handleSelectBase, }); const diffContent: ReactElement = ( ); const bodyContent = ( {diffContent} ); const toolbarMode = useMemo( () => buildChangesToolbarMode({ presentation, compact: isMobile, hasChanges, canUseSplitLayout, refreshSupported, isRefreshing, layout: preferences.layout, hideWhitespace: preferences.hideWhitespace, wrapLines, treeVisible: desktopTreeVisible, onOpenDiff: handleOpenDiff, inlineDiff: !isMobile ? { value: preferences.inlineDiff, onToggle: handleToggleInlineDiff } : null, onRefresh: handleRefresh, onCollapseAll: handleCollapseAllFiles, onExpandAll: handleExpandAllFiles, allFilesCollapsed, onToggleLayout: handleToggleLayout, onToggleHideWhitespace: handleToggleHideWhitespace, onToggleWrapLines: handleToggleWrapLines, onToggleTree: handleToggleDesktopTree, }), [ allFilesCollapsed, canUseSplitLayout, desktopTreeVisible, handleCollapseAllFiles, handleExpandAllFiles, handleOpenDiff, handleToggleInlineDiff, handleRefresh, handleToggleDesktopTree, handleToggleHideWhitespace, handleToggleLayout, handleToggleWrapLines, hasChanges, preferences.hideWhitespace, preferences.inlineDiff, preferences.layout, isMobile, isRefreshing, presentation, refreshSupported, wrapLines, ], ); const changesHeaderModel = useMemo( () => buildChangesHeaderModel({ branchName: currentBranchName, committedDescription: committedDiffDescription, compact: isMobile, cwd, diffMode, gitActions, mode: toolbarMode, onOpenPullRequest: handleOpenPullRequest, onSelectBase: handleSelectBase, onSelectUncommitted: handleSelectUncommitted, pullRequest: selectPrHintFromStatus(pullRequestStatus, forge), selectedDiffStat, serverId, workspaceId, }), [ committedDiffDescription, currentBranchName, cwd, diffMode, gitActions, handleOpenPullRequest, handleSelectBase, handleSelectUncommitted, isMobile, forge, pullRequestStatus, selectedDiffStat, serverId, toolbarMode, workspaceId, ], ); return ( void }) => event.preventDefault?.(), }} style={styles.container} > {isGit ? ( ) : null} {forgeSetupMessage ? ( {forgeSetupMessage} ) : null} {prErrorMessage ? {prErrorMessage} : null} {bodyContent} {showJumpToFile ? ( ) : null} ); } const styles = StyleSheet.create((theme) => ({ container: { flex: 1, minHeight: 0, }, changesToolbar: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingLeft: theme.spacing[2], }, changesToolbarSidebar: { backgroundColor: theme.colors.surfaceSidebar, }, changesToolbarIdentity: { flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], }, changesToolbarControls: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", flexShrink: 0, }, diffStatusIconHidden: { opacity: 0, }, actionErrorText: { paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[1], fontSize: theme.fontSize.sm, 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.sm, color: theme.colors.foregroundMuted, }, diffContainer: { flex: 1, minHeight: 0, position: "relative", }, scrollContainer: { flex: 1, minHeight: 0, position: "relative", }, 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], gap: theme.spacing[2], }, emptyText: { fontSize: theme.fontSize.base, color: theme.colors.foregroundMuted, }, tooltipText: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, }, }));