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
+195 -47
View File
@@ -1,6 +1,20 @@
import { useMemo, type ReactElement, type ReactNode } from "react";
import { Fragment, useMemo, type ReactElement, type ReactNode } from "react";
import { withUnistyles } from "react-native-unistyles";
import { Copy, Download, FileText, MessageSquarePlus, type LucideIcon } from "lucide-react-native";
import {
Copy,
CopyPlus,
Download,
FilePlus,
FileText,
FolderMinus,
FolderOpen,
FolderPlus,
MessageSquarePlus,
Pencil,
Trash2,
Undo2,
type LucideIcon,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
@@ -10,11 +24,14 @@ import {
} from "@/components/ui/context-menu";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
interface FileAction {
key: string;
label: string;
icon: LucideIcon;
onSelect: () => void;
destructive?: boolean;
separatorBefore?: boolean;
testID?: string;
}
@@ -23,8 +40,18 @@ interface FileActionsContextMenuContentProps {
fileExists?: boolean;
onOpenFile?: () => void;
onCopyPath?: () => void;
onCopyRelativePath?: () => void;
onReveal?: () => void;
revealTargetName?: string;
onDownload?: () => void;
onAddToChat?: () => void;
onNewFile?: () => void;
onNewFolder?: () => void;
onCollapseFolder?: () => void;
onRename?: () => void;
onDuplicate?: () => void;
onRevert?: () => void;
onDelete?: () => void;
/** Optional metadata block rendered above the actions (e.g. size/modified). */
header?: ReactNode;
testIDPrefix?: string;
@@ -39,51 +66,161 @@ export function FileActionsContextMenuContent({
fileExists = true,
onOpenFile,
onCopyPath,
onCopyRelativePath,
onReveal,
revealTargetName,
onDownload,
onAddToChat,
onNewFile,
onNewFolder,
onCollapseFolder,
onRename,
onDuplicate,
onRevert,
onDelete,
header,
testIDPrefix,
}: FileActionsContextMenuContentProps): 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]);
const specs: Array<FileAction | null> = [
onNewFile
? {
key: "new-file",
label: t("workspace.fileActions.newFile"),
icon: FilePlus,
onSelect: onNewFile,
}
: null,
onNewFolder
? {
key: "new-folder",
label: t("workspace.fileActions.newFolder"),
icon: FolderPlus,
onSelect: onNewFolder,
}
: null,
onCollapseFolder
? {
key: "collapse-folder",
label: t("workspace.fileActions.collapseFolder"),
icon: FolderMinus,
onSelect: onCollapseFolder,
}
: null,
availableFile && onOpenFile
? {
key: "open-file",
label: t("workspace.fileActions.openFile"),
icon: FileText,
onSelect: onOpenFile,
}
: null,
onRename
? {
key: "rename",
label: t("workspace.fileActions.rename"),
icon: Pencil,
onSelect: onRename,
}
: null,
onDuplicate
? {
key: "duplicate",
label: t("workspace.fileActions.duplicate"),
icon: CopyPlus,
onSelect: onDuplicate,
}
: null,
onCopyPath
? {
key: "copy-path",
label: t("workspace.fileActions.copyPath"),
icon: Copy,
onSelect: onCopyPath,
}
: null,
onCopyRelativePath
? {
key: "copy-relative-path",
label: t("workspace.fileActions.copyRelativePath"),
icon: Copy,
onSelect: onCopyRelativePath,
}
: null,
onReveal && revealTargetName
? {
key: "reveal",
label: t("workspace.fileActions.revealIn", { target: revealTargetName }),
icon: FolderOpen,
onSelect: onReveal,
}
: null,
availableFile && onDownload
? {
key: "download",
label: t("workspace.fileActions.download"),
icon: Download,
onSelect: onDownload,
}
: null,
availableFile && onAddToChat
? {
key: "add-to-chat",
label: t("workspace.fileActions.addToChat"),
icon: MessageSquarePlus,
onSelect: onAddToChat,
}
: null,
onRevert
? {
key: "revert",
label: t("workspace.fileActions.revert"),
icon: Undo2,
onSelect: onRevert,
destructive: true,
}
: null,
onDelete
? {
key: "delete",
label: t("workspace.fileActions.delete"),
icon: Trash2,
onSelect: onDelete,
destructive: true,
}
: null,
];
const availableActions = specs.filter((action): action is FileAction => action !== null);
return availableActions.map((action, index) =>
Object.assign(action, {
separatorBefore: Boolean(
action.destructive && index > 0 && !availableActions[index - 1].destructive,
),
testID: testIDPrefix ? `${testIDPrefix}-${action.key}` : undefined,
}),
);
}, [
fileExists,
fileKind,
onAddToChat,
onCollapseFolder,
onCopyPath,
onCopyRelativePath,
onDelete,
onDownload,
onDuplicate,
onNewFile,
onNewFolder,
onOpenFile,
onRename,
onReveal,
onRevert,
revealTargetName,
t,
testIDPrefix,
]);
if (actions.length === 0) {
return null;
@@ -101,21 +238,32 @@ export function FileActionsContextMenuContent({
</>
) : null}
{actions.map((action) => (
<FileActionMenuItem key={action.key} action={action} />
<Fragment key={action.key}>
{action.separatorBefore ? <ContextMenuSeparator /> : null}
<FileActionMenuItem action={action} />
</Fragment>
))}
</ContextMenuContent>
);
}
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],
);
const leading = useMemo(() => {
const ThemedIcon = withUnistyles(action.icon);
return (
<ThemedIcon
size={ICON_SIZE.sm}
uniProps={action.destructive ? destructiveColorMapping : foregroundMutedColorMapping}
/>
);
}, [action.destructive, action.icon]);
return (
<ContextMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
<ContextMenuItem
leading={leading}
onSelect={action.onSelect}
destructive={action.destructive}
testID={action.testID}
>
{action.label}
</ContextMenuItem>
);
File diff suppressed because it is too large Load Diff
@@ -10,22 +10,22 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
// paths) render different data, but their ROWS should look identical — same
// 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];
export const WORKSPACE_TREE_ICON_SIZE = 16;
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14;
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2];
export const TREE_INDENT_PER_LEVEL = 14;
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1];
export const WORKSPACE_TREE_ICON_SIZE = 14;
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 12;
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[1];
/**
* Trailing glyph rail shared with the explorer X and Changes options chevron.
* The extra 2px is optical: text ink ends inside its layout box, while the
* header icons' strokes extend to theirs.
*/
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2;
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[3] + 2;
/** 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. */
export function treeRowPaddingLeft(depth: number): number {
return SPACING[3] + depth * TREE_INDENT_PER_LEVEL;
return SPACING[2] + depth * TREE_INDENT_PER_LEVEL;
}
const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({
@@ -46,7 +46,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) {
key: index,
style: [
styles.indentGuide,
inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }),
inlineUnistylesStyle({ left: SPACING[2] + index * TREE_INDENT_PER_LEVEL + 3 }),
],
})),
[depth],
@@ -63,13 +63,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) {
/** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */
export function TreeChevron({ expanded }: { expanded: boolean }) {
return (
<View
style={
expanded
? [styles.chevron, styles.chevronExpanded]
: [styles.chevron, styles.chevronCollapsed]
}
>
<View style={expanded ? [styles.chevron, styles.chevronExpanded] : styles.chevron}>
<ThemedChevronRight
size={WORKSPACE_TREE_ICON_SIZE}
uniProps={foregroundExtraMutedIconColorMapping}
@@ -93,13 +87,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
justifyContent: "center",
flexShrink: 0,
},
// Lucide leaves space around the chevron path. Align its painted right edge inside the fixed
// slot; the rotated glyph is wider, so it needs half the optical offset.
chevronCollapsed: {
left: 4,
},
chevronExpanded: {
left: 2,
transform: [{ rotate: "90deg" }],
},
}));
@@ -105,6 +105,7 @@ export function ContextMenuTrigger({
enabledOnMobile = true,
enabledOnWeb = true,
longPressDelayMs,
onContextMenu,
triggerRef,
...props
}: PropsWithChildren<
@@ -115,6 +116,7 @@ export function ContextMenuTrigger({
enabledOnMobile?: boolean;
enabledOnWeb?: boolean;
longPressDelayMs?: number;
onContextMenu?: (event: unknown) => void;
triggerRef?: Ref<View | null>;
}
>): ReactElement {
@@ -170,9 +172,10 @@ export function ContextMenuTrigger({
if (isCallable(preventDefault)) preventDefault.call(event);
if (isCallable(stopPropagation)) stopPropagation.call(event);
}
onContextMenu?.(event);
openAtEvent(event);
},
[openAtEvent],
[onContextMenu, openAtEvent],
);
const pressableStyle = useCallback(
@@ -333,6 +333,7 @@ export function AnchoredSurface({
<>
{backdrop ? (
<Pressable
{...{ onContextMenu: onClose }}
accessibilityRole="button"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
@@ -413,6 +414,9 @@ export function MenuOverlay({
const overlay = (
<OverlayLayerProvider layer={floatingLayer}>
<View
{...{
onContextMenu: (event: { preventDefault?: () => void }) => event.preventDefault?.(),
}}
ref={setWebOverlayScope}
collapsable={false}
style={[