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:
co-authored by
Mohamed Boudra
parent
42ee5a5949
commit
bd937850b3
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user