feat: add file manager context actions (#3027)

* feat(app): add file manager context actions

* fix: reshape file context actions

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
nikuscs
2026-08-10 18:18:23 +02:00
committed by GitHub
co-authored by Mohamed Boudra
parent a98d154b6b
commit ca2b633286
36 changed files with 4159 additions and 227 deletions
@@ -183,6 +183,31 @@ describe("checkout-git-actions-store", () => {
).toBe("idle");
});
it("discards selected paths through the shared checkout action workflow", async () => {
const checkoutDiscardChanges = vi.fn(async () => ({ success: true, error: null }));
const client = { checkoutDiscardChanges };
useSessionStore.setState((state) => ({
...state,
sessions: {
...state.sessions,
[serverId]: { client } as unknown as (typeof state.sessions)[string],
},
}));
await useCheckoutGitActionsStore
.getState()
.discardChanges({ serverId, cwd, paths: ["renamed.ts", "original.ts"] });
expect(checkoutDiscardChanges).toHaveBeenCalledWith(cwd, {
paths: ["renamed.ts", "original.ts"],
});
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "discard-changes" }),
).toBe("success");
});
for (const rpc of [
{
label: "forge",
+20 -1
View File
@@ -24,7 +24,8 @@ export type CheckoutGitAsyncActionId =
| "enable-pr-auto-merge-rebase"
| "disable-pr-auto-merge"
| "merge-branch"
| "merge-from-base";
| "merge-from-base"
| "discard-changes";
type CheckoutKey = string;
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
@@ -119,6 +120,7 @@ interface CheckoutGitActionsStoreState {
disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
discardChanges: (params: { serverId: string; cwd: string; paths: string[] }) => Promise<void>;
}
async function runCheckoutAction({
@@ -366,6 +368,23 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
},
});
},
discardChanges: async ({ serverId, cwd, paths }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "discard-changes",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutDiscardChanges(cwd, { paths });
if (!payload.success) {
throw new Error(
payload.error?.message ?? i18n.t("workspace.fileActions.confirmRevert.failed"),
);
}
},
});
},
}));
export function __resetCheckoutGitActionsStoreForTests() {
+132 -41
View File
@@ -1,12 +1,7 @@
import { useCallback, useMemo } from "react";
import {
View,
Text,
Pressable,
type LayoutChangeEvent,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { View, Text, type LayoutChangeEvent, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Folder } from "lucide-react-native";
import { DiffStat } from "@/components/diff-stat";
import {
TreeChevron,
@@ -15,9 +10,16 @@ import {
WORKSPACE_FILE_ROW_TRAILING_PADDING,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
WORKSPACE_TREE_ICON_LABEL_GAP,
WORKSPACE_TREE_ICON_SIZE,
} from "@/components/tree-primitives";
import { type Theme } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
import { isWeb } from "@/constants/platform";
const ThemedFolder = withUnistyles(Folder);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
interface DiffFolderRowProps {
/** full uncompressed directory path — the collapse identity */
@@ -25,20 +27,27 @@ interface DiffFolderRowProps {
displayName: string;
depth: number;
collapsed: boolean;
isSelected: boolean;
additions: number;
deletions: number;
onToggle: (dirPath: string) => void;
onCollapse: (dirPath: string) => void;
onSelect: (dirPath: string) => void;
onHeightChange?: (height: number) => void;
onCopyPath?: (path: string) => void;
onCopyRelativePath?: (path: string) => void;
onReveal?: (path: string) => void;
revealTargetName?: string;
onDuplicate?: (path: string) => void;
onRevert?: (path: string) => void;
testID?: string;
}
function folderRowPressableStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
// Subtle background highlight on hover/press, matching the Files explorer rows
// (entryRowActive) — no opacity darken.
return [styles.folderRow, (Boolean(hovered) || pressed) && styles.folderRowActive];
function folderRowPressableStyle(
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
isSelected: boolean,
) {
return [styles.folderRow, (Boolean(hovered) || pressed || isSelected) && styles.folderRowActive];
}
export function DiffFolderRow({
@@ -46,15 +55,39 @@ export function DiffFolderRow({
displayName,
depth,
collapsed,
isSelected,
additions,
deletions,
onToggle,
onCollapse,
onSelect,
onHeightChange,
onCopyPath,
onCopyRelativePath,
onReveal,
revealTargetName,
onDuplicate,
onRevert,
testID,
}: DiffFolderRowProps) {
const handleSelect = useCallback(() => {
onSelect(dirPath);
}, [dirPath, onSelect]);
const handlePress = useCallback(() => {
const selection = isWeb ? window.getSelection() : null;
if (selection && !selection.isCollapsed && selection.toString().length > 0) {
return;
}
handleSelect();
onToggle(dirPath);
}, [dirPath, onToggle]);
}, [dirPath, handleSelect, onToggle]);
const pressableStyle = useCallback(
(state: PressableStateCallbackType & { hovered?: boolean }) =>
folderRowPressableStyle(state, isSelected),
[isSelected],
);
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
@@ -63,39 +96,88 @@ export function DiffFolderRow({
[onHeightChange],
);
const handleCollapse = useCallback(() => {
onCollapse(dirPath);
}, [dirPath, onCollapse]);
const handleCopyPath = useCallback(() => {
onCopyPath?.(dirPath);
}, [dirPath, onCopyPath]);
const handleCopyRelativePath = useCallback(() => {
onCopyRelativePath?.(dirPath);
}, [dirPath, onCopyRelativePath]);
const handleReveal = useCallback(() => {
onReveal?.(dirPath);
}, [dirPath, onReveal]);
const handleDuplicate = useCallback(() => {
onDuplicate?.(dirPath);
}, [dirPath, onDuplicate]);
const handleRevert = useCallback(() => {
onRevert?.(dirPath);
}, [dirPath, onRevert]);
const leftStyle = useMemo(
() => [styles.left, inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) })],
[depth],
);
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
const accessibilityState = useMemo(
() => ({ expanded: !collapsed, selected: isSelected }),
[collapsed, isSelected],
);
return (
<View style={styles.container} onLayout={handleLayout} testID={testID}>
<TreeIndentGuides depth={depth} />
<Pressable
onPress={handlePress}
style={folderRowPressableStyle}
accessibilityRole="button"
accessibilityState={accessibilityState}
testID={testID ? `${testID}-toggle` : undefined}
>
<View style={leftStyle}>
<View style={styles.chevronOpticalOffset}>
<TreeChevron expanded={!collapsed} />
<ContextMenu>
<ContextMenuTrigger
onPress={handlePress}
onLongPress={handleSelect}
onContextMenu={handleSelect}
style={pressableStyle}
accessibilityRole="button"
accessibilityState={accessibilityState}
aria-selected={isSelected}
testID={testID ? `${testID}-toggle` : undefined}
>
<View style={leftStyle}>
<View style={styles.chevronOpticalOffset}>
<TreeChevron expanded={!collapsed} />
</View>
<View style={styles.folderIcon}>
<ThemedFolder
size={WORKSPACE_TREE_ICON_SIZE}
uniProps={foregroundMutedColorMapping}
/>
</View>
<Text style={styles.folderName} numberOfLines={1}>
{displayName}
</Text>
</View>
<Text style={styles.folderName} numberOfLines={1}>
{displayName}
</Text>
</View>
<View style={styles.right}>
<DiffStat
additions={additions}
deletions={deletions}
testID={testID ? `${testID}-stat` : undefined}
/>
</View>
</Pressable>
<View style={styles.right}>
<DiffStat
additions={additions}
deletions={deletions}
testID={testID ? `${testID}-stat` : undefined}
/>
</View>
</ContextMenuTrigger>
<FileActionsContextMenuContent
fileKind="directory"
onCollapseFolder={!collapsed ? handleCollapse : undefined}
onCopyPath={onCopyPath ? handleCopyPath : undefined}
onCopyRelativePath={onCopyRelativePath ? handleCopyRelativePath : undefined}
onReveal={onReveal ? handleReveal : undefined}
revealTargetName={revealTargetName}
onDuplicate={onDuplicate ? handleDuplicate : undefined}
onRevert={onRevert ? handleRevert : undefined}
testIDPrefix={testID}
/>
</ContextMenu>
</View>
);
}
@@ -123,8 +205,16 @@ const styles = StyleSheet.create((theme: Theme) => ({
minWidth: 0,
},
chevronOpticalOffset: {
// The Changes directory chevron reads high beside the folder label.
transform: [{ translateY: 2 }],
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
alignItems: "center",
justifyContent: "center",
},
folderIcon: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
alignItems: "center",
justifyContent: "center",
},
right: {
flexDirection: "row",
@@ -138,5 +228,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
color: theme.colors.foreground,
flexShrink: 1,
minWidth: 0,
userSelect: "none",
},
}));
+339 -37
View File
@@ -81,6 +81,7 @@ import * as Clipboard from "expo-clipboard";
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { useFileDownload } from "@/hooks/use-file-download";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { lineNumberGutterWidth } from "@/components/code-insets";
@@ -94,6 +95,7 @@ import type { ForgeAuthState } from "@getpaseo/protocol/messages";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
import { confirmDialog } from "@/utils/confirm-dialog";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useOverlayFlatListScrollbar } from "@/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -119,6 +121,7 @@ import {
} from "@/review";
import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff";
import { DiffTooLargeState } from "@/git/diff-too-large-state";
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
@@ -129,11 +132,14 @@ export function resolveDiffLayout(
return canUseSplitLayout ? layout : "unified";
}
function fileHeaderPressableStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.fileHeader, (Boolean(hovered) || pressed) && styles.fileHeaderActive];
function fileHeaderPressableStyle(
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
isSelected: boolean,
) {
return [
styles.fileHeader,
(Boolean(hovered) || pressed || isSelected) && styles.fileHeaderActive,
];
}
interface HighlightedTextProps {
@@ -208,16 +214,23 @@ interface DiffFileSectionProps {
file: ParsedDiffFile;
workspaceFileDragScope?: { serverId: string; workspaceId: string };
isExpanded: boolean;
isSelected?: boolean;
/** Tree indentation level (0 on the flat/mobile path). */
depth?: number;
/** Show the muted directory suffix (flat list); false inside the folder tree. */
showDir?: boolean;
interactive?: boolean;
onToggle?: (path: string) => void;
onSelect?: (path: string) => void;
onOpenFile?: (path: string) => void;
onAddToChat?: (path: string) => void;
onCopyPath?: (path: string) => void;
onCopyRelativePath?: (path: string) => void;
onReveal?: (path: string) => void;
revealTargetName?: string;
onDownload?: (path: string) => void;
onDuplicate?: (path: string) => void;
onRevert?: (path: string, oldPath?: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void;
testID?: string;
}
@@ -226,6 +239,57 @@ const EMPTY_COMMENTS: readonly ReviewDraftComment[] = [];
function noopStartComment(): void {}
function useDiscardChangesAction({
serverId,
cwd,
diffMode,
}: {
serverId: string;
cwd: string;
diffMode: "uncommitted" | "base";
}): ((path: string, oldPath?: string) => void) | undefined {
const { t } = useTranslation();
const toast = useToast();
const discardChanges = useCheckoutGitActionsStore((state) => state.discardChanges);
// COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08.
const discardSupported = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutDiscardChanges === true,
);
const discardPath = useCallback(
async (path: string, oldPath?: string) => {
const confirmed = await confirmDialog({
title: t("workspace.fileActions.confirmRevert.title"),
message: t("workspace.fileActions.confirmRevert.message", { name: path }),
confirmLabel: t("workspace.fileActions.confirmRevert.confirm"),
cancelLabel: t("workspace.fileActions.confirmRevert.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
try {
await discardChanges({
serverId,
cwd,
paths: oldPath ? [path, oldPath] : [path],
});
} catch (cause) {
toast.error(
cause instanceof Error ? cause.message : t("workspace.fileActions.confirmRevert.failed"),
);
}
},
[cwd, discardChanges, serverId, t, toast],
);
const handleDiscardPath = useCallback(
(path: string, oldPath?: string) => {
void discardPath(path, oldPath);
},
[discardPath],
);
return discardSupported && diffMode === "uncommitted" ? handleDiscardPath : undefined;
}
const DIFF_LINE_HOVER_STYLE = isWeb ? ({ cursor: "auto" } as const) : null;
function LongPressableLine({
@@ -247,6 +311,10 @@ function LongPressableLine({
}) {
const onStartComment = reviewActions?.onStartComment;
const handlePress = useCallback(() => {
const selection = isWeb ? window.getSelection() : null;
if (selection && !selection.isCollapsed && selection.toString().length > 0) {
return;
}
if (reviewTarget && onStartComment) {
onStartComment(reviewTarget);
}
@@ -912,18 +980,84 @@ function SplitDiffColumn({
);
}
function DiffFileActionsContextMenuContent({
file,
onOpenFile,
onAddToChat,
onCopyPath,
onCopyRelativePath,
onReveal,
revealTargetName,
onDownload,
onDuplicate,
onRevert,
testID,
}: Pick<
DiffFileSectionProps,
| "file"
| "onOpenFile"
| "onAddToChat"
| "onCopyPath"
| "onCopyRelativePath"
| "onReveal"
| "revealTargetName"
| "onDownload"
| "onDuplicate"
| "onRevert"
| "testID"
>) {
const handleOpenFile = useCallback(() => onOpenFile?.(file.path), [file.path, onOpenFile]);
const handleAddToChat = useCallback(() => onAddToChat?.(file.path), [file.path, onAddToChat]);
const handleCopyPath = useCallback(() => onCopyPath?.(file.path), [file.path, onCopyPath]);
const handleCopyRelativePath = useCallback(
() => onCopyRelativePath?.(file.path),
[file.path, onCopyRelativePath],
);
const handleReveal = useCallback(() => onReveal?.(file.path), [file.path, onReveal]);
const handleDownload = useCallback(() => onDownload?.(file.path), [file.path, onDownload]);
const handleDuplicate = useCallback(() => onDuplicate?.(file.path), [file.path, onDuplicate]);
const handleRevert = useCallback(
() => onRevert?.(file.path, file.oldPath),
[file.oldPath, file.path, onRevert],
);
return (
<FileActionsContextMenuContent
fileKind="file"
fileExists={!file.isDeleted}
onOpenFile={onOpenFile ? handleOpenFile : undefined}
onCopyPath={onCopyPath ? handleCopyPath : undefined}
onCopyRelativePath={onCopyRelativePath ? handleCopyRelativePath : undefined}
onReveal={onReveal ? handleReveal : undefined}
revealTargetName={revealTargetName}
onDownload={onDownload ? handleDownload : undefined}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
onDuplicate={!file.isDeleted && onDuplicate ? handleDuplicate : undefined}
onRevert={onRevert ? handleRevert : undefined}
testIDPrefix={testID}
/>
);
}
const DiffFileHeader = memo(function DiffFileHeader({
file,
workspaceFileDragScope,
isExpanded,
isSelected = false,
depth = 0,
showDir = true,
interactive = true,
onToggle,
onSelect,
onOpenFile,
onAddToChat,
onCopyPath,
onCopyRelativePath,
onReveal,
revealTargetName,
onDownload,
onDuplicate,
onRevert,
onHeaderHeightChange,
testID,
}: DiffFileSectionProps) {
@@ -938,29 +1072,24 @@ const DiffFileHeader = memo(function DiffFileHeader({
const pressHandledRef = useRef(false);
const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null);
const handleSelect = useCallback(() => {
if (interactive) {
onSelect?.(file.path);
}
}, [file.path, interactive, onSelect]);
const toggleExpanded = useCallback(() => {
if (!interactive) {
return;
}
const selection = isWeb ? window.getSelection() : null;
if (selection && !selection.isCollapsed && selection.toString().length > 0) {
return;
}
pressHandledRef.current = true;
handleSelect();
onToggle?.(file.path);
}, [file.path, interactive, onToggle]);
const handleOpenFile = useCallback(() => {
onOpenFile?.(file.path);
}, [file.path, onOpenFile]);
const handleAddToChat = useCallback(() => {
onAddToChat?.(file.path);
}, [file.path, onAddToChat]);
const handleCopyPath = useCallback(() => {
onCopyPath?.(file.path);
}, [file.path, onCopyPath]);
const handleDownload = useCallback(() => {
onDownload?.(file.path);
}, [file.path, onDownload]);
}, [file.path, handleSelect, interactive, onToggle]);
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
@@ -981,7 +1110,8 @@ const DiffFileHeader = memo(function DiffFileHeader({
const handleLongPress = useCallback(() => {
pressHandledRef.current = true;
}, []);
handleSelect();
}, [handleSelect]);
const handlePressOut = useCallback(
(event: { nativeEvent: { pageX: number; pageY: number } }) => {
@@ -1008,16 +1138,20 @@ const DiffFileHeader = memo(function DiffFileHeader({
() => [styles.fileSectionHeaderContainer, isExpanded && styles.fileSectionHeaderExpanded],
[isExpanded],
);
const accessibilityState = useMemo(
() => ({ expanded: isExpanded, selected: isSelected }),
[isExpanded, isSelected],
);
const headerPressableStyle = useCallback(
(state: PressableStateCallbackType) =>
depth > 0
? [
fileHeaderPressableStyle(state),
fileHeaderPressableStyle(state, isSelected),
inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }),
]
: fileHeaderPressableStyle(state),
[depth],
: fileHeaderPressableStyle(state, isSelected),
[depth, isSelected],
);
const fileName = file.path.split("/").pop() ?? file.path;
@@ -1027,9 +1161,10 @@ const DiffFileHeader = memo(function DiffFileHeader({
ref={dragSourceRef}
style={showDir ? styles.fileHeaderLeft : [styles.fileHeaderLeft, styles.fileHeaderLeftTree]}
>
{showDir ? null : <View style={styles.fileChevronSpacer} />}
{showDir ? null : (
<View style={styles.fileIcon}>
<MaterialFileIcon fileName={fileName} size={16} />
<MaterialFileIcon fileName={fileName} size={WORKSPACE_TREE_ICON_SIZE} />
</View>
)}
<Text style={styles.fileName} numberOfLines={1}>
@@ -1060,7 +1195,14 @@ const DiffFileHeader = memo(function DiffFileHeader({
let trigger: ReactElement;
if (!interactive) {
trigger = (
<View style={headerPressableStyle({ hovered: false, pressed: false })}>{headerContent}</View>
<View
{...{
onContextMenu: (event: { preventDefault?: () => void }) => event.preventDefault?.(),
}}
style={headerPressableStyle({ hovered: false, pressed: false })}
>
{headerContent}
</View>
);
} else {
trigger = (
@@ -1072,7 +1214,10 @@ const DiffFileHeader = memo(function DiffFileHeader({
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onLongPress={handleLongPress}
onContextMenu={handleSelect}
onPress={toggleExpanded}
accessibilityState={accessibilityState}
aria-selected={isSelected}
>
{headerContent}
</ContextMenuTrigger>
@@ -1090,14 +1235,18 @@ const DiffFileHeader = memo(function DiffFileHeader({
</TooltipContent>
</Tooltip>
{interactive ? (
<FileActionsContextMenuContent
fileKind="file"
fileExists={!file.isDeleted}
onOpenFile={onOpenFile ? handleOpenFile : undefined}
onCopyPath={onCopyPath ? handleCopyPath : undefined}
onDownload={onDownload ? handleDownload : undefined}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
testIDPrefix={testID}
<DiffFileActionsContextMenuContent
file={file}
onOpenFile={onOpenFile}
onAddToChat={onAddToChat}
onCopyPath={onCopyPath}
onCopyRelativePath={onCopyRelativePath}
onReveal={onReveal}
revealTargetName={revealTargetName}
onDownload={onDownload}
onDuplicate={onDuplicate}
onRevert={onRevert}
testID={testID}
/>
) : null}
</ContextMenu>
@@ -1827,7 +1976,12 @@ interface SharedDiffViewProps {
onOpenFile?: (path: string) => void;
onAddToChat?: (path: string) => void;
onCopyPath?: (path: string) => void;
onCopyRelativePath?: (path: string) => void;
onReveal?: (path: string) => void;
revealTargetName?: string;
onDownload?: (path: string) => void;
onDuplicate?: (path: string) => void;
onRevert?: (path: string, oldPath?: string) => void;
onExpandedPathsChange: (paths: string[]) => void;
onCollapsedFoldersChange: (paths: string[]) => void;
}
@@ -1882,10 +2036,28 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
const workspaceFileDragScope =
mode.kind === "working_tree" ? mode.workspaceFileDragScope : undefined;
const onCopyPath = mode.kind === "working_tree" ? mode.onCopyPath : undefined;
const onCopyRelativePath = mode.kind === "working_tree" ? mode.onCopyRelativePath : undefined;
const onReveal = mode.kind === "working_tree" ? mode.onReveal : undefined;
const revealTargetName = mode.kind === "working_tree" ? mode.revealTargetName : undefined;
const onDownload = mode.kind === "working_tree" ? mode.onDownload : undefined;
const onDuplicate = mode.kind === "working_tree" ? mode.onDuplicate : undefined;
const onRevert = mode.kind === "working_tree" ? mode.onRevert : undefined;
// Keep selection independent from expansion so future keyboard actions (such as R to rename)
// can target the current VCS file or folder without changing its open state.
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const handleSelectPath = useCallback((path: string) => setSelectedPath(path), []);
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]);
useEffect(() => {
if (
selectedPath &&
!allFolderPathSet.has(selectedPath) &&
!files.some((file) => file.path === selectedPath)
) {
setSelectedPath(null);
}
}, [allFolderPathSet, files, selectedPath]);
const effectiveCollapsedFolders = useMemo(
() => new Set(Array.from(collapsedFolders).filter((path) => allFolderPathSet.has(path))),
[allFolderPathSet, collapsedFolders],
@@ -2181,6 +2353,40 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
[computeItemOffset, effectiveCollapsedFolders, mode],
);
const handleCollapseFolder = useCallback(
(dirPath: string) => {
if (mode.kind !== "working_tree") {
return;
}
const targetOffset = computeItemOffset(
(item) => item.type === "folder" && item.dirPath === dirPath,
);
const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current;
if (
targetOffset !== null &&
shouldAnchorHeaderBeforeCollapse({
headerOffset: targetOffset,
headerHeight: folderHeight,
viewportOffset: diffListScrollOffsetRef.current,
viewportHeight: diffListViewportHeightRef.current,
})
) {
diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false });
}
const pathPrefix = `${dirPath}/`;
mode.onCollapsedFoldersChange([
...new Set([
...effectiveCollapsedFolders,
...allFolderPaths.filter(
(folderPath) => folderPath === dirPath || folderPath.startsWith(pathPrefix),
),
]),
]);
},
[allFolderPaths, computeItemOffset, effectiveCollapsedFolders, mode],
);
const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => {
if (item.type === "folder") {
@@ -2190,10 +2396,19 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
displayName={item.displayName}
depth={item.depth}
collapsed={item.collapsed}
isSelected={selectedPath === item.dirPath}
additions={item.additions}
deletions={item.deletions}
onToggle={handleToggleFolder}
onCollapse={handleCollapseFolder}
onSelect={handleSelectPath}
onHeightChange={handleFolderRowHeightChange}
onCopyPath={onCopyPath}
onCopyRelativePath={onCopyRelativePath}
onReveal={onReveal}
revealTargetName={revealTargetName}
onDuplicate={onDuplicate}
onRevert={onRevert}
testID={`diff-folder-${item.dirPath}`}
/>
);
@@ -2204,14 +2419,21 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
file={item.file}
workspaceFileDragScope={workspaceFileDragScope}
isExpanded={item.isExpanded}
isSelected={selectedPath === item.file.path}
depth={item.depth}
showDir={viewMode === "flat"}
interactive={interactive}
onToggle={interactive ? (onFilePress ?? handleToggleExpanded) : undefined}
onSelect={handleSelectPath}
onOpenFile={onOpenFile}
onAddToChat={onAddToChat}
onCopyPath={onCopyPath}
onCopyRelativePath={onCopyRelativePath}
onReveal={onReveal}
revealTargetName={revealTargetName}
onDownload={onDownload}
onDuplicate={onDuplicate}
onRevert={onRevert}
onHeaderHeightChange={handleHeaderHeightChange}
testID={`diff-file-${item.fileIndex}`}
/>
@@ -2235,6 +2457,8 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
handleBodyHeightChange,
handleFolderRowHeightChange,
handleHeaderHeightChange,
handleCollapseFolder,
handleSelectPath,
handleToggleExpanded,
handleToggleFolder,
layout,
@@ -2248,7 +2472,13 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
onOpenFile,
onAddToChat,
onCopyPath,
onCopyRelativePath,
onReveal,
revealTargetName,
onDownload,
onDuplicate,
onRevert,
selectedPath,
],
);
@@ -2659,6 +2889,11 @@ export function GitDiffPane({
const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []);
const toast = useToast();
const isLocalDaemon = useIsLocalDaemon(serverId);
const { targets: desktopOpenTargets } = useDesktopOpenTargets({
isLocalExecution: isLocalDaemon,
});
const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager");
const {
changesTabOpen,
toggleChanges: handleToggleChangesTab,
@@ -2668,6 +2903,11 @@ export function GitDiffPane({
const refreshSupported = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
);
const client = useSessionStore((state) => state.sessions[serverId]?.client);
// COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09.
const fsEntryDuplicateEnabled = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryDuplicate === true,
);
const runRefresh = useCheckoutGitActionsStore((s) => s.refresh);
const isRefreshing =
useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) ===
@@ -2773,12 +3013,51 @@ export function GitDiffPane({
},
[cwd],
);
const handleCopyRelativePath = useCallback((path: string) => {
void Clipboard.setStringAsync(path);
}, []);
const handleRevealPath = useCallback(
async (path: string) => {
if (!fileManagerTarget) {
return;
}
try {
await openDesktopTarget({
editorId: fileManagerTarget.id,
workspacePath: cwd,
filePath: buildAbsoluteExplorerPath({ workspaceRoot: cwd, entryPath: path }),
});
} catch (cause) {
toast.error(
cause instanceof Error ? cause.message : t("workspace.fileExplorer.errors.revealFailed"),
);
}
},
[cwd, fileManagerTarget, t, toast],
);
const handleDownloadPath = useCallback(
(path: string) => {
downloadFile({ fileName: path.split("/").pop() ?? path, path });
},
[downloadFile],
);
const handleDuplicatePath = useCallback(
async (path: string) => {
if (!client) {
return;
}
try {
const payload = await client.duplicateFileEntry({ cwd, path });
if (!payload.success) {
toast.error(payload.error ?? t("workspace.fileExplorer.errors.duplicateFailed"));
}
} catch (cause) {
toast.error(cause instanceof Error ? cause.message : String(cause));
}
},
[client, cwd, t, toast],
);
const onRevertPath = useDiscardChangesAction({ serverId, cwd, diffMode });
const workingTreeMode = useMemo(
() => ({
kind: "working_tree" as const,
@@ -2791,7 +3070,12 @@ export function GitDiffPane({
onOpenFile,
onAddToChat,
onCopyPath: handleCopyPath,
onCopyRelativePath: handleCopyRelativePath,
onReveal: fileManagerTarget ? handleRevealPath : undefined,
revealTargetName: fileManagerTarget?.label,
onDownload: handleDownloadPath,
onDuplicate: fsEntryDuplicateEnabled ? handleDuplicatePath : undefined,
onRevert: onRevertPath,
onExpandedPathsChange: changesTree.updateExpandedPaths,
onCollapsedFoldersChange: changesTree.updateCollapsedFolders,
}),
@@ -2806,7 +3090,13 @@ export function GitDiffPane({
onOpenFile,
onAddToChat,
handleCopyPath,
handleCopyRelativePath,
handleDownloadPath,
handleDuplicatePath,
handleRevealPath,
fileManagerTarget,
fsEntryDuplicateEnabled,
onRevertPath,
changesTree.updateExpandedPaths,
changesTree.updateCollapsedFolders,
],
@@ -2861,7 +3151,12 @@ export function GitDiffPane({
);
return (
<View style={styles.container}>
<View
{...{
onContextMenu: (event: { preventDefault?: () => void }) => event.preventDefault?.(),
}}
style={styles.container}
>
{isGit && (currentBranchName || isMobile) ? (
<View style={styles.header} testID="changes-header">
<BranchSwitcher
@@ -3170,6 +3465,11 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
flexShrink: 0,
},
fileChevronSpacer: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
flexShrink: 0,
},
fileIcon: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
@@ -3183,6 +3483,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
flexShrink: 1,
minWidth: 0,
userSelect: "none",
},
fileDir: {
fontSize: theme.fontSize.sm,
@@ -3190,6 +3491,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
flex: 1,
minWidth: 0,
userSelect: "none",
},
fileDirSpacer: {
flex: 1,