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
+3 -2
View File
@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
interface DiffStatProps {
additions: number;
deletions: number;
testID?: string;
}
const compactFormatter = new Intl.NumberFormat("en-US", {
@@ -15,9 +16,9 @@ export function formatDiffCount(value: number): string {
return compactFormatter.format(value).toLowerCase();
}
export function DiffStat({ additions, deletions }: DiffStatProps) {
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
return (
<View style={styles.row}>
<View style={styles.row} testID={testID}>
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
</View>
@@ -36,6 +36,13 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
import { useDraftStore } from "@/stores/draft-store";
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
@@ -386,15 +393,16 @@ function ExplorerSidebarContent({
{/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area">
{resolvedTab === "changes" && (
<GitDiffPane
<ChangedFilesPane
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
enabled={isOpen}
workspaceRoot={workspaceRoot}
isOpen={isOpen}
onOpenFile={onOpenFile}
/>
)}
{resolvedTab === "files" && (
<FileExplorerPane
<FilesPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
@@ -415,6 +423,83 @@ function ExplorerSidebarContent({
);
}
/**
* Shared add-to-chat state for the changes/files panes: both expose an "add file
* to chat" action that attaches the file to the focused chat's composer.
* Available only when a workspace with a focused chat is available.
*/
function useAddFileToChat({
serverId,
workspaceId,
}: Pick<SidebarContentProps, "serverId" | "workspaceId">) {
const workspaceKey = workspaceId
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
: null;
const layout = useWorkspaceLayoutStore((state) =>
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
);
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
const focusedChat = useMemo(
() => resolveFocusedChatTarget({ serverId, layout }),
[serverId, layout],
);
const addFile = useCallback(
(filePath: string) => {
if (!focusedChat || !workspaceKey) {
return;
}
void useDraftStore.getState().attachWorkspaceFile({
draftKey: focusedChat.draftKey,
attachment: createWorkspaceFileAttachment({ path: filePath }),
});
focusTab(workspaceKey, focusedChat.tabId);
},
[focusTab, focusedChat, workspaceKey],
);
return { addFile, canAddToChat: focusedChat !== null };
}
function ChangedFilesPane({
serverId,
workspaceId,
workspaceRoot,
isOpen,
onOpenFile,
}: Pick<
SidebarContentProps,
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
>) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return (
<GitDiffPane
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
enabled={isOpen}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
function FilesPane({
serverId,
workspaceId,
workspaceRoot,
onOpenFile,
}: Pick<SidebarContentProps, "serverId" | "workspaceId" | "workspaceRoot" | "onOpenFile">) {
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
return (
<FileExplorerPane
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
onOpenFile={onOpenFile}
onAddToChat={canAddToChat ? addFile : undefined}
/>
);
}
interface PrTabContentProps {
serverId: string;
cwd: string;
@@ -0,0 +1,185 @@
import { useMemo, type ReactElement, type ReactNode } from "react";
import { type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
Copy,
Download,
FileText,
MessageSquarePlus,
MoreVertical,
type LucideIcon,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedMoreVertical = withUnistyles(MoreVertical);
/** Width occupied by a file action trigger, including its visual padding. */
export const FILE_ACTIONS_MENU_WIDTH = ICON_SIZE.sm + 2 * SPACING[1];
interface FileAction {
key: string;
label: string;
icon: LucideIcon;
onSelect: () => void;
testID?: string;
}
interface FileActionsMenuProps {
fileKind: "file" | "directory";
fileExists?: boolean;
onOpenFile?: () => void;
onCopyPath?: () => void;
onDownload?: () => void;
onAddToChat?: () => void;
/** Optional metadata block rendered above the actions (e.g. size/modified). */
header?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
hitSlop?: number;
accessibilityLabel: string;
testIDPrefix?: string;
}
// The menu lives inside pressable rows (diff header, explorer entry); stop the
// press so opening it doesn't also trigger the row.
function stopTriggerPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function triggerStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [styles.trigger, (Boolean(hovered) || pressed || Boolean(open)) && styles.triggerActive];
}
/**
* Shared kebab (⋮) menu for per-file actions. Used by the file explorer tree and
* git diff pane so both surfaces share action availability, ordering, and chrome.
*/
export function FileActionsMenu({
fileKind,
fileExists = true,
onOpenFile,
onCopyPath,
onDownload,
onAddToChat,
header,
open,
onOpenChange,
hitSlop = 12,
accessibilityLabel,
testIDPrefix,
}: FileActionsMenuProps): ReactElement | null {
const { t } = useTranslation();
const actions = useMemo<FileAction[]>(() => {
const availableFile = fileKind === "file" && fileExists;
const next: FileAction[] = [];
if (availableFile && onOpenFile) {
next.push({
key: "open-file",
label: t("workspace.fileActions.openFile"),
icon: FileText,
onSelect: onOpenFile,
testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined,
});
}
if (onCopyPath) {
next.push({
key: "copy-path",
label: t("workspace.fileActions.copyPath"),
icon: Copy,
onSelect: onCopyPath,
});
}
if (availableFile && onDownload) {
next.push({
key: "download",
label: t("workspace.fileActions.download"),
icon: Download,
onSelect: onDownload,
});
}
if (availableFile && onAddToChat) {
next.push({
key: "add-to-chat",
label: t("workspace.fileActions.addToChat"),
icon: MessageSquarePlus,
onSelect: onAddToChat,
testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined,
});
}
return next;
}, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]);
if (actions.length === 0) {
return null;
}
return (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger
hitSlop={hitSlop}
onPressIn={stopTriggerPropagation}
style={triggerStyle}
accessibilityLabel={accessibilityLabel}
testID={testIDPrefix ? `${testIDPrefix}-actions` : undefined}
>
<ThemedMoreVertical size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
{header ? (
<>
{header}
<DropdownMenuSeparator />
</>
) : null}
{actions.map((action) => (
<FileActionMenuItem key={action.key} action={action} />
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
const Icon = action.icon;
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
const leading = useMemo(
() => <ThemedIcon size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />,
[ThemedIcon],
);
return (
<DropdownMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
{action.label}
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
// The hover box comes from padding, but an equal negative vertical margin
// cancels its height contribution so the trigger overlaps the row's natural
// line height instead of growing it. The comfortable tap target is `hitSlop`,
// never padding.
padding: theme.spacing[1],
width: FILE_ACTIONS_MENU_WIDTH,
marginVertical: -theme.spacing[1],
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
triggerActive: {
backgroundColor: theme.colors.surface2,
},
}));
@@ -1,4 +1,5 @@
import type { ImageAttachment } from "@/composer/types";
import type { WorkspaceFileDragPayload } from "@/attachments/workspace-file-drag";
export interface DroppedFileItem {
kind: "web-file";
@@ -18,4 +19,5 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
export interface FileDropSink {
onFiles: (images: ImageAttachment[]) => void;
onGenericFiles?: (items: DroppedItem[]) => void;
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
}
@@ -11,6 +11,10 @@ import {
} from "@/attachments/file-types";
import { isWeb } from "@/constants/platform";
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
import {
parseWorkspaceFileDragPayload,
WORKSPACE_FILE_DRAG_MIME,
} from "@/attachments/workspace-file-drag";
type DesktopDragDropPayload =
| { type: "enter"; paths: string[] }
@@ -185,7 +189,11 @@ export function useDropListeners({
if (disabledRef.current) return;
dragCounter.current++;
if (e.dataTransfer?.types.includes("Files")) {
if (suppressed.value || !hasSink.value) return;
const types = new Set(e.dataTransfer?.types ?? []);
const acceptsWorkspaceFile =
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
if (types.has("Files") || acceptsWorkspaceFile) {
isDragging.value = true;
}
}
@@ -197,7 +205,11 @@ export function useDropListeners({
if (!e.dataTransfer) return;
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
const canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
const types = new Set(e.dataTransfer.types);
const acceptsWorkspaceFile =
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
const acceptsDrop = types.has("Files") || acceptsWorkspaceFile;
const canAccept = acceptsDrop && !disabledRef.current && !suppressed.value && hasSink.value;
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
}
@@ -225,6 +237,14 @@ export function useDropListeners({
const sink = getSink();
if (!sink) return;
const serializedWorkspaceFile = e.dataTransfer?.getData(WORKSPACE_FILE_DRAG_MIME);
if (serializedWorkspaceFile && sink.onWorkspaceFile) {
const payload = parseWorkspaceFileDragPayload(serializedWorkspaceFile);
if (payload) {
sink.onWorkspaceFile(payload);
}
}
const files = Array.from(e.dataTransfer?.files ?? []);
const genericItems: DroppedItem[] = files.map((file) => ({
kind: "web-file",
+110 -155
View File
@@ -15,35 +15,26 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import * as Clipboard from "expo-clipboard";
import {
ChevronDown,
Copy,
Download,
Eye,
EyeOff,
MoreVertical,
RotateCw,
} from "lucide-react-native";
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
import {
TreeChevron,
TreeIndentGuides,
treeRowPaddingLeft,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { FileActionsMenu } from "@/components/file-actions-menu";
import { useFileDownload } from "@/hooks/use-file-download";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { usePanelStore, type SortOption } from "@/stores/panel-store";
import { formatTimeAgo } from "@/utils/time";
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
const SORT_OPTIONS: { value: SortOption }[] = [
{ value: "name" },
@@ -62,6 +53,8 @@ function formatFileSize({ size }: { size: number }): string {
}
interface TreeRowItemProps {
serverId: string;
workspaceId?: string | null;
entry: ExplorerEntry;
depth: number;
isExpanded: boolean;
@@ -70,21 +63,8 @@ interface TreeRowItemProps {
onEntryPress: (entry: ExplorerEntry) => void;
onCopyPath: (path: string) => void;
onDownloadEntry: (entry: ExplorerEntry) => void;
}
function stopPressInPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function menuButtonStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [
styles.menuButton,
(Boolean(hovered) || pressed || Boolean(open)) && styles.menuButtonActive,
];
onAddToChat?: (path: string) => void;
testID?: string;
}
function sortTriggerStyle({
@@ -103,6 +83,8 @@ function treeRowKeyExtractor(row: TreeRow) {
}
function TreeRowItem({
serverId,
workspaceId,
entry,
depth,
isExpanded,
@@ -111,10 +93,17 @@ function TreeRowItem({
onEntryPress,
onCopyPath,
onDownloadEntry,
onAddToChat,
testID,
}: TreeRowItemProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isDirectory = entry.kind === "directory";
const dragSourceRef = useWorkspaceFileDragSource({
enabled: !isDirectory,
serverId,
workspaceId,
path: entry.path,
});
const handlePress = useCallback(() => {
onEntryPress(entry);
@@ -123,10 +112,10 @@ function TreeRowItem({
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.entryRow,
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
{ paddingLeft: treeRowPaddingLeft(depth) },
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
],
[depth, isSelected, theme.spacing],
[depth, isSelected],
);
const handleCopy = useCallback(() => {
@@ -137,19 +126,38 @@ function TreeRowItem({
onDownloadEntry(entry);
}, [onDownloadEntry, entry]);
const copyLeading = useMemo(
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const downloadLeading = useMemo(
() => <Download size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
const handleAddToChat = useCallback(() => {
onAddToChat?.(entry.path);
}, [onAddToChat, entry.path]);
const metaHeader = useMemo(
() => (
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
),
[entry.modifiedAt, entry.size, t],
);
return (
<Pressable onPress={handlePress} style={pressableStyle}>
<Pressable onPress={handlePress} style={pressableStyle} testID={testID}>
<TreeIndentGuides depth={depth} />
<View style={styles.entryInfo}>
<View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryIcon}>
{(() => {
if (!isDirectory) {
@@ -163,40 +171,15 @@ function TreeRowItem({
{entry.name}
</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger hitSlop={8} onPressIn={stopPressInPropagation} style={menuButtonStyle}>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.size")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
{t("workspace.fileExplorer.context.modified")}
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
<DropdownMenuSeparator />
<DropdownMenuItem leading={copyLeading} onSelect={handleCopy}>
{t("workspace.fileExplorer.context.copyPath")}
</DropdownMenuItem>
{entry.kind === "file" ? (
<DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}>
{t("workspace.fileExplorer.context.download")}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
<FileActionsMenu
fileKind={entry.kind}
onCopyPath={handleCopy}
onDownload={handleDownload}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
header={metaHeader}
accessibilityLabel={t("workspace.fileActions.moreActions")}
testIDPrefix={testID}
/>
</Pressable>
);
}
@@ -206,6 +189,7 @@ interface FileExplorerPaneProps {
workspaceId?: string | null;
workspaceRoot: string;
onOpenFile?: (filePath: string) => void;
onAddToChat?: (path: string) => void;
}
interface TreeRow {
@@ -218,14 +202,10 @@ export function FileExplorerPane({
workspaceId,
workspaceRoot,
onOpenFile,
onAddToChat,
}: FileExplorerPaneProps) {
const { t } = useTranslation();
const daemons = useHosts();
const daemonProfile = useMemo(
() => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId],
);
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const workspaceStateKey = useMemo(
() =>
@@ -235,10 +215,6 @@ export function FileExplorerPane({
}),
[normalizedWorkspaceRoot, workspaceId],
);
const workspaceScopeId = useMemo(
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId],
);
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
const explorerState = useSessionStore((state) =>
workspaceStateKey && state.sessions[serverId]
@@ -246,12 +222,16 @@ export function FileExplorerPane({
: undefined,
);
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const downloadFile = useFileDownload({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const sortOption = usePanelStore((state) => state.explorerSortOption);
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
@@ -346,18 +326,14 @@ export function FileExplorerPane({
[normalizedWorkspaceRoot],
);
const startDownload = useDownloadStore((state) => state.startDownload);
const handleDownloadEntry = useCallback(
(entry: ExplorerEntry) =>
downloadExplorerEntry({
entry,
workspaceScopeId,
serverId,
daemonProfile,
startDownload,
requestFileDownloadToken,
}),
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
(entry: ExplorerEntry) => {
if (entry.kind !== "file") {
return;
}
downloadFile({ fileName: entry.name, path: entry.path });
},
[downloadFile],
);
const handleSortCycle = useCallback(() => {
@@ -419,6 +395,8 @@ export function FileExplorerPane({
const renderTreeRow = useCallback(
(info: ListRenderItemInfo<TreeRow>) => (
<TreeRowDispatcher
serverId={serverId}
workspaceId={workspaceId}
info={info}
expandedPaths={expandedPaths}
selectedEntryPath={selectedEntryPath}
@@ -426,6 +404,7 @@ export function FileExplorerPane({
onEntryPress={handleEntryPress}
onCopyPath={handleCopyPath}
onDownloadEntry={handleDownloadEntry}
onAddToChat={onAddToChat}
/>
),
[
@@ -435,6 +414,9 @@ export function FileExplorerPane({
handleDownloadEntry,
isDirectoryLoading,
selectedEntryPath,
onAddToChat,
serverId,
workspaceId,
],
);
@@ -576,8 +558,14 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<View style={[styles.treePane, styles.treePaneFill]}>
<View style={styles.paneHeader} testID="files-pane-header">
<Pressable onPress={handleSortCycle} style={sortTriggerStyleProp}>
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
<Pressable
onPress={handleSortCycle}
style={sortTriggerStyleProp}
testID="files-sort-trigger"
>
<Text style={styles.sortTriggerText} testID="files-sort-label">
{currentSortLabel}
</Text>
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.headerActions}>
@@ -588,6 +576,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
accessibilityRole="button"
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
accessibilityState={hiddenFilesToggleAccessibilityState}
testID="files-hidden-toggle"
>
{showHiddenFiles ? (
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
@@ -606,6 +595,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
? t("workspace.fileExplorer.actions.refreshing")
: t("workspace.fileExplorer.actions.refresh")
}
testID="files-refresh"
>
<View style={styles.refreshIcon}>
{isRefreshFetching ? (
@@ -778,39 +768,6 @@ function resolveTreeRows({
});
}
type StartDownloadFn = ReturnType<typeof useDownloadStore.getState>["startDownload"];
type StartDownloadParams = Parameters<StartDownloadFn>[0];
function downloadExplorerEntry({
entry,
workspaceScopeId,
serverId,
daemonProfile,
startDownload,
requestFileDownloadToken,
}: {
entry: ExplorerEntry;
workspaceScopeId: string | undefined;
serverId: string;
daemonProfile: StartDownloadParams["daemonProfile"];
startDownload: StartDownloadFn;
requestFileDownloadToken: (
targetPath: string,
) => ReturnType<StartDownloadParams["requestFileDownloadToken"]>;
}): void {
if (!workspaceScopeId || entry.kind !== "file") {
return;
}
startDownload({
serverId,
scopeId: workspaceScopeId,
fileName: entry.name,
path: entry.path,
daemonProfile,
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
});
}
function toggleDirectory({
entry,
workspaceStateKey,
@@ -850,6 +807,8 @@ function toggleDirectory({
}
function TreeRowDispatcher({
serverId,
workspaceId,
info,
expandedPaths,
selectedEntryPath,
@@ -857,7 +816,10 @@ function TreeRowDispatcher({
onEntryPress,
onCopyPath,
onDownloadEntry,
onAddToChat,
}: {
serverId: string;
workspaceId?: string | null;
info: ListRenderItemInfo<TreeRow>;
expandedPaths: Set<string>;
selectedEntryPath: string | null;
@@ -865,6 +827,7 @@ function TreeRowDispatcher({
onEntryPress: (entry: ExplorerEntry) => void;
onCopyPath: (path: string) => void | Promise<void>;
onDownloadEntry: (entry: ExplorerEntry) => void;
onAddToChat?: (path: string) => void;
}) {
const entry = info.item.entry;
const depth = info.item.depth;
@@ -875,6 +838,8 @@ function TreeRowDispatcher({
return (
<TreeRowItem
serverId={serverId}
workspaceId={workspaceId}
entry={entry}
depth={depth}
isExpanded={isExpanded}
@@ -883,6 +848,8 @@ function TreeRowDispatcher({
onEntryPress={onEntryPress}
onCopyPath={onCopyPath}
onDownloadEntry={onDownloadEntry}
onAddToChat={onAddToChat}
testID={`file-explorer-row-${info.index}`}
/>
);
}
@@ -1061,7 +1028,6 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
entriesContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
@@ -1111,9 +1077,8 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 2,
paddingRight: theme.spacing[2],
borderRadius: theme.borderRadius.md,
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
paddingRight: theme.spacing[3],
},
entryRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
@@ -1133,16 +1098,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
menuButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
menuButtonActive: {
backgroundColor: theme.colors.surface2,
},
contextMetaBlock: {
paddingVertical: theme.spacing[1],
},
@@ -11,6 +11,7 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
// indentation, guide lines, and chevron. Keep those here so the two trees can't
// drift apart.
export const TREE_INDENT_PER_LEVEL = 16;
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
* in the Changes tree so their indentation can't drift apart. */