feat: add workspace files to chat from Files and Changes

* feat(app): open changed files from context menu

* fix(app): preserve changed-file row behavior

* feat(app): add changed files to open chats

* feat(app): share file actions across explorer and changes

* feat(app): attach workspace files to focused chat

* feat(app): drag workspace files into chat

* refactor(app): use file actions menu for changed files

* fix(app): open changed-file actions on right click

* test(app): cover direct file attachment flow

* fix(app): enable add to chat on native

* fix(app): align workspace file pane rows

* refactor(app): simplify workspace file attachments

* fix(app): preserve uploaded file draft attachments

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
nikuscs
2026-07-22 15:58:24 +02:00
committed by GitHub
co-authored by Mohamed Boudra
parent 42ee5a5949
commit bd937850b3
42 changed files with 1665 additions and 342 deletions
+19 -4
View File
@@ -8,7 +8,13 @@ import {
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DiffStat } from "@/components/diff-stat";
import { TreeChevron, TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
import { FILE_ACTIONS_MENU_WIDTH } from "@/components/file-actions-menu";
import {
TreeChevron,
TreeIndentGuides,
treeRowPaddingLeft,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives";
import { type Theme } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
@@ -80,7 +86,12 @@ export function DiffFolderRow({
</Text>
</View>
<View style={styles.right}>
<DiffStat additions={additions} deletions={deletions} />
<DiffStat
additions={additions}
deletions={deletions}
testID={testID ? `${testID}-stat` : undefined}
/>
<View style={styles.actionSlot} />
</View>
</Pressable>
</View>
@@ -94,8 +105,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
folderRow: {
flexDirection: "row",
alignItems: "center",
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
gap: theme.spacing[1],
minWidth: 0,
},
@@ -113,6 +124,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexDirection: "row",
alignItems: "center",
flexShrink: 0,
gap: theme.spacing[1],
},
actionSlot: {
width: FILE_ACTIONS_MENU_WIDTH,
},
folderName: {
fontSize: theme.fontSize.sm,
+177 -31
View File
@@ -57,7 +57,11 @@ import {
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "@/git/diff-flat-items";
import { buildDiffTree, collectDirPaths, compressSingleChildChains } from "@/git/diff-tree";
import { DiffFolderRow } from "@/git/diff-folder-row";
import { TreeIndentGuides, treeRowPaddingLeft } from "@/components/tree-primitives";
import {
TreeIndentGuides,
treeRowPaddingLeft,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives";
import { SvgXml } from "react-native-svg";
import { getFileIconSvg } from "@/components/material-file-icons";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
@@ -83,6 +87,10 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import * as Clipboard from "expo-clipboard";
import { FILE_ACTIONS_MENU_WIDTH, FileActionsMenu } from "@/components/file-actions-menu";
import { useFileDownload } from "@/hooks/use-file-download";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { GitActionsSplitButton } from "@/git/actions-split-button";
@@ -106,6 +114,7 @@ import {
hasVisibleDiffTokens,
} from "@/utils/diff-rendering";
import { isWeb, isNative } from "@/constants/platform";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
import {
buildWorkspaceAttachmentScopeKey,
useWorkspaceAttachmentsStore,
@@ -202,6 +211,7 @@ function HighlightedText({
interface DiffFileSectionProps {
file: ParsedDiffFile;
workspaceFileDragScope?: { serverId: string; workspaceId: string };
isExpanded: boolean;
/** Tree indentation level (0 on the flat/mobile path). */
depth?: number;
@@ -209,6 +219,10 @@ interface DiffFileSectionProps {
showDir?: boolean;
interactive?: boolean;
onToggle?: (path: string) => void;
onOpenFile?: (path: string) => void;
onAddToChat?: (path: string) => void;
onCopyPath?: (path: string) => void;
onDownload?: (path: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void;
testID?: string;
}
@@ -905,18 +919,31 @@ function SplitDiffColumn({
const DiffFileHeader = memo(function DiffFileHeader({
file,
workspaceFileDragScope,
isExpanded,
depth = 0,
showDir = true,
interactive = true,
onToggle,
onOpenFile,
onAddToChat,
onCopyPath,
onDownload,
onHeaderHeightChange,
testID,
}: DiffFileSectionProps) {
const { t } = useTranslation();
const dragSourceRef = useWorkspaceFileDragSource({
enabled: interactive,
disabled: file.isDeleted,
workspaceId: null,
path: file.path,
...workspaceFileDragScope,
});
const layoutYRef = useRef<number | null>(null);
const pressHandledRef = useRef(false);
const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null);
const [isActionsOpen, setIsActionsOpen] = useState(false);
const toggleExpanded = useCallback(() => {
if (!interactive) {
@@ -926,6 +953,31 @@ const DiffFileHeader = memo(function DiffFileHeader({
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]);
const handleContextMenu = useCallback(
(event: { preventDefault: () => void; stopPropagation: () => void }) => {
event.preventDefault();
event.stopPropagation();
setIsActionsOpen(true);
},
[],
);
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
layoutYRef.current = event.nativeEvent.layout.y;
@@ -983,7 +1035,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
const fileName = file.path.split("/").pop() ?? file.path;
const headerContent = (
<>
<View style={styles.fileHeaderLeft}>
<View ref={dragSourceRef} style={styles.fileHeaderLeft}>
{showDir ? null : (
<View style={styles.fileIcon}>
<SvgXml xml={getFileIconSvg(fileName)} width={16} height={16} />
@@ -1013,38 +1065,55 @@ const DiffFileHeader = memo(function DiffFileHeader({
)}
</View>
<View style={styles.fileHeaderRight}>
<DiffStat additions={file.additions} deletions={file.deletions} />
<DiffStat
additions={file.additions}
deletions={file.deletions}
testID={testID ? `${testID}-stat` : undefined}
/>
{interactive ? (
<FileActionsMenu
fileKind="file"
fileExists={!file.isDeleted}
onOpenFile={onOpenFile ? handleOpenFile : undefined}
onCopyPath={onCopyPath ? handleCopyPath : undefined}
onDownload={onDownload ? handleDownload : undefined}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
open={isActionsOpen}
onOpenChange={setIsActionsOpen}
accessibilityLabel={t("workspace.fileActions.moreActions")}
testIDPrefix={testID}
/>
) : null}
</View>
</>
);
let trigger: ReactElement;
if (!interactive) {
trigger = (
<View style={headerPressableStyle({ hovered: false, pressed: false })}>{headerContent}</View>
);
} else {
trigger = (
<Pressable
testID={testID ? `${testID}-toggle` : undefined}
style={headerPressableStyle}
// Android: prevent parent pan/scroll gestures from canceling the tap release.
cancelable={false}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={toggleExpanded}
// @ts-ignore - onContextMenu is web-only and not in RN types.
onContextMenu={handleContextMenu}
>
{headerContent}
</Pressable>
);
}
return (
<View style={containerStyle} onLayout={handleLayout} testID={testID}>
<TreeIndentGuides depth={depth} />
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
{interactive ? (
<Pressable
testID={testID ? `${testID}-toggle` : undefined}
style={headerPressableStyle}
// Android: prevent parent pan/scroll gestures from canceling the tap release.
cancelable={false}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={toggleExpanded}
>
{headerContent}
</Pressable>
) : (
<View style={headerPressableStyle({ hovered: false, pressed: false })}>
{headerContent}
</View>
)}
</TooltipTrigger>
<TooltipContent side="bottom" align="start" offset={6} maxWidth={520}>
<Text style={styles.tooltipText}>{file.path}</Text>
</TooltipContent>
</Tooltip>
{trigger}
</View>
);
});
@@ -1242,6 +1311,8 @@ interface GitDiffPaneProps {
workspaceId?: string | null;
cwd: string;
enabled?: boolean;
onOpenFile?: (path: string) => void;
onAddToChat?: (path: string) => void;
}
type PressableStyleFn = (
@@ -1639,6 +1710,11 @@ interface SharedDiffViewProps {
expandedPaths: string[];
collapsedFolders: string[];
reviewActions?: InlineReviewActions;
workspaceFileDragScope?: { serverId: string; workspaceId: string };
onOpenFile?: (path: string) => void;
onAddToChat?: (path: string) => void;
onCopyPath?: (path: string) => void;
onDownload?: (path: string) => void;
onExpandedPathsChange: (paths: string[]) => void;
onCollapsedFoldersChange: (paths: string[]) => void;
}
@@ -1671,6 +1747,12 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
const stickyHeaders = mode.kind === "working_tree";
const interactive = mode.kind === "working_tree";
const reviewActions = mode.kind === "working_tree" ? mode.reviewActions : undefined;
const onOpenFile = mode.kind === "working_tree" ? mode.onOpenFile : undefined;
const onAddToChat = mode.kind === "working_tree" ? mode.onAddToChat : undefined;
const workspaceFileDragScope =
mode.kind === "working_tree" ? mode.workspaceFileDragScope : undefined;
const onCopyPath = mode.kind === "working_tree" ? mode.onCopyPath : undefined;
const onDownload = mode.kind === "working_tree" ? mode.onDownload : undefined;
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]);
@@ -1917,11 +1999,16 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
return (
<DiffFileHeader
file={item.file}
workspaceFileDragScope={workspaceFileDragScope}
isExpanded={item.isExpanded}
depth={item.depth}
showDir={viewMode === "flat"}
interactive={interactive}
onToggle={interactive ? handleToggleExpanded : undefined}
onOpenFile={onOpenFile}
onAddToChat={onAddToChat}
onCopyPath={onCopyPath}
onDownload={onDownload}
onHeaderHeightChange={handleHeaderHeightChange}
testID={`diff-file-${item.fileIndex}`}
/>
@@ -1949,10 +2036,15 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
handleToggleFolder,
layout,
reviewActions,
workspaceFileDragScope,
textMetricsStyle,
viewMode,
wrapLines,
interactive,
onOpenFile,
onAddToChat,
onCopyPath,
onDownload,
],
);
@@ -1982,6 +2074,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
viewMode,
wrapLines,
reviewActions,
workspaceFileDragScope,
}),
[
expandedPathsArray,
@@ -1991,6 +2084,7 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
reviewActions,
typographyKey,
viewMode,
workspaceFileDragScope,
wrapLines,
],
);
@@ -2160,6 +2254,13 @@ function buildExpandAllButtonStyle(): PressableStyleFn {
];
}
function buildOverflowButtonStyle(): PressableStyleFn {
return ({ hovered, pressed }) => [
styles.overflowButton,
(Boolean(hovered) || pressed) && styles.toggleButtonSelected,
];
}
function buildToggleButtonStyle(
selected: boolean,
baseStyles: StyleProp<ViewStyle> | StyleProp<ViewStyle>[],
@@ -2174,7 +2275,14 @@ function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean
return input.paneEnabled && input.isGit;
}
export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPaneProps) {
export function GitDiffPane({
serverId,
workspaceId,
cwd,
enabled,
onOpenFile,
onAddToChat,
}: GitDiffPaneProps) {
const { settings: appSettings } = useAppSettings();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
@@ -2214,7 +2322,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const overflowToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []);
const toast = useToast();
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
@@ -2461,6 +2569,21 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
},
[setDiffCollapsedFoldersForWorkspace, workspaceStateKey],
);
const downloadFile = useFileDownload({ serverId, workspaceId, workspaceRoot: cwd });
const handleCopyPath = useCallback(
(path: string) => {
void Clipboard.setStringAsync(
buildAbsoluteExplorerPath({ workspaceRoot: cwd, entryPath: path }),
);
},
[cwd],
);
const handleDownloadPath = useCallback(
(path: string) => {
downloadFile({ fileName: path.split("/").pop() ?? path, path });
},
[downloadFile],
);
const workingTreeMode = useMemo(
() => ({
kind: "working_tree" as const,
@@ -2468,6 +2591,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
expandedPaths: stableExpandedPathsArray,
collapsedFolders: stableCollapsedFoldersArray,
reviewActions,
workspaceFileDragScope: workspaceId ? { serverId, workspaceId } : undefined,
onOpenFile,
onAddToChat,
onCopyPath: handleCopyPath,
onDownload: handleDownloadPath,
onExpandedPathsChange: handleExpandedPathsChange,
onCollapsedFoldersChange: handleCollapsedFoldersChange,
}),
@@ -2476,6 +2604,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
stableExpandedPathsArray,
stableCollapsedFoldersArray,
reviewActions,
serverId,
workspaceId,
onOpenFile,
onAddToChat,
handleCopyPath,
handleDownloadPath,
handleExpandedPathsChange,
handleCollapsedFoldersChange,
],
@@ -2742,6 +2876,18 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.base,
flexShrink: 0,
},
overflowButton: {
width: FILE_ACTIONS_MENU_WIDTH,
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],
@@ -2830,8 +2976,8 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
paddingLeft: theme.spacing[3],
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
gap: theme.spacing[1],
minWidth: 0,
zIndex: 2,