* feat(workspace): bind agents/terminals/status to workspaceId (Model B phase 1)
Model B EX-Phase 1: the binding layer. Records gain an additive
workspaceId that answers "which workspace does this belong to" without
replacing the existing required cwd.
- protocol: add optional workspaceId to agent/terminal records and a
workspaceOwnership capability under server_info.features.*
- server: resolve and persist workspaceId on agent and terminal
creation; carry it through agent-manager, projections, storage,
loading, sessions, and the terminal manager/worker pipeline
- workspace-directory / workspace-registry-model: derive and expose
workspace ownership; bind status to workspaceId
- client: surface workspaceId via daemon-client
- app: filter terminals and agent visibility by workspaceId, thread it
through workspace screen / terminal panel / session store
- tests: cover workspaceId binding across create, agent-manager,
mcp-server, workspace-directory, workspace-registry-model, and
agent-visibility
cwd stays required on every record; workspaceId is purely additive and
back-compatible (optional/defaulted), so old and new peers still parse
each other's messages.
Known follow-ups: none.
* feat(workspace): empty projects + editable titles, fix same-cwd terminal isolation (Model B phase 2)
- Address Phase 1 review fixes and add same-cwd terminal isolation e2e coverage
- Isolate terminal subscriptions per workspaceId so two workspaces sharing one cwd no longer cross-wire terminals (terminal-subscription-key)
- Add editable workspace title, decoupled from the backing directory/branch
- Persist empty projects (no workspaces yet) across daemon restarts
- Add e2e coverage: empty-project-persists (server + app), workspace-same-cwd-isolation, sidebar-workspace-rename
Known follow-ups: none
* feat(workspace): create multiple workspaces per directory — local or worktree (Model B phase 3)
- workspace.create RPC always creates a new workspace record; no directory dedup
- dropped directory dedup in open_project and createPaseoWorktree paths
- non-git directories are first-class: local-checkout workspaces no longer require a repo
- service-proxy collision resolved by defaulting to the owning workspace instead of failing
- new creation UI: choose backing directory (local checkout or worktree) per workspace
- e2e coverage for creating multiple workspaces over the same directory
Known follow-ups:
- COMPAT cwd->workspaceId resolver remains until client floor advances
- prune any remaining workspace==directory assumptions surfaced by usage
* feat(cli): paseo run workspace policy — bare run creates a workspace, --workspace/PASEO_WORKSPACE_ID target (Model B phase 4)
- A workspace is now the explicit home of a `paseo run`: run.ts resolves a
workspace before creating any agent, then stamps the agent with that
workspaceId — no run leans on createAgent's legacy cwd->workspace fallback.
- Precedence: --workspace <id> > $PASEO_WORKSPACE_ID > --worktree > bare run.
--worktree mints its own workspace and overrides the ambient
PASEO_WORKSPACE_ID; --worktree + --workspace is rejected upstream.
- New-workspace-per-bare-run: every bare run mints a fresh local-backed
workspace for its cwd rather than reattaching to an existing one in that dir.
- Env support: $PASEO_WORKSPACE_ID (exported by workspace terminals) targets an
existing workspace, same as --workspace.
- Help text: new --workspace option documents the default (new workspace per
run) and the env fallback; created runs print the workspace id plus a tip.
- e2e: cli-run-workspace-precedence.e2e.test.ts covers bare, distinct-per-cwd,
--workspace attach, and env attach against an isolated daemon.
Known follow-ups:
- createAgent's COMPAT cwd->workspace resolver stays for old clients; the CLI no
longer relies on it but it is not yet removable.
* feat(workspace): archive removes the workspace record, never the directory; last worktree ref offers disk delete (Model B phase 5)
- Archive is now scoped to a single workspace RECORD (by workspaceId), not by cwd/worktree path. It tears down only the agents and terminals owned by the target workspaceId.
- Sibling isolation: a directory can back multiple workspaces, so archiving one workspace no longer destroys a sibling workspace's agents, terminals, or directory.
- Decoupled on-disk worktree deletion into an explicit, last-reference-only option (deleteWorktreeFromDisk). The directory is removed only when the archived workspace was the last active reference to a Paseo-owned worktree; local checkouts are never deleted.
- Unified archive UI with a keep/delete prompt: "Keep on disk" is the default non-destructive choice, "Delete" removes the worktree directory.
- Protocol: new optional, defaulted deleteWorktreeFromDisk field (COMPAT-tagged, back-compat preserved).
- Tests: record-scoped archive e2e (sibling isolation, last-ref disk delete, sibling-keeps-directory) and an app e2e for the keep prompt.
Known follow-ups:
- Surface the keep/delete prompt's "delete" path coverage in app e2e (only the keep path is exercised today).
- COMPAT(worktreeDiskDeletion): drop the optional gate when floor >= v0.1.97.
* feat(workspace): uniform expandable projects + status inbox; merged worktrees cleaned, explicit-id archive (Model B phase 6)
- P5 archive fixes: worktree archive now targets an explicit workspaceId, and auto-archive-on-merge cleans the worktree directory from disk when it is the last reference.
- Dropped non-git project flattening: every project — git or non-git, single- or multi-workspace — renders as the same expandable parent.
- Every project is expandable and every workspace is archivable; each carries its own "+ New workspace" affordance regardless of kind.
- Added a status inbox grouping (Ready for review / Working / Done) where each workspace is bucketed independently by last update.
- The deepest sidebar level is the workspace row: tabs, agents, and terminals never appear in the sidebar.
- Added Model B sidebar e2e coverage proving the expandable-parent, no-leaf, and independent-status-bucketing invariants.
Known follow-ups: the inline header FolderPlus worktree shortcut stays git-only (canCreateWorktreeForProjectKind); non-git projects create workspaces via the "+ New workspace" row.
* fix(protocol): use literal :: separator in terminal subscription key
The committed blob contained a NUL byte as the separator instead of the
:: the comment documents, which broke text diffs and review tooling
(git flagged the file as binary). Functionally the key is an in-memory
Map key only, but source must stay text.
* feat(workspace): shared directory right-sidebar boundary + docs; final consolidation (Model B phase 7)
- Pin and document the directory-backed vs workspace-owned right-sidebar boundary:
same-cwd workspaces share directory-keyed git/PR/file surfaces but never share
workspace-owned drafts, attachments, or file-explorer state. New docs section in
architecture.md plus data-model.md keying convention and two glossary terms.
- Add e2e coverage for the boundary: same-directory-workspaces.spec.ts proves the
right sidebar is shared (a directory change appears in both same-dir workspaces)
while tabs/terminals stay independent per workspace.
- Accumulated follow-up fixes:
- MCP child agents created in a new worktree are stamped with the new worktree's
workspaceId (not the parent's, not unstamped), mirroring the session path so
workspaceId-scoped archive can find and tear them down.
- session-store mergeWorkspaces prunes a stale empty-project descriptor when a
workspace lands in that project, so it stops governing the project's metadata.
- CLI run: reject --worktree alongside an ambient PASEO_WORKSPACE_ID (not just
--workspace), so worktree resolution never races an existing-workspace select.
New run.test.ts pins the three validation outcomes.
- Full-branch reshape outcomes:
- Consolidate the four placeholder server_info.features flags (workspaceOwnership,
workspaceTitles, workspaceProjects, workspaceMultiplicity) into the single
workspaceMultiplicity capability gate.
- Remove the now-dead ensureLocalCheckoutWorkspace and its deps interface; explicit
creation always mints a new same-cwd record via createLocalCheckoutWorkspace.
- Resolve every Model B COMPAT marker from v0.1.X to v0.1.97.
- Drop the obsolete workspaceOwnership feature assertion from the same-cwd
isolation e2e; broaden a sidebar-shortcuts test to cover both project kinds.
- Add workspace-create-errors.e2e.test.ts pinning each early-reject error branch.
- typecheck, lint, format all green.
Known follow-ups: pre-existing COMPAT(rewind) markers still carry v0.1.X placeholders
(out of Model B scope, left untouched).
* test(workspace): de-slop Model B tests per audit-tests (reshape mocks/assertions)
Reshape Model B test changes flagged by audit-tests so they exercise real
behavior and observable state instead of mock scaffolding.
Categories fixed:
- Mocks -> real dependencies + persisted state: create.test.ts and
mcp-server.test.ts dropped hand-built AgentManager/AgentStorage mock objects
and mock.calls[...] assertions, now run the real AgentManager/AgentStorage
with a fake agent client and assert on the stored agent record (workspaceId,
parent label).
- Module-internal spying -> injected seam: acp-agent stopped spying on the
tree-kill module (vi.spyOn(treeKillModule, ...)). Added a ProcessTerminator
injection seam to ACPAgentClient/ACPAgentSession (production defaults to
terminateWithTreeKill); tests inject a typed FakeTerminator and assert on the
recorded children plus observable stream .destroyed state.
- Poking internals -> public API: acp close()/killTerminal/releaseTerminal
tests now create terminals through the public createTerminal API instead of
mutating internals.terminalEntries.
- Reimplemented production logic deleted: cli-run-workspace-precedence dropped
its inline copy of resolveRunWorkspace flag precedence (a fake reimplementation
it then asserted against) and now proves only the daemon behaviors the CLI
builds on; flag precedence stays covered in the CLI's own run.test.ts.
- Sleep -> poll: workspace-same-cwd-isolation replaced a fixed setTimeout with
expect.poll on the observed snapshot state to remove the race.
- Internal protocol-frame assertions removed: sidebar-workspace-rename dropped
the captureWsSessionFrames workspace.title.set.request assertions, relying on
the user-visible rename + reload checks already present.
* fix: green CI — new-workspace status bucket regression + branch-picker flow tests + sdk emptyProjects
Source regression:
- packages/app/src/screens/new-workspace-screen.tsx
- packages/app/src/screens/new-workspace-empty.ts
When a new workspace is created with an initial agent, optimistically merge it
with status "running" (statusEnteredAt now) so it lands in the "Working" bucket
instead of defaulting into the wrong bucket. The empty-workspace path passes
withInitialAgent: false so a bare workspace keeps its descriptor status.
Test-drift (intentional P3 flow change — backing picker now required):
- packages/app/e2e/new-workspace.spec.ts
- packages/app/e2e/helpers/new-workspace.ts
The reshaped creation flow requires choosing a backing ("New worktree") before
the branch / starting-ref picker is reachable. Branch-picker specs now call
selectWorkspaceBacking(page, "worktree") first, and the helper waits for the
worktree control to drop aria-disabled (it stays disabled until the checkout
status query confirms the project is a git repo) before clicking.
Test-drift (protocol field added — sdk emptyProjects):
- packages/client/src/index.test.ts
fetch_workspaces response now carries emptyProjects; the toEqual expectation
includes emptyProjects: [].
Flake hardening:
- packages/app/e2e/same-directory-workspaces.spec.ts
- packages/app/e2e/helpers/seed-client.ts
Out-of-band working-tree writes raced the daemon's debounced filesystem
watcher, so the UI could subscribe before the new file was in the git snapshot.
Force a checkout refresh (same path as the UI's manual refresh) to make the
write authoritative before asserting, removing the timing dependency.
* test(app): expect withInitialAgent:false in empty-workspace create call
The withInitialAgent flag (fix for the new-workspace Done-bucket regression)
added a field to the ensureWorkspace call; the empty-path unit test pinned the
exact args. Test-drift, not behavior — update the expectation.
* feat(app): stack new-workspace creation params (Project / Isolation dropdown / Base) with reserved base row + keyboard avoidance
Restructure the new-workspace creation screen into a vertical stack of
Project / Isolation / Base parameters instead of the previous mixed
layout.
- Isolation/backing is now a dropdown (Local vs New worktree) matching the
app's existing dropdown/combobox primitives, replacing the inline
segmented switcher.
- The Base (starting ref) row now reserves its space even when the backing
is Local and the picker is hidden, so switching backing no longer causes
the form to shift vertically.
- The form avoids the keyboard so the title input and submit stay visible
while typing on native and web.
- Updated the e2e new-workspace helper to drive the dropdown-based
backing selector and the reserved Base row.
* fix(app): simplify new-workspace form — stacked ghost rows above composer, no card/title, project-matched dropdowns
- Render the three rows (formStack) at the top, under the "New workspace"
heading and above the composer input, not in the composer footer.
- Drop the card/surface/border chrome; rows sit on the plain background.
- Stack Project, Isolation (multiplicity only), and Base; remove the title
field — the title is set server-side and create works with no user title.
- Each row is a Label immediately followed by its dropdown control; no
columns, fixed label widths, or reserved horizontal space, with the label
glyph aligned to the heading's text x.
- All three triggers share the ghost badge style of the Project control
(ProjectPickerTrigger / IsolationPickerTrigger / RefPickerTrigger +
Combobox); keep the workspace-create-backing-* test IDs.
- Omit "New worktree" from Isolation entirely when the project is non-git.
- Reserve the Base row height always but render nothing on Local backing,
showing the Base label + ref picker only for New worktree.
- Keep keyboard avoidance on the centered content.
- E2E: drive selectWorkspaceBacking via the Isolation trigger + Combobox and
remove the now-gone workspace-create-title-input usage and title field.
* feat(app): sidebar new-workspace entry points + non-git isolation hidden + project auto-select
- Q1: hide the Isolation control on non-git projects — gate the row on canCreateWorktree (multiplicity && selectedIsGit) so a project with no git checkout never offers a worktree backing choice.
- Q2: reset the stale project preselect across the reused 'new' screen — clear the manual picker choice on route project identity change so each route-driven navigation preselects its own project; align the nav verb to router.navigate.
- Q3: remove the per-project "+ New workspace" sidebar row and add one global "New workspace" entry above Sessions in both mobile and desktop sidebars (testID sidebar-global-new-workspace); creation stays reachable per-project via the existing git new-worktree icon.
- Q4: match the Sessions / New-workspace header button sizing to the workspace rows — SidebarHeaderRow icon md->sm, label fontSize base->sm.
- e2e: new new-workspace-entry.spec covering global entry, project preselect reset across reused screen, and non-git isolation hidden; update sidebar-model-b, empty-project-persists, workspace-multiplicity, and helpers for the removed per-project row.
* fix(app): group New-workspace/Sessions header (no divider, workspace-row sized) + remove banned useUnistyles
- Q5: wrap the New-workspace and Sessions header entries in a single sidebarHeaderGroup that owns one bottom divider, so the two rows sit tight together with no gap and no per-row separator (both mobile and desktop sidebars).
- Add a compact variant to SidebarHeaderRow: workspace-row sized (minHeight 36, surfaceSidebarHover, borderRadius.lg) with horizontal padding that aligns its icon/label with the Workspaces section title and the workspace rows below; the default header variant (settings Back-to-workspace) keeps its sidebar-height row and own separator.
- Remove the banned useUnistyles() from sidebar-header-row.tsx per docs/unistyles.md: theme-reactive icon color now goes through withUnistyles(Icon) + uniProps mappings; static sizing reads ICON_SIZE.
- Taste: new-workspace-screen dedupes the project-icon styles (single projectIcon/projectIconFallback/projectIconFallbackText) and replaces repeated magic numbers with BADGE_HEIGHT and a named fallback-font-size constant.
* fix(app): no layout shift on git<->non-git (reserve Isolation row) + symmetric sidebar divider spacing
- Q6: Isolation row reserves its height and renders an invisible spacer for non-git projects, matching the Base-row pattern, so switching between git and non-git projects keeps a constant form height with no layout shift.
- Q7: sidebar header group splits paddingVertical into paddingTop/paddingBottom so the Sessions-row-to-divider gap equals the divider-to-Workspaces-header gap, centering the divider.
* feat(app): rename Sessions to History (clock icon, Agent history header)
- Sidebar label now reads "History" with a clock icon (was Sessions / MessagesSquare)
- Sessions screen header now reads "Agent history"
- Updated i18n strings across all 6 locales (en, ar, ru, zh-CN, fr, es)
- Route and testIDs unchanged (sidebar-sessions, /sessions)
* fix(app): symmetric sidebar header padding (top == bottom), traffic-light inset preserved
The sidebarHeaderGroup wrapper (New-workspace + History rows) had paddingTop: theme.spacing[1] against paddingBottom: theme.spacing[2]. Equalize paddingTop to theme.spacing[2] so the header group's top padding matches its bottom padding. The divider stays centered since paddingBottom still matches WorkspacesSectionHeader's paddingTop. The desktop window-controls (traffic-light) spacer — paddingTopSpacerStyle plus TitlebarDragRegion — is a separate inset and is left untouched.
* fix(app): hover card shows branch; sidebar copy-branch copies the real branch (not the title)
- Add SidebarWorkspaceEntry.currentBranch, sourced from gitRuntime.currentBranch (normalized; detached HEAD/blank/missing -> null)
- Fix copy-branch smear: handleCopyBranchName copied workspace.name (the title); now copies the real currentBranch (guarded)
- Workspace hover card: add a branch row (GitBranch icon + branch name), shown only when it differs from the title
- Header branch-switcher left untouched; diff-pane re-home decision still pending
* feat(app): branch switcher moves into the git diff panel; header title is static (Model B coherence)
- branch switcher now lives in the diff panel Changes header on desktop+mobile
- workspace header title is a plain static title; branch removed from it
- no new workspace-screen header row
- git diff-stat unchanged, stays where it is
- no duplicate git actions
- unified descriptor name fallbacks via resolveWorkspaceName
- rename-then-switch e2e proves header title and real branch stay independent
2689 lines
81 KiB
TypeScript
2689 lines
81 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,
|
|
GitCommitHorizontal,
|
|
GitMerge,
|
|
ListChevronsDownUp,
|
|
ListChevronsUpDown,
|
|
Pilcrow,
|
|
RefreshCcw,
|
|
RotateCw,
|
|
Upload,
|
|
WrapText,
|
|
} from "lucide-react-native";
|
|
import {
|
|
useCheckoutDiffQuery,
|
|
type ParsedDiffFile,
|
|
type DiffLine,
|
|
type HighlightToken,
|
|
} from "@/git/use-diff-query";
|
|
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
|
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
|
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
|
import { 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 { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
|
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[];
|
|
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 HighlightedToken({ token }: { token: HighlightToken }) {
|
|
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
|
}
|
|
|
|
function HighlightedText({ tokens, wrapLines = false, testID }: HighlightedTextProps) {
|
|
const containerStyle = useMemo(
|
|
() => [styles.diffTextMetrics, styles.diffLineText, getWrappedTextStyle(wrapLines)],
|
|
[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;
|
|
onToggle: (path: string) => void;
|
|
onHeaderHeightChange?: (path: string, height: number) => void;
|
|
testID?: string;
|
|
}
|
|
|
|
const EMPTY_COMMENTS: readonly ReviewDraftComment[] = [];
|
|
|
|
function noopStartComment(): void {}
|
|
|
|
const DIFF_LINE_HOVER_STYLE = isWeb ? ({ cursor: "auto" } as const) : null;
|
|
|
|
function LongPressableLine({
|
|
reviewTarget,
|
|
reviewActions,
|
|
onHoverChange,
|
|
hoverTargetKey,
|
|
onHoverTargetChange,
|
|
style,
|
|
children,
|
|
}: {
|
|
reviewTarget: ReviewableDiffTarget | null | undefined;
|
|
reviewActions: InlineReviewActions | undefined;
|
|
onHoverChange?: (hovered: boolean) => void;
|
|
hoverTargetKey?: string | null;
|
|
onHoverTargetChange?: (key: string | null) => void;
|
|
style: StyleProp<ViewStyle>;
|
|
children: ReactNode;
|
|
}) {
|
|
const onStartComment = reviewActions?.onStartComment;
|
|
const handlePress = useCallback(() => {
|
|
if (reviewTarget && onStartComment) {
|
|
onStartComment(reviewTarget);
|
|
}
|
|
}, [reviewTarget, onStartComment]);
|
|
|
|
const handleHoverIn = useCallback(() => {
|
|
onHoverChange?.(true);
|
|
if (hoverTargetKey) {
|
|
onHoverTargetChange?.(hoverTargetKey);
|
|
}
|
|
}, [hoverTargetKey, onHoverChange, onHoverTargetChange]);
|
|
const handleHoverOut = useCallback(() => {
|
|
onHoverChange?.(false);
|
|
if (hoverTargetKey) {
|
|
onHoverTargetChange?.(null);
|
|
}
|
|
}, [hoverTargetKey, onHoverChange, onHoverTargetChange]);
|
|
const hoverStyle = useMemo(() => [style, DIFF_LINE_HOVER_STYLE], [style]);
|
|
|
|
if (isWeb && (onHoverChange || onHoverTargetChange)) {
|
|
return (
|
|
<Pressable onHoverIn={handleHoverIn} onHoverOut={handleHoverOut} style={hoverStyle}>
|
|
{children}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
if (!isNative || !reviewTarget || !onStartComment) {
|
|
return <View style={style}>{children}</View>;
|
|
}
|
|
return (
|
|
<Pressable onPress={handlePress} style={style}>
|
|
{children}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function lineTypeBackground(type: DiffLine["type"] | undefined | null) {
|
|
if (!type) return styles.emptySplitCell;
|
|
if (type === "add") return styles.addLineContainer;
|
|
if (type === "remove") return styles.removeLineContainer;
|
|
if (type === "header") return styles.headerLineContainer;
|
|
return styles.contextLineContainer;
|
|
}
|
|
|
|
function DiffGutterCell({
|
|
lineNumber,
|
|
type,
|
|
gutterWidth,
|
|
reviewTarget,
|
|
reviewActions,
|
|
isLineHovered,
|
|
style,
|
|
textTestID,
|
|
actionTestID,
|
|
}: {
|
|
lineNumber: number | null;
|
|
type: DiffLine["type"] | undefined | null;
|
|
gutterWidth: number;
|
|
reviewTarget?: ReviewableDiffTarget | null;
|
|
reviewActions?: InlineReviewActions;
|
|
isLineHovered?: boolean;
|
|
style?: StyleProp<ViewStyle>;
|
|
textTestID?: string;
|
|
actionTestID?: string;
|
|
}) {
|
|
const containerStyle = useMemo(
|
|
() => [
|
|
styles.gutterCell,
|
|
lineTypeBackground(type),
|
|
inlineUnistylesStyle({ width: gutterWidth }),
|
|
style,
|
|
],
|
|
[type, gutterWidth, style],
|
|
);
|
|
const textStyle = useMemo(
|
|
() => [
|
|
styles.diffTextMetrics,
|
|
styles.lineNumberText,
|
|
type === "add" && styles.addLineNumberText,
|
|
type === "remove" && styles.removeLineNumberText,
|
|
],
|
|
[type],
|
|
);
|
|
const comments = useMemo(
|
|
() =>
|
|
reviewTarget
|
|
? (reviewActions?.commentsByTarget.get(reviewTarget.key) ?? EMPTY_COMMENTS)
|
|
: EMPTY_COMMENTS,
|
|
[reviewTarget, reviewActions?.commentsByTarget],
|
|
);
|
|
const isEditorOpen = isInlineReviewEditorForTarget(reviewActions?.editor ?? null, reviewTarget);
|
|
const onStartComment = reviewActions?.onStartComment ?? noopStartComment;
|
|
|
|
return (
|
|
<InlineReviewGutterCell
|
|
reviewTarget={reviewTarget}
|
|
comments={comments}
|
|
isEditorOpen={isEditorOpen}
|
|
isLineHovered={isLineHovered}
|
|
onStartComment={onStartComment}
|
|
style={containerStyle}
|
|
actionTestID={actionTestID}
|
|
>
|
|
<Text numberOfLines={1} style={textStyle} testID={textTestID}>
|
|
{formatDiffGutterText(lineNumber)}
|
|
</Text>
|
|
</InlineReviewGutterCell>
|
|
);
|
|
}
|
|
|
|
function DiffTextLine({
|
|
line,
|
|
wrapLines,
|
|
reviewTarget,
|
|
reviewActions,
|
|
onHoverChange,
|
|
hoverTargetKey,
|
|
onHoverTargetChange,
|
|
textTestID,
|
|
}: {
|
|
line: DiffLine;
|
|
wrapLines: boolean;
|
|
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 containerStyle = useMemo(
|
|
() => [styles.textLineContainer, lineTypeBackground(line.type)],
|
|
[line.type],
|
|
);
|
|
const textStyle = useMemo(
|
|
() => [
|
|
styles.diffTextMetrics,
|
|
styles.diffLineText,
|
|
getWrappedTextStyle(wrapLines),
|
|
line.type === "add" && styles.addLineText,
|
|
line.type === "remove" && styles.removeLineText,
|
|
line.type === "header" && styles.headerLineText,
|
|
line.type === "context" && styles.contextLineText,
|
|
],
|
|
[line.type, wrapLines],
|
|
);
|
|
|
|
return (
|
|
<LongPressableLine
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
onHoverChange={onHoverChange}
|
|
hoverTargetKey={hoverTargetKey}
|
|
onHoverTargetChange={onHoverTargetChange}
|
|
style={containerStyle}
|
|
>
|
|
{line.type !== "header" && visibleTokens ? (
|
|
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} testID={textTestID} />
|
|
) : (
|
|
<Text style={textStyle} testID={textTestID}>
|
|
{formatDiffContentText(line.content)}
|
|
</Text>
|
|
)}
|
|
</LongPressableLine>
|
|
);
|
|
}
|
|
|
|
function SplitTextLine({
|
|
line,
|
|
wrapLines,
|
|
reviewActions,
|
|
onHoverChange,
|
|
hoverTargetKey,
|
|
onHoverTargetChange,
|
|
}: {
|
|
line: SplitDiffDisplayLine | null;
|
|
wrapLines: boolean;
|
|
reviewActions?: InlineReviewActions;
|
|
onHoverChange?: (hovered: boolean) => void;
|
|
hoverTargetKey?: string | null;
|
|
onHoverTargetChange?: (key: string | null) => void;
|
|
}) {
|
|
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
|
|
|
const containerStyle = useMemo(
|
|
() => [styles.textLineContainer, lineTypeBackground(line?.type)],
|
|
[line?.type],
|
|
);
|
|
const textStyle = useMemo(
|
|
() => [
|
|
styles.diffTextMetrics,
|
|
styles.diffLineText,
|
|
getWrappedTextStyle(wrapLines),
|
|
line?.type === "add" && styles.addLineText,
|
|
line?.type === "remove" && styles.removeLineText,
|
|
line?.type === "context" && styles.contextLineText,
|
|
!line && styles.emptySplitCellText,
|
|
],
|
|
[line, wrapLines],
|
|
);
|
|
|
|
return (
|
|
<LongPressableLine
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
onHoverChange={onHoverChange}
|
|
hoverTargetKey={hoverTargetKey}
|
|
onHoverTargetChange={onHoverTargetChange}
|
|
style={containerStyle}
|
|
>
|
|
{visibleTokens ? (
|
|
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
|
) : (
|
|
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
|
|
)}
|
|
</LongPressableLine>
|
|
);
|
|
}
|
|
|
|
function DiffLineView({
|
|
line,
|
|
lineNumber,
|
|
gutterWidth,
|
|
wrapLines,
|
|
reviewTarget,
|
|
reviewActions,
|
|
}: {
|
|
line: DiffLine;
|
|
lineNumber: number | null;
|
|
gutterWidth: number;
|
|
wrapLines: boolean;
|
|
reviewTarget?: ReviewableDiffTarget | null;
|
|
reviewActions?: InlineReviewActions;
|
|
}) {
|
|
const [isLineHovered, setIsLineHovered] = useState(false);
|
|
const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
|
|
|
const containerStyle = useMemo(
|
|
() => [styles.diffLineContainer, lineTypeBackground(line.type)],
|
|
[line.type],
|
|
);
|
|
const textStyle = useMemo(
|
|
() => [
|
|
styles.diffTextMetrics,
|
|
styles.diffLineText,
|
|
getWrappedTextStyle(wrapLines),
|
|
line.type === "add" && styles.addLineText,
|
|
line.type === "remove" && styles.removeLineText,
|
|
line.type === "header" && styles.headerLineText,
|
|
line.type === "context" && styles.contextLineText,
|
|
],
|
|
[line.type, wrapLines],
|
|
);
|
|
|
|
return (
|
|
<LongPressableLine
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
onHoverChange={setIsLineHovered}
|
|
style={containerStyle}
|
|
>
|
|
<DiffGutterCell
|
|
lineNumber={lineNumber}
|
|
type={line.type}
|
|
gutterWidth={gutterWidth}
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
isLineHovered={isLineHovered}
|
|
style={styles.lineNumberGutter}
|
|
/>
|
|
{line.type !== "header" && visibleTokens ? (
|
|
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
|
) : (
|
|
<Text style={textStyle}>{formatDiffContentText(line.content)}</Text>
|
|
)}
|
|
</LongPressableLine>
|
|
);
|
|
}
|
|
|
|
function SplitDiffLine({
|
|
line,
|
|
gutterWidth,
|
|
wrapLines,
|
|
reviewActions,
|
|
}: {
|
|
line: SplitDiffDisplayLine | null;
|
|
gutterWidth: number;
|
|
wrapLines: boolean;
|
|
reviewActions?: InlineReviewActions;
|
|
}) {
|
|
const [isLineHovered, setIsLineHovered] = useState(false);
|
|
const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null;
|
|
|
|
const containerStyle = useMemo(
|
|
() => [styles.diffLineContainer, lineTypeBackground(line?.type)],
|
|
[line?.type],
|
|
);
|
|
const textStyle = useMemo(
|
|
() => [
|
|
styles.diffTextMetrics,
|
|
styles.diffLineText,
|
|
getWrappedTextStyle(wrapLines),
|
|
line?.type === "add" && styles.addLineText,
|
|
line?.type === "remove" && styles.removeLineText,
|
|
line?.type === "context" && styles.contextLineText,
|
|
!line && styles.emptySplitCellText,
|
|
],
|
|
[line, wrapLines],
|
|
);
|
|
|
|
return (
|
|
<LongPressableLine
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
onHoverChange={setIsLineHovered}
|
|
style={containerStyle}
|
|
>
|
|
<DiffGutterCell
|
|
lineNumber={line?.lineNumber ?? null}
|
|
type={line?.type}
|
|
gutterWidth={gutterWidth}
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
isLineHovered={isLineHovered}
|
|
style={styles.lineNumberGutter}
|
|
/>
|
|
{visibleTokens ? (
|
|
<HighlightedText tokens={visibleTokens} wrapLines={wrapLines} />
|
|
) : (
|
|
<Text style={textStyle}>{formatDiffContentText(line?.content)}</Text>
|
|
)}
|
|
</LongPressableLine>
|
|
);
|
|
}
|
|
|
|
function InlineReviewThreadContent({
|
|
reviewTarget,
|
|
reviewActions,
|
|
reservedHeight,
|
|
viewportWidth,
|
|
pinToViewport,
|
|
}: {
|
|
reviewTarget: ReviewableDiffTarget | null | undefined;
|
|
reviewActions?: InlineReviewActions;
|
|
reservedHeight?: number;
|
|
viewportWidth?: number;
|
|
pinToViewport?: boolean;
|
|
}) {
|
|
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
|
|
const height = reservedHeight ?? threadState?.height ?? 0;
|
|
const placeholderStyle = useMemo<ViewStyle>(
|
|
() => inlineUnistylesStyle({ minHeight: height }),
|
|
[height],
|
|
);
|
|
if (height === 0) {
|
|
return null;
|
|
}
|
|
if (!reviewTarget || !reviewActions || !threadState) {
|
|
return <View style={placeholderStyle} />;
|
|
}
|
|
|
|
return (
|
|
<InlineReviewThread
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
height={height}
|
|
viewportWidth={viewportWidth}
|
|
pinToViewport={pinToViewport}
|
|
testID={`review-thread-${reviewTarget.key}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function InlineReviewGutterSpacer({
|
|
reviewTarget,
|
|
reviewActions,
|
|
gutterWidth,
|
|
reservedHeight,
|
|
style,
|
|
}: {
|
|
reviewTarget: ReviewableDiffTarget | null | undefined;
|
|
reviewActions?: InlineReviewActions;
|
|
gutterWidth: number;
|
|
reservedHeight?: number;
|
|
style?: StyleProp<ViewStyle>;
|
|
}) {
|
|
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
|
|
const height = reservedHeight ?? threadState?.height ?? 0;
|
|
const spacerStyle = useMemo<StyleProp<ViewStyle>>(
|
|
() => [
|
|
styles.inlineReviewGutterSpacer,
|
|
inlineUnistylesStyle({ width: gutterWidth, minHeight: height }),
|
|
style,
|
|
],
|
|
[gutterWidth, height, style],
|
|
);
|
|
if (height === 0) {
|
|
return null;
|
|
}
|
|
|
|
return <View style={spacerStyle} />;
|
|
}
|
|
|
|
function InlineReviewRow({
|
|
reviewTarget,
|
|
reviewActions,
|
|
gutterWidth,
|
|
reservedHeight,
|
|
}: {
|
|
reviewTarget: ReviewableDiffTarget | null | undefined;
|
|
reviewActions?: InlineReviewActions;
|
|
gutterWidth: number;
|
|
reservedHeight?: number;
|
|
}) {
|
|
const threadState = getInlineReviewThreadState({ reviewTarget, reviewActions });
|
|
const height = reservedHeight ?? threadState?.height ?? 0;
|
|
const gutterSpacerStyle = useMemo<StyleProp<ViewStyle>>(
|
|
() => [styles.inlineReviewGutterSpacer, inlineUnistylesStyle({ width: gutterWidth })],
|
|
[gutterWidth],
|
|
);
|
|
const placeholderStyle = useMemo<ViewStyle>(
|
|
() => inlineUnistylesStyle({ minHeight: height }),
|
|
[height],
|
|
);
|
|
if (height === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<View style={styles.inlineReviewRow}>
|
|
<View style={gutterSpacerStyle} />
|
|
{reviewTarget && reviewActions && threadState ? (
|
|
<InlineReviewThread
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
height={height}
|
|
testID={`review-thread-${reviewTarget.key}`}
|
|
/>
|
|
) : (
|
|
<View style={placeholderStyle} />
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SplitDiffColumn({
|
|
rows,
|
|
side,
|
|
gutterWidth,
|
|
wrapLines,
|
|
reviewActions,
|
|
showDivider = false,
|
|
}: {
|
|
rows: SplitDiffRow[];
|
|
side: "left" | "right";
|
|
gutterWidth: number;
|
|
wrapLines: boolean;
|
|
reviewActions?: InlineReviewActions;
|
|
showDivider?: boolean;
|
|
}) {
|
|
const [scrollWidth, setScrollWidth] = useState(0);
|
|
const [hoveredReviewTargetKey, setHoveredReviewTargetKey] = useState<string | null>(null);
|
|
|
|
const wrapCellStyle = useMemo(
|
|
() => [styles.splitCell, showDivider && styles.splitCellWithDivider],
|
|
[showDivider],
|
|
);
|
|
const rowCellStyle = useMemo(
|
|
() => [styles.splitCell, showDivider && styles.splitCellWithDivider, styles.splitCellRow],
|
|
[showDivider],
|
|
);
|
|
const linesContainerRowStyle = useMemo(
|
|
() => [
|
|
styles.linesContainer,
|
|
scrollWidth > 0 && inlineUnistylesStyle({ minWidth: scrollWidth }),
|
|
],
|
|
[scrollWidth],
|
|
);
|
|
|
|
const keyedRows = useMemo(() => rows.map((row, i) => ({ key: `row-${i}`, row })), [rows]);
|
|
|
|
if (wrapLines) {
|
|
return (
|
|
<View style={wrapCellStyle}>
|
|
<View style={styles.linesContainer}>
|
|
{keyedRows.map(({ key, row }) => {
|
|
if (row.kind === "header") {
|
|
return (
|
|
<View key={key} style={styles.splitHeaderRow}>
|
|
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
const line = side === "left" ? row.left : row.right;
|
|
const reviewRowState = getSplitInlineReviewThreadState({
|
|
left: row.left?.reviewTarget,
|
|
right: row.right?.reviewTarget,
|
|
reviewActions,
|
|
});
|
|
return (
|
|
<View key={key}>
|
|
<SplitDiffLine
|
|
line={line}
|
|
gutterWidth={gutterWidth}
|
|
wrapLines={wrapLines}
|
|
reviewActions={reviewActions}
|
|
/>
|
|
<InlineReviewRow
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
gutterWidth={gutterWidth}
|
|
reservedHeight={reviewRowState?.height}
|
|
/>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={rowCellStyle}>
|
|
<View style={styles.gutterColumn}>
|
|
{keyedRows.map(({ key, row }) => {
|
|
if (row.kind === "header") {
|
|
return (
|
|
<DiffGutterCell key={key} lineNumber={null} type="header" gutterWidth={gutterWidth} />
|
|
);
|
|
}
|
|
const line = side === "left" ? row.left : row.right;
|
|
const reviewTargetKey = line?.reviewTarget?.key ?? null;
|
|
const reviewRowState = getSplitInlineReviewThreadState({
|
|
left: row.left?.reviewTarget,
|
|
right: row.right?.reviewTarget,
|
|
reviewActions,
|
|
});
|
|
return (
|
|
<View key={key}>
|
|
<DiffGutterCell
|
|
lineNumber={line?.lineNumber ?? null}
|
|
type={line?.type}
|
|
gutterWidth={gutterWidth}
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
isLineHovered={
|
|
reviewTargetKey !== null && hoveredReviewTargetKey === reviewTargetKey
|
|
}
|
|
/>
|
|
<InlineReviewGutterSpacer
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
gutterWidth={gutterWidth}
|
|
reservedHeight={reviewRowState?.height}
|
|
/>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
<DiffScroll
|
|
scrollViewWidth={scrollWidth}
|
|
onScrollViewWidthChange={setScrollWidth}
|
|
style={styles.splitColumnScroll}
|
|
contentContainerStyle={styles.diffContentInner}
|
|
>
|
|
<View style={linesContainerRowStyle}>
|
|
{keyedRows.map(({ key, row }) => {
|
|
if (row.kind === "header") {
|
|
return (
|
|
<View key={key} style={styles.splitHeaderRow}>
|
|
<Text style={HEADER_LINE_TEXT_STYLE}>{row.content}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
const line = side === "left" ? row.left : row.right;
|
|
const reviewTargetKey = line?.reviewTarget?.key ?? null;
|
|
const reviewRowState = getSplitInlineReviewThreadState({
|
|
left: row.left?.reviewTarget,
|
|
right: row.right?.reviewTarget,
|
|
reviewActions,
|
|
});
|
|
return (
|
|
<View key={key}>
|
|
<SplitTextLine
|
|
line={line}
|
|
wrapLines={false}
|
|
reviewActions={reviewActions}
|
|
hoverTargetKey={reviewTargetKey}
|
|
onHoverTargetChange={setHoveredReviewTargetKey}
|
|
/>
|
|
<InlineReviewThreadContent
|
|
reviewTarget={line?.reviewTarget}
|
|
reviewActions={reviewActions}
|
|
reservedHeight={reviewRowState?.height}
|
|
viewportWidth={scrollWidth}
|
|
pinToViewport
|
|
/>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
</DiffScroll>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const DiffFileHeader = memo(function DiffFileHeader({
|
|
file,
|
|
isExpanded,
|
|
onToggle,
|
|
onHeaderHeightChange,
|
|
testID,
|
|
}: DiffFileSectionProps) {
|
|
const { 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],
|
|
);
|
|
|
|
return (
|
|
<View style={containerStyle} onLayout={handleLayout} testID={testID}>
|
|
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable
|
|
testID={testID ? `${testID}-toggle` : undefined}
|
|
style={fileHeaderPressableStyle}
|
|
// Android: prevent parent pan/scroll gestures from canceling the tap release.
|
|
cancelable={false}
|
|
onPressIn={handlePressIn}
|
|
onPressOut={handlePressOut}
|
|
onPress={toggleExpanded}
|
|
>
|
|
<View style={styles.fileHeaderLeft}>
|
|
<Text style={styles.fileName} numberOfLines={1}>
|
|
{file.path.split("/").pop()}
|
|
</Text>
|
|
<Text style={styles.fileDir} numberOfLines={1}>
|
|
{file.path.includes("/")
|
|
? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}`
|
|
: ""}
|
|
</Text>
|
|
{file.isNew && (
|
|
<View style={styles.newBadge}>
|
|
<Text style={styles.newBadgeText}>{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,
|
|
reviewActions,
|
|
onBodyHeightChange,
|
|
testID,
|
|
}: {
|
|
file: ParsedDiffFile;
|
|
layout: "unified" | "split";
|
|
wrapLines: boolean;
|
|
codeFontSize: number;
|
|
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}
|
|
reviewActions={reviewActions}
|
|
/>
|
|
<SplitDiffColumn
|
|
rows={rows}
|
|
side="right"
|
|
gutterWidth={gutterWidth}
|
|
wrapLines={wrapLines}
|
|
reviewActions={reviewActions}
|
|
showDivider
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const computedLines = buildUnifiedDiffLines(file);
|
|
|
|
if (wrapLines) {
|
|
return (
|
|
<View style={styles.diffContent} dataSet={CODE_SURFACE_DATASET}>
|
|
<View style={styles.linesContainer}>
|
|
{computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => (
|
|
<View key={key} testID={`diff-wrapped-row-${index}`}>
|
|
<DiffLineView
|
|
line={line}
|
|
lineNumber={lineNumber}
|
|
gutterWidth={gutterWidth}
|
|
wrapLines={wrapLines}
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
/>
|
|
<InlineReviewRow
|
|
reviewTarget={reviewTarget}
|
|
reviewActions={reviewActions}
|
|
gutterWidth={gutterWidth}
|
|
/>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const textViewportWidth =
|
|
scrollViewWidth > 0 ? scrollViewWidth : Math.max(0, bodyWidth - gutterWidth);
|
|
return (
|
|
<View style={DIFF_CONTENT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
|
|
<View style={styles.gutterColumn}>
|
|
{computedLines.map(({ line, lineNumber, key, reviewTarget }, index) => (
|
|
<View key={key} testID={`diff-gutter-row-${index}`}>
|
|
<DiffGutterCell
|
|
lineNumber={lineNumber}
|
|
type={line.type}
|
|
gutterWidth={gutterWidth}
|
|
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}
|
|
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 foregroundIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
|
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 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);
|
|
|
|
interface DiffLayoutToggleGroupProps {
|
|
layout: "unified" | "split";
|
|
unifiedToggleStyle: PressableStyleFn;
|
|
splitToggleStyle: PressableStyleFn;
|
|
onUnified: () => void;
|
|
onSplit: () => void;
|
|
}
|
|
|
|
function DiffLayoutToggleGroup({
|
|
layout,
|
|
unifiedToggleStyle,
|
|
splitToggleStyle,
|
|
onUnified,
|
|
onSplit,
|
|
}: DiffLayoutToggleGroupProps) {
|
|
const { t } = useTranslation();
|
|
const unifiedLabel = t("workspace.git.diff.unified");
|
|
const splitLabel = t("workspace.git.diff.split");
|
|
return (
|
|
<View style={styles.toggleButtonGroup}>
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={unifiedLabel}
|
|
testID="changes-layout-unified"
|
|
onPress={onUnified}
|
|
style={unifiedToggleStyle}
|
|
>
|
|
<ThemedAlignJustify
|
|
size={14}
|
|
uniProps={
|
|
layout === "unified" ? foregroundIconColorMapping : foregroundMutedIconColorMapping
|
|
}
|
|
/>
|
|
</Pressable>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<Text style={styles.tooltipText}>{unifiedLabel}</Text>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={splitLabel}
|
|
testID="changes-layout-split"
|
|
onPress={onSplit}
|
|
style={splitToggleStyle}
|
|
>
|
|
<ThemedColumns2
|
|
size={14}
|
|
uniProps={
|
|
layout === "split" ? foregroundIconColorMapping : foregroundMutedIconColorMapping
|
|
}
|
|
/>
|
|
</Pressable>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<Text style={styles.tooltipText}>{splitLabel}</Text>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
interface DiffWhitespaceToggleProps {
|
|
hideWhitespace: boolean;
|
|
isMobile: boolean;
|
|
toggleStyle: PressableStyleFn;
|
|
onToggle: () => void;
|
|
}
|
|
|
|
function DiffWhitespaceToggle({
|
|
hideWhitespace,
|
|
isMobile,
|
|
toggleStyle,
|
|
onToggle,
|
|
}: DiffWhitespaceToggleProps) {
|
|
const { t } = useTranslation();
|
|
const label = t("workspace.git.diff.hideWhitespace");
|
|
return (
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={label}
|
|
testID="changes-toggle-whitespace"
|
|
style={toggleStyle}
|
|
onPress={onToggle}
|
|
>
|
|
<ThemedPilcrow
|
|
size={isMobile ? 18 : 14}
|
|
uniProps={hideWhitespace ? foregroundIconColorMapping : foregroundMutedIconColorMapping}
|
|
/>
|
|
</Pressable>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<Text style={styles.tooltipText}>{label}</Text>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
interface DiffFilesToolbarProps {
|
|
wrapLines: boolean;
|
|
allExpanded: boolean;
|
|
isMobile: boolean;
|
|
wrapLinesToggleStyle: PressableStyleFn;
|
|
expandAllToggleStyle: PressableStyleFn;
|
|
onToggleWrapLines: () => void;
|
|
onToggleExpandAll: () => void;
|
|
}
|
|
|
|
function DiffFilesToolbar({
|
|
wrapLines,
|
|
allExpanded,
|
|
isMobile,
|
|
wrapLinesToggleStyle,
|
|
expandAllToggleStyle,
|
|
onToggleWrapLines,
|
|
onToggleExpandAll,
|
|
}: DiffFilesToolbarProps) {
|
|
const { t } = useTranslation();
|
|
const wrapLinesLabel = wrapLines
|
|
? t("workspace.git.diff.scrollLongLines")
|
|
: t("workspace.git.diff.wrapLongLines");
|
|
const expandAllLabel = allExpanded
|
|
? t("workspace.git.diff.collapseAll")
|
|
: t("workspace.git.diff.expandAll");
|
|
return (
|
|
<View style={styles.diffStatusButtons}>
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable style={wrapLinesToggleStyle} onPress={onToggleWrapLines}>
|
|
<ThemedWrapText
|
|
size={isMobile ? 18 : 14}
|
|
uniProps={wrapLines ? foregroundIconColorMapping : foregroundMutedIconColorMapping}
|
|
/>
|
|
</Pressable>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<Text style={styles.tooltipText}>{wrapLinesLabel}</Text>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable style={expandAllToggleStyle} onPress={onToggleExpandAll}>
|
|
{allExpanded ? (
|
|
<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 DiffRefreshButtonProps {
|
|
isRefreshing: boolean;
|
|
toggleStyle: PressableStyleFn;
|
|
onPress: () => void;
|
|
}
|
|
|
|
const ThemedRotateCw = withUnistyles(RotateCw);
|
|
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
|
|
|
function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) {
|
|
const { t } = useTranslation();
|
|
const refreshLabel = t("workspace.git.diff.refresh");
|
|
return (
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={
|
|
isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refreshState")
|
|
}
|
|
testID="changes-refresh"
|
|
style={toggleStyle}
|
|
onPress={onPress}
|
|
disabled={isRefreshing}
|
|
>
|
|
<View style={styles.refreshIcon}>
|
|
{isRefreshing ? (
|
|
<ThemedLoadingSpinner
|
|
size={ICON_SIZE.sm}
|
|
uniProps={foregroundMutedIconColorMapping}
|
|
/>
|
|
) : (
|
|
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
|
|
)}
|
|
</View>
|
|
</Pressable>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">
|
|
<Text style={styles.tooltipText}>{refreshLabel}</Text>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
type DiffFlatItem =
|
|
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
|
|
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
|
|
type DiffFlatItemLayoutGetter = NonNullable<FlatListProps<DiffFlatItem>["getItemLayout"]>;
|
|
|
|
function getUnifiedDiffLineCount(file: ParsedDiffFile): number {
|
|
let lineCount = 0;
|
|
for (const hunk of file.hunks) {
|
|
lineCount += hunk.lines.length;
|
|
}
|
|
return lineCount;
|
|
}
|
|
|
|
function getDiffContentLength(file: ParsedDiffFile): number {
|
|
let contentLength = 0;
|
|
for (const hunk of file.hunks) {
|
|
for (const line of hunk.lines) {
|
|
contentLength += line.content.length;
|
|
}
|
|
}
|
|
return contentLength;
|
|
}
|
|
|
|
function computeEmptyMessage(
|
|
hideWhitespace: boolean,
|
|
diffMode: "uncommitted" | "base",
|
|
baseRefLabel: string,
|
|
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;
|
|
onContentSizeChange: (width: number, height: number) => void;
|
|
showDesktopWebScrollbar: boolean;
|
|
checkingRepositoryLabel: string;
|
|
notRepositoryLabel: string;
|
|
}
|
|
|
|
function DiffBodyContent({
|
|
isStatusLoading,
|
|
statusErrorMessage,
|
|
notGit,
|
|
isDiffLoading,
|
|
diffErrorMessage,
|
|
hasChanges,
|
|
emptyMessage,
|
|
flatItems,
|
|
stickyHeaderIndices,
|
|
renderFlatItem,
|
|
flatKeyExtractor,
|
|
getFlatItemLayout,
|
|
flatExtraData,
|
|
diffListRef,
|
|
handleDiffListLayout,
|
|
handleDiffListScroll,
|
|
onContentSizeChange,
|
|
showDesktopWebScrollbar,
|
|
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}
|
|
onContentSizeChange={onContentSizeChange}
|
|
scrollEventThrottle={16}
|
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
|
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
|
|
// Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll.
|
|
removeClippedSubviews={false}
|
|
initialNumToRender={12}
|
|
maxToRenderPerBatch={12}
|
|
windowSize={10}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface DeriveStatusStateInputs {
|
|
status: ReturnType<typeof useCheckoutStatusQuery>["status"];
|
|
isStatusLoading: boolean;
|
|
isStatusError: boolean;
|
|
statusError: unknown;
|
|
}
|
|
|
|
interface DerivedStatusState {
|
|
gitStatus: NonNullable<ReturnType<typeof useCheckoutStatusQuery>["status"]> | null;
|
|
isGit: boolean;
|
|
notGit: boolean;
|
|
statusErrorMessage: string | null;
|
|
baseRef: string | undefined;
|
|
hasUncommittedChanges: boolean;
|
|
actionsDisabled: boolean;
|
|
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 showDesktopWebScrollbar = isWeb && !isMobile;
|
|
const canUseSplitLayout = isWeb && !isMobile;
|
|
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
|
useChangesPreferences();
|
|
const wrapLines = changesPreferences.wrapLines;
|
|
const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
|
|
|
|
const handleToggleWrapLines = useCallback(() => {
|
|
void updateChangesPreferences({ wrapLines: !wrapLines });
|
|
}, [updateChangesPreferences, wrapLines]);
|
|
|
|
const handleLayoutChange = useCallback(
|
|
(nextLayout: "unified" | "split") => {
|
|
void updateChangesPreferences({ layout: nextLayout });
|
|
},
|
|
[updateChangesPreferences],
|
|
);
|
|
|
|
const handleToggleHideWhitespace = useCallback(() => {
|
|
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
|
|
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
|
|
|
|
const handleLayoutUnified = useCallback(() => {
|
|
handleLayoutChange("unified");
|
|
}, [handleLayoutChange]);
|
|
|
|
const handleLayoutSplit = useCallback(() => {
|
|
handleLayoutChange("split");
|
|
}, [handleLayoutChange]);
|
|
|
|
const codeFontSize = appSettings.codeFontSize;
|
|
const diffBodyLineHeight = Math.round(codeFontSize * 1.5);
|
|
const diffBodyTypographyKey = [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight].join(
|
|
":",
|
|
);
|
|
const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []);
|
|
|
|
const unifiedToggleStyle = useMemo(
|
|
() =>
|
|
buildToggleButtonStyle(changesPreferences.layout === "unified", [
|
|
styles.toggleButton,
|
|
styles.toggleButtonGroupStart,
|
|
]),
|
|
[changesPreferences.layout],
|
|
);
|
|
|
|
const splitToggleStyle = useMemo(
|
|
() =>
|
|
buildToggleButtonStyle(changesPreferences.layout === "split", [
|
|
styles.toggleButton,
|
|
styles.toggleButtonGroupEnd,
|
|
]),
|
|
[changesPreferences.layout],
|
|
);
|
|
|
|
const hideWhitespaceToggleStyle = useMemo(
|
|
() => buildToggleButtonStyle(changesPreferences.hideWhitespace, styles.expandAllButton),
|
|
[changesPreferences.hideWhitespace],
|
|
);
|
|
|
|
const wrapLinesToggleStyle = useMemo(
|
|
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton),
|
|
[wrapLines],
|
|
);
|
|
|
|
const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
|
|
|
|
const refreshToggleStyle = 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]);
|
|
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
|
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
|
|
enabled: showDesktopWebScrollbar,
|
|
});
|
|
const diffListScrollOffsetRef = useRef(0);
|
|
const diffListViewportHeightRef = useRef(0);
|
|
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
|
const bodyHeightByKeyRef = useRef<Record<string, number>>({});
|
|
const defaultHeaderHeightRef = useRef<number>(44);
|
|
const [heightVersion, setHeightVersion] = useState(0);
|
|
const diffBodyChromeHeight = BORDER_WIDTH[1] * 2;
|
|
const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight;
|
|
const { flatItems, stickyHeaderIndices } = useMemo(() => {
|
|
const items: DiffFlatItem[] = [];
|
|
const stickyIndices: number[] = [];
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
const isExpanded = expandedPaths.has(file.path);
|
|
items.push({ type: "header", file, fileIndex: i, isExpanded });
|
|
if (isExpanded) {
|
|
stickyIndices.push(items.length - 1);
|
|
}
|
|
if (isExpanded) {
|
|
items.push({ type: "body", file, fileIndex: i });
|
|
}
|
|
}
|
|
return { flatItems: items, stickyHeaderIndices: stickyIndices };
|
|
}, [expandedPaths, files]);
|
|
|
|
const getBodyHeightKey = useCallback(
|
|
(file: ParsedDiffFile): string => {
|
|
if (file.status === "too_large" || file.status === "binary") {
|
|
return `${effectiveLayout}:${wrapLines ? "wrap" : "scroll"}:${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],
|
|
);
|
|
|
|
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
|
|
if (!Number.isFinite(height) || height <= 0) {
|
|
return;
|
|
}
|
|
const previousHeight = headerHeightByPathRef.current[path];
|
|
if (
|
|
previousHeight !== undefined &&
|
|
Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON
|
|
) {
|
|
return;
|
|
}
|
|
headerHeightByPathRef.current[path] = height;
|
|
defaultHeaderHeightRef.current = height;
|
|
setHeightVersion((version) => version + 1);
|
|
}, []);
|
|
|
|
const handleBodyHeightChange = useCallback(
|
|
(file: ParsedDiffFile, height: number) => {
|
|
if (!Number.isFinite(height) || height < 0) {
|
|
return;
|
|
}
|
|
const heightKey = getBodyHeightKey(file);
|
|
const previousHeight = bodyHeightByKeyRef.current[heightKey];
|
|
if (
|
|
previousHeight !== undefined &&
|
|
Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON
|
|
) {
|
|
return;
|
|
}
|
|
bodyHeightByKeyRef.current[heightKey] = height;
|
|
setHeightVersion((version) => version + 1);
|
|
},
|
|
[getBodyHeightKey],
|
|
);
|
|
|
|
const handleDiffListScroll = useCallback(
|
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
|
scrollbar.onScroll(event);
|
|
},
|
|
[scrollbar],
|
|
);
|
|
|
|
const handleDiffListLayout = useCallback(
|
|
(event: LayoutChangeEvent) => {
|
|
const height = event.nativeEvent.layout.height;
|
|
if (!Number.isFinite(height) || height <= 0) {
|
|
return;
|
|
}
|
|
diffListViewportHeightRef.current = height;
|
|
scrollbar.onLayout(event);
|
|
},
|
|
[scrollbar],
|
|
);
|
|
|
|
const computeHeaderOffset = useCallback(
|
|
(path: string): number => {
|
|
const defaultHeaderHeight = defaultHeaderHeightRef.current;
|
|
let offset = 0;
|
|
for (const file of files) {
|
|
if (file.path === path) {
|
|
break;
|
|
}
|
|
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
|
|
if (expandedPaths.has(file.path)) {
|
|
const bodyHeightKey = getBodyHeightKey(file);
|
|
offset += bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(file);
|
|
}
|
|
}
|
|
return Math.max(0, offset);
|
|
},
|
|
[estimateBodyHeight, expandedPaths, files, getBodyHeightKey],
|
|
);
|
|
|
|
const handleToggleExpanded = useCallback(
|
|
(path: string) => {
|
|
if (!workspaceStateKey) {
|
|
return;
|
|
}
|
|
const isCurrentlyExpanded = expandedPaths.has(path);
|
|
const nextExpanded = !isCurrentlyExpanded;
|
|
const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null;
|
|
const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current;
|
|
const shouldAnchor =
|
|
isCurrentlyExpanded &&
|
|
targetOffset !== null &&
|
|
shouldAnchorHeaderBeforeCollapse({
|
|
headerOffset: targetOffset,
|
|
headerHeight,
|
|
viewportOffset: diffListScrollOffsetRef.current,
|
|
viewportHeight: diffListViewportHeightRef.current,
|
|
});
|
|
|
|
// Anchor to the clicked header before collapsing so visual context is preserved.
|
|
if (shouldAnchor && targetOffset !== null) {
|
|
diffListRef.current?.scrollToOffset({
|
|
offset: targetOffset,
|
|
animated: false,
|
|
});
|
|
}
|
|
|
|
const nextPaths = nextExpanded
|
|
? [...expandedPaths, path]
|
|
: Array.from(expandedPaths).filter((expandedPath) => expandedPath !== path);
|
|
setDiffExpandedPathsForWorkspace(workspaceStateKey, nextPaths);
|
|
},
|
|
[computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey],
|
|
);
|
|
|
|
const allExpanded = useMemo(() => {
|
|
if (files.length === 0) return false;
|
|
return files.every((file) => expandedPaths.has(file.path));
|
|
}, [expandedPaths, files]);
|
|
|
|
const handleToggleExpandAll = useCallback(() => {
|
|
if (!workspaceStateKey) {
|
|
return;
|
|
}
|
|
if (allExpanded) {
|
|
setDiffExpandedPathsForWorkspace(workspaceStateKey, []);
|
|
} else {
|
|
setDiffExpandedPathsForWorkspace(
|
|
workspaceStateKey,
|
|
files.map((file) => file.path),
|
|
);
|
|
}
|
|
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
|
|
|
|
const renderFlatItem = useCallback(
|
|
({ item }: { item: DiffFlatItem }) => {
|
|
if (item.type === "header") {
|
|
return (
|
|
<DiffFileHeader
|
|
file={item.file}
|
|
isExpanded={item.isExpanded}
|
|
onToggle={handleToggleExpanded}
|
|
onHeaderHeightChange={handleHeaderHeightChange}
|
|
testID={`diff-file-${item.fileIndex}`}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<DiffFileBody
|
|
file={item.file}
|
|
layout={effectiveLayout}
|
|
wrapLines={wrapLines}
|
|
codeFontSize={codeFontSize}
|
|
reviewActions={reviewActions}
|
|
onBodyHeightChange={handleBodyHeightChange}
|
|
testID={`diff-file-${item.fileIndex}-body`}
|
|
/>
|
|
);
|
|
},
|
|
[
|
|
codeFontSize,
|
|
effectiveLayout,
|
|
handleBodyHeightChange,
|
|
handleHeaderHeightChange,
|
|
handleToggleExpanded,
|
|
reviewActions,
|
|
wrapLines,
|
|
],
|
|
);
|
|
|
|
const flatKeyExtractor = useCallback(
|
|
(item: DiffFlatItem) => `${item.type}-${item.file.path}`,
|
|
[],
|
|
);
|
|
|
|
const getFlatItemHeight = useCallback(
|
|
(item: DiffFlatItem): number => {
|
|
if (item.type === "header") {
|
|
return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current;
|
|
}
|
|
|
|
const bodyHeightKey = getBodyHeightKey(item.file);
|
|
return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file);
|
|
},
|
|
[estimateBodyHeight, getBodyHeightKey],
|
|
);
|
|
|
|
const getFlatItemLayout = useCallback<DiffFlatItemLayoutGetter>(
|
|
(_data, index) => {
|
|
let offset = 0;
|
|
for (let itemIndex = 0; itemIndex < index; itemIndex += 1) {
|
|
const item = flatItems[itemIndex];
|
|
if (item) {
|
|
offset += getFlatItemHeight(item);
|
|
}
|
|
}
|
|
|
|
const item = flatItems[index];
|
|
const length = item ? getFlatItemHeight(item) : 0;
|
|
return { length, offset, index };
|
|
},
|
|
[flatItems, getFlatItemHeight],
|
|
);
|
|
|
|
const flatExtraData = useMemo(
|
|
() => ({
|
|
expandedPathsArray,
|
|
effectiveLayout,
|
|
diffBodyTypographyKey,
|
|
heightVersion,
|
|
wrapLines,
|
|
reviewActions,
|
|
}),
|
|
[
|
|
expandedPathsArray,
|
|
effectiveLayout,
|
|
diffBodyTypographyKey,
|
|
heightVersion,
|
|
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, worktreeDeletePrompt } = 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}
|
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
|
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
|
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}
|
|
{worktreeDeletePrompt}
|
|
</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 ? (
|
|
<DiffLayoutToggleGroup
|
|
layout={changesPreferences.layout}
|
|
unifiedToggleStyle={unifiedToggleStyle}
|
|
splitToggleStyle={splitToggleStyle}
|
|
onUnified={handleLayoutUnified}
|
|
onSplit={handleLayoutSplit}
|
|
/>
|
|
) : null}
|
|
<DiffWhitespaceToggle
|
|
hideWhitespace={changesPreferences.hideWhitespace}
|
|
isMobile={isMobile}
|
|
toggleStyle={hideWhitespaceToggleStyle}
|
|
onToggle={handleToggleHideWhitespace}
|
|
/>
|
|
{files.length > 0 ? (
|
|
<DiffFilesToolbar
|
|
wrapLines={wrapLines}
|
|
allExpanded={allExpanded}
|
|
isMobile={isMobile}
|
|
wrapLinesToggleStyle={wrapLinesToggleStyle}
|
|
expandAllToggleStyle={expandAllToggleStyle}
|
|
onToggleWrapLines={handleToggleWrapLines}
|
|
onToggleExpandAll={handleToggleExpandAll}
|
|
/>
|
|
) : null}
|
|
{refreshSupported ? (
|
|
<DiffRefreshButton
|
|
isRefreshing={isRefreshing}
|
|
toggleStyle={refreshToggleStyle}
|
|
onPress={handleRefresh}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
|
|
|
|
<View style={styles.diffContainer}>
|
|
{bodyContent}
|
|
{hasChanges ? scrollbar.overlay : null}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
},
|
|
header: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[2],
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: theme.colors.border,
|
|
},
|
|
diffStatusContainer: {
|
|
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: theme.colors.border,
|
|
},
|
|
diffStatusInner: {
|
|
flex: 1,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
paddingRight: theme.spacing[3],
|
|
},
|
|
diffModeTrigger: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: theme.spacing[1],
|
|
// Align text with header branch icon (at spacing[3] from edge, minus our horizontal padding)
|
|
marginLeft: theme.spacing[3] - theme.spacing[1],
|
|
paddingHorizontal: theme.spacing[1],
|
|
height: {
|
|
xs: 28,
|
|
sm: 28,
|
|
md: 24,
|
|
},
|
|
borderRadius: theme.borderRadius.base,
|
|
flexShrink: 0,
|
|
},
|
|
diffModeTriggerHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
diffModeTriggerPressed: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
diffStatusRowHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
diffStatusText: {
|
|
fontSize: theme.fontSize.xs,
|
|
lineHeight: theme.fontSize.xs * 1.25,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
diffStatusIconHidden: {
|
|
opacity: 0,
|
|
},
|
|
diffStatusButtons: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
flexWrap: "wrap",
|
|
},
|
|
toggleButtonGroup: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
},
|
|
toggleButton: {
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
minWidth: {
|
|
xs: 32,
|
|
sm: 32,
|
|
md: 24,
|
|
},
|
|
height: {
|
|
xs: 32,
|
|
sm: 32,
|
|
md: 24,
|
|
},
|
|
paddingHorizontal: {
|
|
xs: theme.spacing[2],
|
|
sm: theme.spacing[2],
|
|
md: theme.spacing[1],
|
|
},
|
|
},
|
|
toggleButtonGroupStart: {
|
|
borderTopLeftRadius: theme.borderRadius.base,
|
|
borderBottomLeftRadius: theme.borderRadius.base,
|
|
},
|
|
toggleButtonGroupEnd: {
|
|
borderTopRightRadius: theme.borderRadius.base,
|
|
borderBottomRightRadius: theme.borderRadius.base,
|
|
},
|
|
toggleButtonSelected: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
refreshIcon: {
|
|
width: ICON_SIZE.md,
|
|
height: ICON_SIZE.md,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
expandAllButton: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: theme.spacing[1],
|
|
minWidth: {
|
|
xs: 32,
|
|
sm: 32,
|
|
md: 24,
|
|
},
|
|
height: {
|
|
xs: 32,
|
|
sm: 32,
|
|
md: 24,
|
|
},
|
|
paddingHorizontal: {
|
|
xs: theme.spacing[2],
|
|
sm: theme.spacing[2],
|
|
md: theme.spacing[1],
|
|
},
|
|
borderRadius: theme.borderRadius.base,
|
|
flexShrink: 0,
|
|
},
|
|
actionErrorText: {
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingBottom: theme.spacing[1],
|
|
fontSize: theme.fontSize.xs,
|
|
color: theme.colors.destructive,
|
|
},
|
|
diffContainer: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
position: "relative",
|
|
},
|
|
scrollView: {
|
|
flex: 1,
|
|
},
|
|
contentContainer: {
|
|
paddingBottom: theme.spacing[8],
|
|
},
|
|
loadingContainer: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
paddingTop: theme.spacing[16],
|
|
gap: theme.spacing[4],
|
|
},
|
|
loadingText: {
|
|
fontSize: theme.fontSize.base,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
errorContainer: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
paddingTop: theme.spacing[16],
|
|
paddingHorizontal: theme.spacing[6],
|
|
},
|
|
errorText: {
|
|
fontSize: theme.fontSize.base,
|
|
color: theme.colors.destructive,
|
|
textAlign: "center",
|
|
},
|
|
emptyContainer: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
paddingTop: theme.spacing[16],
|
|
},
|
|
emptyText: {
|
|
fontSize: theme.fontSize.lg,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
fileSection: {
|
|
overflow: "hidden",
|
|
backgroundColor: theme.colors.surface2,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: theme.colors.border,
|
|
},
|
|
fileSectionHeaderContainer: {
|
|
overflow: "hidden",
|
|
},
|
|
fileSectionHeaderExpanded: {
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
fileSectionBodyContainer: {
|
|
overflow: "hidden",
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
fileSectionBorder: {
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: theme.colors.border,
|
|
},
|
|
fileHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingLeft: theme.spacing[3],
|
|
paddingRight: theme.spacing[2],
|
|
paddingVertical: theme.spacing[2],
|
|
gap: theme.spacing[1],
|
|
minWidth: 0,
|
|
zIndex: 2,
|
|
elevation: 2,
|
|
},
|
|
fileHeaderPressed: {
|
|
opacity: 0.7,
|
|
},
|
|
fileHeaderLeft: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
fileHeaderRight: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
flexShrink: 0,
|
|
},
|
|
fileName: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.foreground,
|
|
flexShrink: 1,
|
|
minWidth: 0,
|
|
},
|
|
fileDir: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.foregroundMuted,
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
newBadge: {
|
|
backgroundColor: "rgba(46, 160, 67, 0.2)",
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingVertical: theme.spacing[1],
|
|
borderRadius: theme.borderRadius.md,
|
|
flexShrink: 0,
|
|
},
|
|
newBadgeText: {
|
|
fontSize: theme.fontSize.xs,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.diffAddition,
|
|
},
|
|
deletedBadge: {
|
|
backgroundColor: "rgba(248, 81, 73, 0.2)",
|
|
paddingHorizontal: theme.spacing[2],
|
|
paddingVertical: theme.spacing[1],
|
|
borderRadius: theme.borderRadius.md,
|
|
flexShrink: 0,
|
|
},
|
|
deletedBadgeText: {
|
|
fontSize: theme.fontSize.xs,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.diffDeletion,
|
|
},
|
|
additions: {
|
|
fontSize: theme.fontSize.xs,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.diffAddition,
|
|
},
|
|
deletions: {
|
|
fontSize: theme.fontSize.xs,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.diffDeletion,
|
|
},
|
|
diffContent: {
|
|
borderTopWidth: theme.borderWidth[1],
|
|
borderTopColor: theme.colors.border,
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
diffContentRow: {
|
|
flexDirection: "row",
|
|
alignItems: "stretch",
|
|
},
|
|
diffContentInner: {
|
|
flexDirection: "column",
|
|
},
|
|
linesContainer: {
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
gutterColumn: {
|
|
backgroundColor: theme.colors.surface1,
|
|
zIndex: 4,
|
|
elevation: 4,
|
|
overflow: "visible",
|
|
},
|
|
gutterCell: {
|
|
borderRightWidth: theme.borderWidth[1],
|
|
borderRightColor: theme.colors.border,
|
|
justifyContent: "flex-start",
|
|
zIndex: 4,
|
|
elevation: 4,
|
|
overflow: "visible",
|
|
},
|
|
inlineReviewRow: {
|
|
flexDirection: "row",
|
|
alignItems: "stretch",
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
inlineReviewGutterSpacer: {
|
|
borderRightWidth: theme.borderWidth[1],
|
|
borderRightColor: theme.colors.border,
|
|
backgroundColor: theme.colors.surface1,
|
|
flexShrink: 0,
|
|
},
|
|
textLineContainer: {
|
|
flexDirection: "row",
|
|
alignItems: "stretch",
|
|
paddingLeft: theme.spacing[2],
|
|
},
|
|
splitRow: {
|
|
flexDirection: "row",
|
|
alignItems: "stretch",
|
|
},
|
|
splitColumnScroll: {
|
|
flex: 1,
|
|
},
|
|
splitHeaderRow: {
|
|
backgroundColor: theme.colors.surface2,
|
|
paddingHorizontal: theme.spacing[3],
|
|
},
|
|
splitCell: {
|
|
flex: 1,
|
|
flexBasis: 0,
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
splitCellRow: {
|
|
flexDirection: "row",
|
|
alignItems: "stretch",
|
|
},
|
|
emptySplitCell: {
|
|
backgroundColor: theme.colors.surfaceDiffEmpty,
|
|
},
|
|
splitCellWithDivider: {
|
|
borderLeftWidth: theme.borderWidth[1],
|
|
borderLeftColor: theme.colors.border,
|
|
},
|
|
diffLineContainer: {
|
|
flexDirection: "row",
|
|
alignItems: "stretch",
|
|
overflow: "visible",
|
|
},
|
|
lineNumberGutter: {
|
|
borderRightWidth: theme.borderWidth[1],
|
|
borderRightColor: theme.colors.border,
|
|
marginRight: theme.spacing[2],
|
|
alignSelf: "stretch",
|
|
justifyContent: "flex-start",
|
|
zIndex: 4,
|
|
elevation: 4,
|
|
overflow: "visible",
|
|
},
|
|
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 HEADER_LINE_TEXT_STYLE = [styles.diffTextMetrics, styles.diffLineText, styles.headerLineText];
|
|
const FILE_SECTION_BODY_STYLE = [styles.fileSectionBodyContainer, styles.fileSectionBorder];
|
|
const DIFF_CONTENT_SPLIT_ROW_STYLE = [styles.diffContent, styles.splitRow];
|
|
const DIFF_CONTENT_ROW_STYLE = [styles.diffContent, styles.diffContentRow];
|
|
const DIFF_HEIGHT_CHANGE_EPSILON = 0.5;
|