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:
co-authored by
Mohamed Boudra
parent
a98d154b6b
commit
ca2b633286
@@ -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={[
|
||||
|
||||
Reference in New Issue
Block a user