feat(app): make the side panel user-directed (#3605)

* feat(app): let the file panel close its tree rail

The file panel's tree rail was permanent on desktop — the changes panel
had a folder-tree toggle in its toolbar, the file toolbar had nothing, so
the rail was there whether you wanted it or not.

The changes panel's private DesktopTreeToggle becomes a shared
TreeRailToggle so both toolbars render the same affordance; that also
drops the treeToggleStyle prop threaded through ChangesToolbarProps,
which recomputed exactly what the component already built internally.
The i18n keys move out of workspace.git.diff into workspace.tree, since a
shared component reaching into the git namespace would rot.

The two rails keep independent flags — the file rail in panel-store, the
changes rail in useChangesPreferences — so closing one leaves the other
alone. FileTreeToggle wires itself to the store rather than taking a
callback: the bar renders several components below FilePanel, where the
rail actually lives.

Both rails also get narrower (320 -> 260 default, 200 -> 180 minimum).
The persist version bump resets treeRailWidth on purpose: nearly every
existing install carries the old 320 default, so without the reset the
narrower rail would only ever reach new users. Anyone who dragged their
rail to a deliberate custom width loses it once.

Note that MIN_CONTENT_WIDTH still clamps the rail to
`containerWidth - 240`, so in a narrow pane the new default never
applies — that floor, not the default, is what sets the width there.

* feat(app): make the side panel an empty workspace surface

The fixed, hideable side panel keeps supporting context beside an agent without seeding content. Pane-local choices stay local, while implicit supporting opens follow a persisted routing preference.

* test(app): exercise side panel policy through its capability boundary

The browser rail journey already covers the real tree-toggle behavior. Side-panel policy tests now pass their platform capability through the public boundary instead of replacing a production module.

* test(app): expect an empty side panel after upgrade

* test(app): expect an empty first split pane

* test(app): account for the empty side panel in split retention

* feat(app): make workspace panel tabs independent

Give explicit New actions fresh tab instances, keep panel state opaque to tab management, preserve File navigation context, and stabilize uncached File transitions.

* fix(app): preserve file conflicts during preview refresh

Keep the current preview mounted for same-target reads and conversions so the editor retains its conflict observation state. Clear retained previews only when the file target changes.
This commit is contained in:
Mohamed Boudra
2026-08-20 21:49:23 +02:00
committed by GitHub
parent 2a0a0ff656
commit 19a7d26349
107 changed files with 5386 additions and 2178 deletions
@@ -3,7 +3,6 @@ import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
import { normalizeBranchOptionName } from "@/utils/branch-suggestions";
@@ -13,6 +12,8 @@ interface CommitsSectionProps {
serverId: string;
cwd: string;
onCommitPress: (sha: string) => void;
collapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
}
function CommitsSectionSkeleton() {
@@ -82,11 +83,15 @@ function CommitsSectionContent({
);
}
export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionProps) {
export function CommitsSection({
serverId,
cwd,
onCommitPress,
collapsed = true,
onCollapsedChange,
}: CommitsSectionProps) {
const { t } = useTranslation();
const { preferences, updatePreferences } = useChangesPreferences();
const isPanelActive = useRetainedPanelActive();
const collapsed = preferences.commitsCollapsed;
const [now, setNow] = useState(() => new Date());
const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]);
const query = useCheckoutCommitsQuery({
@@ -99,8 +104,8 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
if (collapsed) {
setNow(new Date());
}
void updatePreferences({ commitsCollapsed: !collapsed });
}, [collapsed, updatePreferences]);
onCollapsedChange?.(!collapsed);
}, [collapsed, onCollapsedChange]);
useEffect(() => {
if (collapsed || !isPanelActive) {
+130 -232
View File
@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { TreeRail } from "@/components/tree-rail";
import { TreeRailToggle } from "@/components/tree-rail-toggle";
import { DiffStat } from "@/components/diff-stat";
import {
View,
@@ -13,13 +14,12 @@ import {
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
AlignJustify,
ChevronDown,
Columns2,
FolderTree,
ListChevronsDownUp,
ListChevronsUpDown,
Maximize2,
@@ -29,6 +29,8 @@ import {
WrapText,
} from "lucide-react-native";
import { type ParsedDiffFile } from "@/git/use-diff-query";
import type { ChangesState } from "@/panels/changes/state";
import { defaultChangesState } from "@/panels/changes/state";
import { DiffDocument, type WorkingDiffMode } from "@/git/diff-document";
import { FileHeader } from "@/git/file-header";
import {
@@ -41,7 +43,6 @@ import {
import { DiffFolderRow } from "@/git/diff-folder-row";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { CommitsSection } from "@/git/commits-section/commits-section";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useAppSettings } from "@/hooks/use-settings";
import {
DropdownMenu,
@@ -68,10 +69,15 @@ import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
import { confirmDialog } from "@/utils/confirm-dialog";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { usePanelStore } from "@/stores/panel-store";
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
PaneContentToolbar,
paneContentToolbarIconSize,
paneContentToolbarIconButtonStyle,
} from "@/components/ui/pane-content-toolbar";
import { FOCUSED_PANE_PLACEMENT, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import type { WorkspaceTabPlacement } from "@/stores/workspace-layout-actions";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { isWeb } from "@/constants/platform";
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
import { DiffTooLargeState } from "@/git/diff-too-large-state";
@@ -163,6 +169,8 @@ interface ChangesSurfaceProps {
focusRequestId?: number;
onOpenFile?: (path: string) => void;
onAddToChat?: (path: string) => void;
state?: ChangesState;
onStateChange?: (state: ChangesState) => void;
}
type PressableStyleFn = (
@@ -176,10 +184,10 @@ const ThemedAlignJustify = withUnistyles(AlignJustify);
const ThemedColumns2 = withUnistyles(Columns2);
const ThemedPilcrow = withUnistyles(Pilcrow);
const ThemedWrapText = withUnistyles(WrapText);
const ThemedFolderTree = withUnistyles(FolderTree);
const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp);
const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
const ThemedMaximize2 = withUnistyles(Maximize2);
const noopStateChange = () => {};
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedMoreHorizontal = withUnistyles(MoreHorizontal);
const DIFF_OPTIONS_WHITESPACE_ICON = (
@@ -216,8 +224,8 @@ export function DiffLayoutToggle({
onToggle,
}: DiffLayoutToggleProps) {
const defaultToggleStyle = useMemo(
() => buildToggleButtonStyle(false, styles.toolbarIconButton),
[],
() => buildToggleButtonStyle(false, undefined, isMobile),
[isMobile],
);
const { t } = useTranslation();
const label =
@@ -316,39 +324,6 @@ export function DiffModeMenu({
);
}
interface DesktopTreeToggleProps {
visible: boolean;
toggleStyle?: PressableStyleFn;
onToggle: () => void;
}
function DesktopTreeToggle({ visible, toggleStyle, onToggle }: DesktopTreeToggleProps) {
const { t } = useTranslation();
const defaultToggleStyle = useMemo(
() => buildToggleButtonStyle(visible, styles.toolbarIconButton),
[visible],
);
const label = t(visible ? "workspace.git.diff.hideTreeView" : "workspace.git.diff.showTreeView");
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
testID="changes-toggle-tree"
style={toggleStyle ?? defaultToggleStyle}
onPress={onToggle}
>
<ThemedFolderTree size={14} uniProps={foregroundMutedIconColorMapping} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
}
interface ChangesToolbarProps {
branchName: string | null;
allFilesCollapsed: boolean;
@@ -369,7 +344,6 @@ interface ChangesToolbarProps {
refreshSupported: boolean;
selectedDiffStat: { additions: number; deletions: number } | null;
serverId: string;
treeToggleStyle: PressableStyleFn;
workspaceId?: string | null;
wrapLines: boolean;
onRefresh: () => void;
@@ -400,14 +374,13 @@ function ChangesToolbar(props: ChangesToolbarProps) {
isMobile,
selectedDiffStat,
serverId,
treeToggleStyle,
workspaceId,
onSelectBase,
onSelectUncommitted,
onToggleDesktopTree,
} = props;
return (
<View style={styles.changesToolbar} testID="changes-header">
<PaneContentToolbar style={styles.changesToolbar} testID="changes-header">
<View style={styles.changesToolbarIdentity}>
<DiffModeMenu
diffMode={diffMode}
@@ -433,16 +406,16 @@ function ChangesToolbar(props: ChangesToolbarProps) {
</View>
<View style={styles.changesToolbarControls}>
{!isMobile && hasFiles ? (
<DesktopTreeToggle
<TreeRailToggle
visible={desktopTreeVisible}
toggleStyle={treeToggleStyle}
testID="changes-toggle-tree"
onToggle={onToggleDesktopTree}
/>
) : null}
{isMobile ? <GitActionsSplitButton gitActions={gitActions} /> : null}
<ChangesOptionsMenu {...props} />
</View>
</View>
</PaneContentToolbar>
);
}
@@ -531,7 +504,7 @@ function ChangesOptionsMenu({
style={overflowToggleStyle}
>
<ThemedMoreHorizontal
size={isMobile ? 18 : 14}
size={paneContentToolbarIconSize(isMobile)}
uniProps={foregroundMutedIconColorMapping}
/>
</DropdownMenuTrigger>
@@ -606,8 +579,6 @@ function ChangesOptionsMenu({
const ThemedRotateCw = withUnistyles(RotateCw);
const EMPTY_PATH_LIST: string[] = [];
function computeEmptyMessage(
hideWhitespace: boolean,
diffMode: "uncommitted" | "base",
@@ -793,74 +764,32 @@ function buildDiffModeTriggerStyle(): PressableStyleFn {
];
}
function buildOverflowButtonStyle(): PressableStyleFn {
return ({ hovered, pressed }) => [
styles.overflowButton,
(Boolean(hovered) || pressed) && styles.toggleButtonSelected,
];
function buildOverflowButtonStyle(isMobile: boolean): PressableStyleFn {
return (state) => paneContentToolbarIconButtonStyle(state, false, isMobile);
}
function buildToggleButtonStyle(
selected: boolean,
baseStyles: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
baseStyles?: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
isMobile = false,
): PressableStyleFn {
return ({ hovered, pressed }) => [
baseStyles,
(selected || Boolean(hovered) || pressed) && styles.toggleButtonSelected,
];
}
function useCollapsedDiffFolders({
workspaceId,
cwd,
}: {
workspaceId?: string | null;
cwd: string;
}) {
const workspaceStateKey = useMemo(
() =>
buildWorkspaceExplorerStateKey({
workspaceId,
workspaceRoot: cwd.trim(),
}),
[cwd, workspaceId],
);
const collapsedFolders = usePanelStore((state) =>
workspaceStateKey ? state.diffCollapsedFoldersByWorkspace[workspaceStateKey] : undefined,
);
const setCollapsedFolders = usePanelStore((state) => state.setDiffCollapsedFoldersForWorkspace);
const stableCollapsedFolders = collapsedFolders ?? EMPTY_PATH_LIST;
const updateCollapsedFolders = useCallback(
(paths: string[]) => {
if (workspaceStateKey) {
setCollapsedFolders(workspaceStateKey, paths);
}
},
[setCollapsedFolders, workspaceStateKey],
);
return {
collapsedFolders: stableCollapsedFolders,
updateCollapsedFolders,
};
return (state) => [baseStyles, paneContentToolbarIconButtonStyle(state, selected, isMobile)];
}
function ChangedFilesTree({
workspaceId,
cwd,
files,
mode,
onSelectFile,
collapsedFolderPaths,
onCollapsedFolderPathsChange,
}: {
workspaceId?: string | null;
cwd: string;
files: ParsedDiffFile[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
collapsedFolderPaths: string[];
onCollapsedFolderPathsChange: (paths: string[]) => void;
}) {
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const { collapsedFolders: collapsedFolderPaths, updateCollapsedFolders } =
useCollapsedDiffFolders({ workspaceId, cwd });
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
const collapsedFolders = useMemo(() => new Set(collapsedFolderPaths), [collapsedFolderPaths]);
@@ -881,14 +810,14 @@ function ChangedFilesTree({
const next = collapsedFolders.has(dirPath)
? Array.from(collapsedFolders).filter((path) => path !== dirPath)
: [...collapsedFolders, dirPath];
updateCollapsedFolders(next);
onCollapsedFolderPathsChange(next);
},
[collapsedFolders, updateCollapsedFolders],
[collapsedFolders, onCollapsedFolderPathsChange],
);
const handleCollapseFolder = useCallback(
(dirPath: string) => {
const prefix = `${dirPath}/`;
updateCollapsedFolders([
onCollapsedFolderPathsChange([
...new Set([
...collapsedFolders,
...allFolderPaths.filter(
@@ -897,7 +826,7 @@ function ChangedFilesTree({
]),
]);
},
[allFolderPaths, collapsedFolders, updateCollapsedFolders],
[allFolderPaths, collapsedFolders, onCollapsedFolderPathsChange],
);
const renderItem = useCallback(
({ item }: { item: DiffTreeRow }) => {
@@ -979,30 +908,34 @@ function ChangedFilesTree({
function ChangesTreeRail({
shown,
children,
workspaceId,
cwd,
files,
mode,
onSelectFile,
treeWidth,
onTreeWidthChange,
collapsedFolderPaths,
onCollapsedFolderPathsChange,
}: {
shown: boolean;
children: ReactElement;
workspaceId?: string | null;
cwd: string;
files: ParsedDiffFile[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
treeWidth?: number;
onTreeWidthChange: (width: number) => void;
collapsedFolderPaths: string[];
onCollapsedFolderPathsChange: (paths: string[]) => void;
}) {
if (!shown) return children;
return (
<TreeRail testID="changes-tree-rail">
<TreeRail testID="changes-tree-rail" width={treeWidth ?? 220} onWidthChange={onTreeWidthChange}>
{children}
<ChangedFilesTree
workspaceId={workspaceId}
cwd={cwd}
files={files}
mode={mode}
onSelectFile={onSelectFile}
collapsedFolderPaths={collapsedFolderPaths}
onCollapsedFolderPathsChange={onCollapsedFolderPathsChange}
/>
</TreeRail>
);
@@ -1019,33 +952,30 @@ function useDiffTabNavigation({
cwd: string;
isMobile: boolean;
}) {
const openWorkspaceTabInFocusedPane = useWorkspaceLayoutStore(
(state) => state.openTabInFocusedPane,
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
const openWorkspaceTabInFocusedPane = useCallback(
(workspaceKey: string, target: WorkspaceTabTarget, placement?: WorkspaceTabPlacement) =>
openTab({ workspaceKey, target, intent: "reveal", placement }),
[openTab],
);
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const persistenceKey = useMemo(
() => buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? cwd }),
[cwd, serverId, workspaceId],
);
const changesTabId = useWorkspaceLayoutStore((state) => {
if (!persistenceKey) {
return null;
}
const layout = state.layoutByWorkspace[persistenceKey];
return (
layout && collectAllTabs(layout.root).find((tab) => tab.target.kind === "working_diff")?.tabId
);
});
const changesTabOpen = !isMobile && Boolean(changesTabId);
const changesTabOpen = false;
const openChanges = useCallback(
(path?: string) => {
if (!persistenceKey || isMobile) {
return;
}
openWorkspaceTabInFocusedPane(persistenceKey, {
kind: "working_diff",
...(path ? { focusPath: path, focusRequestId: Date.now() } : {}),
});
openWorkspaceTabInFocusedPane(
persistenceKey,
{
kind: "working_diff",
...(path ? { focusPath: path, focusRequestId: Date.now() } : {}),
},
FOCUSED_PANE_PLACEMENT,
);
},
[isMobile, openWorkspaceTabInFocusedPane, persistenceKey],
);
@@ -1053,16 +983,16 @@ function useDiffTabNavigation({
if (!persistenceKey || isMobile) {
return;
}
if (changesTabId) {
closeWorkspaceTab(persistenceKey, changesTabId);
return;
}
openChanges();
}, [changesTabId, closeWorkspaceTab, isMobile, openChanges, persistenceKey]);
}, [isMobile, openChanges, persistenceKey]);
const openCommit = useCallback(
(sha: string) => {
if (persistenceKey) {
openWorkspaceTabInFocusedPane(persistenceKey, { kind: "commit_diff", sha });
openWorkspaceTabInFocusedPane(
persistenceKey,
{ kind: "commit_diff", sha },
FOCUSED_PANE_PLACEMENT,
);
}
},
[openWorkspaceTabInFocusedPane, persistenceKey],
@@ -1086,27 +1016,26 @@ export function ChangesSurface({
focusRequestId,
onOpenFile,
onAddToChat,
state: changesState,
onStateChange,
}: ChangesSurfaceProps) {
const { settings: appSettings } = useAppSettings();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const canUseSplitLayout = isWeb && !isMobile;
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
useChangesPreferences();
const wrapLines = changesPreferences.wrapLines;
const desktopTreeVisible = changesPreferences.desktopTreeVisible;
const effectiveLayout = resolveDiffLayout(changesPreferences.layout, canUseSplitLayout);
const collapsedFilesWorkspaceKey = `${serverId}:${workspaceId ?? cwd}`;
const persistedCollapsedFilePaths = usePanelStore(
(state) => state.collapsedFilePathsByWorkspace[collapsedFilesWorkspaceKey],
);
const setCollapsedFilePaths = usePanelStore((state) => state.setCollapsedFilePathsForWorkspace);
const collapsedFilePaths = persistedCollapsedFilePaths ?? EMPTY_PATH_LIST;
const instanceState = changesState ?? defaultChangesState;
const updateState = onStateChange ?? noopStateChange;
const wrapLines = instanceState.wrapLines;
const desktopTreeVisible = instanceState.treeVisible;
const effectiveLayout = resolveDiffLayout(instanceState.layout, canUseSplitLayout);
const collapsedFilePaths = instanceState.collapsedFilePaths;
const updateCollapsedFilePaths = useCallback(
(paths: string[]) => {
setCollapsedFilePaths(collapsedFilesWorkspaceKey, paths);
},
[collapsedFilesWorkspaceKey, setCollapsedFilePaths],
(paths: string[]) => updateState({ ...instanceState, collapsedFilePaths: paths }),
[instanceState, updateState],
);
const updateCollapsedFolderPaths = useCallback(
(paths: string[]) => updateState({ ...instanceState, collapsedFolderPaths: paths }),
[instanceState, updateState],
);
const collapseState = useMemo(
() => ({ paths: collapsedFilePaths, onChange: updateCollapsedFilePaths }),
@@ -1114,26 +1043,30 @@ export function ChangesSurface({
);
const handleToggleWrapLines = useCallback(() => {
void updateChangesPreferences({ wrapLines: !wrapLines });
}, [updateChangesPreferences, wrapLines]);
updateState({ ...instanceState, wrapLines: !wrapLines });
}, [instanceState, updateState, wrapLines]);
const handleToggleHideWhitespace = useCallback(() => {
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
updateState({
...instanceState,
hideWhitespace: !instanceState.hideWhitespace,
});
}, [instanceState, updateState]);
const handleToggleLayout = useCallback(() => {
void updateChangesPreferences({
layout: changesPreferences.layout === "unified" ? "split" : "unified",
updateState({
...instanceState,
layout: instanceState.layout === "unified" ? "split" : "unified",
});
}, [changesPreferences.layout, updateChangesPreferences]);
const codeFontSize = appSettings.codeFontSize;
const treeToggleStyle = useMemo(
() => buildToggleButtonStyle(desktopTreeVisible, styles.toolbarIconButton),
[desktopTreeVisible],
}, [instanceState, updateState]);
const handleDiffModeChange = useCallback(
(mode: "uncommitted" | "base") => updateState({ ...instanceState, mode }),
[instanceState, updateState],
);
const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []);
const codeFontSize = appSettings.codeFontSize;
const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(isMobile), [isMobile]);
const toast = useToast();
const isLocalDaemon = useIsLocalDaemon(serverId);
@@ -1192,8 +1125,11 @@ export function ChangesSurface({
serverId,
workspaceId: workspaceId ?? undefined,
cwd,
ignoreWhitespace: changesPreferences.hideWhitespace,
ignoreWhitespace: instanceState.hideWhitespace,
enabled: enabled !== false,
mode: instanceState.mode,
baseRef: instanceState.baseRef,
onModeChange: handleDiffModeChange,
});
usePublishWorkingDiffAttachment({
serverId,
@@ -1231,8 +1167,16 @@ export function ChangesSurface({
[forgeSetupAction, forge, status?.remoteUrl, t],
);
const handleToggleDesktopTree = useCallback(() => {
void updateChangesPreferences({ desktopTreeVisible: !desktopTreeVisible });
}, [desktopTreeVisible, updateChangesPreferences]);
updateState({ ...instanceState, treeVisible: !desktopTreeVisible });
}, [desktopTreeVisible, instanceState, updateState]);
const handleCommitsCollapsedChange = useCallback(
(commitsCollapsed: boolean) => updateState({ ...instanceState, commitsCollapsed }),
[instanceState, updateState],
);
const handleChangesTreeWidth = useCallback(
(treeWidth: number) => updateState({ ...instanceState, treeWidth }),
[instanceState, updateState],
);
const sharedDisplayPreferences = useMemo(
() => ({
layout: effectiveLayout,
@@ -1383,16 +1327,11 @@ export function ChangesSurface({
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
[baseRefLabel, branchLabel],
);
const emptyMessage = computeEmptyMessage(
changesPreferences.hideWhitespace,
diffMode,
baseRefLabel,
{
hiddenWhitespace: t("workspace.git.diff.emptyHiddenWhitespace"),
uncommitted: t("workspace.git.diff.emptyUncommitted"),
againstBase: (label) => t("workspace.git.diff.emptyAgainstBase", { baseRef: label }),
},
);
const emptyMessage = computeEmptyMessage(instanceState.hideWhitespace, diffMode, baseRefLabel, {
hiddenWhitespace: t("workspace.git.diff.emptyHiddenWhitespace"),
uncommitted: t("workspace.git.diff.emptyUncommitted"),
againstBase: (label) => t("workspace.git.diff.emptyAgainstBase", { baseRef: label }),
});
const diffContent: ReactElement = (
<DiffBodyContent
@@ -1418,11 +1357,13 @@ export function ChangesSurface({
const bodyContent = (
<ChangesTreeRail
shown={desktopTreeVisible && !isMobile && files.length > 0}
workspaceId={workspaceId}
cwd={cwd}
files={files}
mode={workingMode}
onSelectFile={handleSelectTreeFile}
treeWidth={instanceState.treeWidth}
onTreeWidthChange={handleChangesTreeWidth}
collapsedFolderPaths={instanceState.collapsedFolderPaths}
onCollapsedFolderPathsChange={updateCollapsedFolderPaths}
>
{diffContent}
</ChangesTreeRail>
@@ -1447,16 +1388,15 @@ export function ChangesSurface({
diffMode={diffMode}
gitActions={gitActions}
hasFiles={hasChanges}
hideWhitespace={changesPreferences.hideWhitespace}
hideWhitespace={instanceState.hideWhitespace}
host={host}
isMobile={isMobile}
isRefreshing={isRefreshing}
layout={changesPreferences.layout}
layout={instanceState.layout}
overflowToggleStyle={overflowToggleStyle}
refreshSupported={refreshSupported}
selectedDiffStat={selectedDiffStat}
serverId={serverId}
treeToggleStyle={treeToggleStyle}
workspaceId={workspaceId}
wrapLines={wrapLines}
onCollapseAll={handleCollapseAllFiles}
@@ -1482,7 +1422,13 @@ export function ChangesSurface({
<View style={styles.diffContainer}>{bodyContent}</View>
<CommitsSection serverId={serverId} cwd={cwd} onCommitPress={handleCommitPress} />
<CommitsSection
serverId={serverId}
cwd={cwd}
onCommitPress={handleCommitPress}
collapsed={instanceState.commitsCollapsed}
onCollapsedChange={handleCommitsCollapsedChange}
/>
</View>
);
}
@@ -1493,16 +1439,12 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
changesToolbar: {
minHeight: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingLeft: theme.spacing[2],
paddingRight: theme.spacing[3],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
flexShrink: 0,
},
changesToolbarIdentity: {
minWidth: 0,
@@ -1535,12 +1477,6 @@ const styles = StyleSheet.create((theme) => ({
diffModeTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
diffModeTriggerPressed: {
backgroundColor: theme.colors.surface2,
},
diffStatusRowHovered: {
backgroundColor: theme.colors.surface2,
},
diffStatusText: {
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.25,
@@ -1549,44 +1485,6 @@ const styles = StyleSheet.create((theme) => ({
diffStatusIconHidden: {
opacity: 0,
},
toggleButtonSelected: {
backgroundColor: theme.colors.surface2,
},
toolbarIconButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
minWidth: {
xs: 32,
sm: 32,
md: 24,
},
height: {
xs: 32,
sm: 32,
md: 24,
},
paddingHorizontal: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
borderRadius: theme.borderRadius.base,
flexShrink: 0,
},
overflowButton: {
width: ICON_SIZE.sm + 2 * SPACING[1],
height: {
xs: 32,
sm: 32,
md: 24,
},
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.base,
flexShrink: 0,
},
actionErrorText: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[1],
@@ -0,0 +1,15 @@
import { useCallback, useState } from "react";
import type { CheckStatus } from "./check-status";
/** Per-mounted PR panel state; never shared with another tab instance. */
export function useCheckGroupState() {
const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<CheckStatus>>(() => new Set());
const toggle = useCallback((status: CheckStatus) => {
setCollapsedGroups((current) => {
const next = new Set(current);
if (!next.delete(status)) next.add(status);
return next;
});
}, []);
return { collapsedGroups, toggle };
}
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo } from "react";
import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native";
@@ -11,6 +11,7 @@ import { summarizeChecks, type ChecksGroup } from "./checks-summary";
import { canAddPullRequestCheckLogsToChat } from "./context-attachment";
import type { PrPaneCheck } from "./data";
import { CheckStatusIcon, foregroundMutedColorMapping, sectionKitStyles } from "./section-kit";
import { useCheckGroupState } from "./check-group-state";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
@@ -67,17 +68,7 @@ export function ChecksSection({
onAddLogsToChat,
}: ChecksSectionProps) {
const summary = useMemo(() => summarizeChecks(checks), [checks]);
const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<CheckStatus>>(() => new Set());
const handleToggleGroup = useCallback((status: CheckStatus) => {
setCollapsedGroups((current) => {
const next = new Set(current);
if (!next.delete(status)) {
next.add(status);
}
return next;
});
}, []);
const { collapsedGroups, toggle: handleToggleGroup } = useCheckGroupState();
return (
<View testID="pr-pane-checks">
@@ -1,6 +1,7 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneContentToolbar } from "@/components/ui/pane-content-toolbar";
import { PrActivitySkeleton, SkeletonPulse, useSkeletonPulse } from "./activity-skeleton";
const CHECK_ROW_KEYS = [0, 1, 2].map((i) => `pr-pane-skeleton-check-${i}`);
@@ -16,10 +17,10 @@ export function PullRequestPaneSkeleton() {
<SkeletonPulse pulse={pulse} style={styles.subtitle} />
</View>
<View style={styles.toolbar}>
<PaneContentToolbar style={styles.toolbar}>
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
</View>
</PaneContentToolbar>
<View style={styles.section}>
<Text style={styles.sectionTitle}>{t("workspace.git.pr.sections.checks")}</Text>
@@ -68,9 +69,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
toolbarButton: {
width: 96,
@@ -36,7 +36,12 @@ import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachment
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
PaneContentToolbar,
paneContentToolbarIconSize,
paneContentToolbarIconButtonStyle,
} from "@/components/ui/pane-content-toolbar";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { getForgePresentation } from "@/git/forge";
import { CLIENT_FORGE_VIEW_MODULES } from "@/git/forges/view";
@@ -145,11 +150,11 @@ function kebabTriggerStyle({
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function refreshButtonStyle({
hovered = false,
pressed = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.refreshButton, (hovered || pressed) && styles.refreshButtonHovered];
function refreshButtonStyle(
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
isCompact: boolean,
) {
return paneContentToolbarIconButtonStyle({ hovered, pressed }, false, isCompact);
}
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
@@ -193,6 +198,11 @@ export function PullRequestPane({
workspaceAttachmentScopeKey?: string;
}) {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const toolbarRefreshButtonStyle = useCallback(
(state: PressableStateCallbackType) => refreshButtonStyle(state, isCompact),
[isCompact],
);
const toast = useToast();
const daemonClient = useHostRuntimeClient(serverId);
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
@@ -467,7 +477,7 @@ export function PullRequestPane({
return (
<View style={styles.root} testID="pr-pane">
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
<View style={styles.toolbar} testID="pr-pane-toolbar">
<PaneContentToolbar style={styles.toolbar} testID="pr-pane-toolbar">
<View style={styles.toolbarActions}>
<Button
variant="ghost"
@@ -489,7 +499,7 @@ export function PullRequestPane({
: t("workspace.git.diff.refreshState", { brand: forgePresentation.brandLabel })
}
testID="pr-pane-refresh"
style={refreshButtonStyle}
style={toolbarRefreshButtonStyle}
hitSlop={8}
onPress={handleRefresh}
disabled={isRefreshing}
@@ -497,16 +507,19 @@ export function PullRequestPane({
<View style={styles.refreshIcon}>
{isRefreshing ? (
<ThemedLoadingSpinner
size={ICON_SIZE.sm}
size={paneContentToolbarIconSize(isCompact)}
uniProps={foregroundMutedColorMapping}
/>
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
<ThemedRotateCw
size={paneContentToolbarIconSize(isCompact)}
uniProps={foregroundMutedColorMapping}
/>
)}
</View>
</Pressable>
) : null}
</View>
</PaneContentToolbar>
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
{({ hovered }) => (
@@ -1239,15 +1252,10 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.border,
},
toolbar: {
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
paddingTop: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingBottom: theme.spacing[2],
paddingLeft: theme.spacing[3],
},
toolbarActions: {
@@ -1263,17 +1271,6 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[1],
borderRadius: theme.borderRadius.base,
},
refreshButton: {
marginLeft: "auto",
width: 22,
height: 22,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
refreshButtonHovered: {
backgroundColor: theme.colors.surface2,
},
refreshIcon: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
+17 -26
View File
@@ -5,11 +5,8 @@ import {
} from "@/attachments/workspace-attachments-store";
import {
buildReviewDraftKey,
buildReviewDraftScopeKey,
useInlineReviewController,
useResolvedDiffMode,
useReviewAttachmentSnapshot,
useSetDiffModeOverride,
} from "@/review";
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
@@ -21,6 +18,9 @@ interface UseWorkingDiffOptions {
ignoreWhitespace: boolean;
enabled: boolean;
queryScope?: string;
mode?: "uncommitted" | "base";
baseRef?: string;
onModeChange?: (mode: "uncommitted" | "base") => void;
}
export function useWorkingDiff({
@@ -30,6 +30,9 @@ export function useWorkingDiff({
ignoreWhitespace,
enabled,
queryScope,
mode,
baseRef: selectedBaseRef,
onModeChange,
}: UseWorkingDiffOptions) {
const {
status,
@@ -43,38 +46,26 @@ export function useWorkingDiff({
const statusErrorMessage =
status?.error?.message ??
(isStatusError && statusError instanceof Error ? statusError.message : null);
const baseRef = gitStatus?.baseRef ?? undefined;
const baseRef = selectedBaseRef ?? gitStatus?.baseRef ?? undefined;
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const currentBranchName =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : null;
const reviewDraftScopeKey = useMemo(
() =>
buildReviewDraftScopeKey({
serverId,
workspaceId,
cwd,
baseRef,
ignoreWhitespace,
}),
[baseRef, cwd, ignoreWhitespace, serverId, workspaceId],
);
const diffMode = useResolvedDiffMode({
scopeKey: reviewDraftScopeKey,
hasUncommittedChanges,
});
const setDiffModeOverride = useSetDiffModeOverride();
const selectDiffMode = useCallback(
(mode: "uncommitted" | "base") => {
setDiffModeOverride({
scopeKey: reviewDraftScopeKey,
override: { serverId, cwd, mode, isDirtyAtSelection: hasUncommittedChanges },
});
(nextMode: "uncommitted" | "base") => {
if (onModeChange) {
onModeChange(nextMode);
return;
}
// A Changes surface always supplies its tab-owned presentation. Keep this
// fallback deterministic for legacy non-tab consumers without mutating
// workspace-wide review preferences.
},
[cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride],
[onModeChange],
);
const selectUncommitted = useCallback(() => selectDiffMode("uncommitted"), [selectDiffMode]);
const selectBase = useCallback(() => selectDiffMode("base"), [selectDiffMode]);
const diffMode = mode ?? (hasUncommittedChanges ? "uncommitted" : "base");
const {
files,