403 lines
13 KiB
TypeScript
403 lines
13 KiB
TypeScript
import { memo, type ReactElement, useCallback, useMemo, useState } from "react";
|
|
import { Text, View, type PressableStateCallbackType } from "react-native";
|
|
import { StyleSheet } from "react-native-unistyles";
|
|
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
|
|
import { DiffStat } from "@/components/diff-stat";
|
|
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
|
|
import { FileChangeIcon } from "@/components/file-change-icon";
|
|
import { MaterialFileIcon } from "@/components/material-file-icon";
|
|
import {
|
|
treeRowPaddingLeft,
|
|
workspaceTreeRowStyles,
|
|
WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
|
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
|
WORKSPACE_TREE_ICON_LABEL_GAP,
|
|
WORKSPACE_TREE_ICON_SIZE,
|
|
} from "@/components/tree-primitives";
|
|
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { useFileHeaderInteraction } from "@/git/file-header-interaction";
|
|
import type { ParsedDiffFile } from "@/git/use-diff-query";
|
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
|
|
|
export interface FileHeaderProps {
|
|
file: ParsedDiffFile;
|
|
workspaceFileDragScope?: { serverId: string; workspaceId: string };
|
|
bodyVisible: boolean;
|
|
showsBodyState?: boolean;
|
|
isSelected?: boolean;
|
|
depth?: number;
|
|
showDir?: boolean;
|
|
interactive?: boolean;
|
|
onActivate?: (path: string) => void;
|
|
onSelect?: (path: string) => void;
|
|
onOpenFile?: (path: string) => void;
|
|
onAddToChat?: (path: string) => void;
|
|
onCopyPath?: (path: string) => void;
|
|
onCopyRelativePath?: (path: string) => void;
|
|
onReveal?: (path: string) => void;
|
|
revealTargetName?: string;
|
|
onDownload?: (path: string) => void;
|
|
onDuplicate?: (path: string) => void;
|
|
onRevert?: (path: string, oldPath?: string) => void;
|
|
onHeaderHeightChange?: (path: string, height: number) => void;
|
|
testID?: string;
|
|
}
|
|
|
|
function fileNameForPath(path: string): string {
|
|
return path.split("/").pop() ?? path;
|
|
}
|
|
|
|
function directorySuffix(path: string): string {
|
|
return path.includes("/") ? ` ${path.slice(0, path.lastIndexOf("/"))}` : "";
|
|
}
|
|
|
|
function fileHeaderAccessibilityState(input: {
|
|
showsBodyState: boolean;
|
|
bodyVisible: boolean;
|
|
isSelected: boolean;
|
|
}) {
|
|
return {
|
|
...(input.showsBodyState ? { expanded: input.bodyVisible } : {}),
|
|
selected: input.isSelected,
|
|
};
|
|
}
|
|
|
|
function expandedAriaValue(showsBodyState: boolean, bodyVisible: boolean): boolean | undefined {
|
|
return showsBodyState ? bodyVisible : undefined;
|
|
}
|
|
|
|
function useFileHeaderHover(enabled: boolean) {
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const handlePointerEnter = useCallback(() => {
|
|
if (enabled) setIsHovered(true);
|
|
}, [enabled]);
|
|
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
|
return { isHovered, handlePointerEnter, handlePointerLeave };
|
|
}
|
|
|
|
function fileHeaderIsActive(input: {
|
|
isHovered: boolean;
|
|
pressed: boolean;
|
|
showsBodyState: boolean;
|
|
isSelected: boolean;
|
|
}): boolean {
|
|
return input.isHovered || input.pressed || (!input.showsBodyState && input.isSelected);
|
|
}
|
|
|
|
function fileHeaderInteractionStyle(input: {
|
|
isHovered: boolean;
|
|
pressed: boolean;
|
|
showsBodyState: boolean;
|
|
isSelected: boolean;
|
|
}) {
|
|
if (!fileHeaderIsActive(input)) return null;
|
|
return input.showsBodyState ? styles.documentActive : workspaceTreeRowStyles.active;
|
|
}
|
|
|
|
function fileHeaderPressFeedbackStyle(showsBodyState: boolean) {
|
|
return showsBodyState ? styles.documentPressFeedback : workspaceTreeRowStyles.active;
|
|
}
|
|
|
|
function fileHeaderContainerStyle(showsBodyState: boolean) {
|
|
if (!showsBodyState) return null;
|
|
return styles.documentContainer;
|
|
}
|
|
|
|
function fileHeaderNameStyle(showsBodyState: boolean, isHovered: boolean) {
|
|
if (showsBodyState) return styles.name;
|
|
return [
|
|
styles.name,
|
|
workspaceTreeRowStyles.name,
|
|
isHovered ? workspaceTreeRowStyles.nameHovered : null,
|
|
];
|
|
}
|
|
|
|
function fileChange(file: ParsedDiffFile): "added" | "deleted" | "modified" {
|
|
if (file.isNew) return "added";
|
|
if (file.isDeleted) return "deleted";
|
|
return "modified";
|
|
}
|
|
|
|
function documentFileChangeIcon(file: ParsedDiffFile, showsBodyState: boolean) {
|
|
if (!showsBodyState) return null;
|
|
if (file.isNew) return <FileChangeIcon change="added" />;
|
|
if (file.isDeleted) return <FileChangeIcon change="deleted" />;
|
|
return null;
|
|
}
|
|
|
|
function treeFileChangeIcon(file: ParsedDiffFile, showsBodyState: boolean) {
|
|
return showsBodyState ? null : <FileChangeIcon change={fileChange(file)} />;
|
|
}
|
|
|
|
function fileHeaderRightStyle(showsBodyState: boolean) {
|
|
return showsBodyState ? styles.right : [styles.right, styles.treeRight];
|
|
}
|
|
|
|
function FileHeaderMenu({
|
|
file,
|
|
onOpenFile,
|
|
onAddToChat,
|
|
onCopyPath,
|
|
onCopyRelativePath,
|
|
onReveal,
|
|
revealTargetName,
|
|
onDownload,
|
|
onDuplicate,
|
|
onRevert,
|
|
testID,
|
|
}: FileHeaderProps) {
|
|
const openFile = useCallback(() => onOpenFile?.(file.path), [file.path, onOpenFile]);
|
|
const addToChat = useCallback(() => onAddToChat?.(file.path), [file.path, onAddToChat]);
|
|
const copyPath = useCallback(() => onCopyPath?.(file.path), [file.path, onCopyPath]);
|
|
const copyRelativePath = useCallback(
|
|
() => onCopyRelativePath?.(file.path),
|
|
[file.path, onCopyRelativePath],
|
|
);
|
|
const reveal = useCallback(() => onReveal?.(file.path), [file.path, onReveal]);
|
|
const download = useCallback(() => onDownload?.(file.path), [file.path, onDownload]);
|
|
const duplicate = useCallback(() => onDuplicate?.(file.path), [file.path, onDuplicate]);
|
|
const revert = useCallback(
|
|
() => onRevert?.(file.path, file.oldPath),
|
|
[file.oldPath, file.path, onRevert],
|
|
);
|
|
return (
|
|
<FileActionsContextMenuContent
|
|
fileKind="file"
|
|
fileExists={!file.isDeleted}
|
|
onOpenFile={onOpenFile ? openFile : undefined}
|
|
onCopyPath={onCopyPath ? copyPath : undefined}
|
|
onCopyRelativePath={onCopyRelativePath ? copyRelativePath : undefined}
|
|
onReveal={onReveal ? reveal : undefined}
|
|
revealTargetName={revealTargetName}
|
|
onDownload={onDownload ? download : undefined}
|
|
onAddToChat={onAddToChat ? addToChat : undefined}
|
|
onDuplicate={!file.isDeleted && onDuplicate ? duplicate : undefined}
|
|
onRevert={onRevert ? revert : undefined}
|
|
testIDPrefix={testID}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export const FileHeader = memo(function FileHeader({
|
|
file,
|
|
workspaceFileDragScope,
|
|
bodyVisible,
|
|
showsBodyState = true,
|
|
isSelected = false,
|
|
depth = 0,
|
|
showDir = true,
|
|
interactive = true,
|
|
onActivate,
|
|
onSelect,
|
|
onHeaderHeightChange,
|
|
testID,
|
|
...actions
|
|
}: FileHeaderProps) {
|
|
const hover = useFileHeaderHover(interactive);
|
|
const dragSourceRef = useWorkspaceFileDragSource({
|
|
enabled: interactive,
|
|
disabled: file.isDeleted,
|
|
workspaceId: null,
|
|
path: file.path,
|
|
...workspaceFileDragScope,
|
|
});
|
|
const interaction = useFileHeaderInteraction({
|
|
path: file.path,
|
|
enabled: interactive,
|
|
onSelect,
|
|
onActivate,
|
|
stickyPressFallback: showsBodyState,
|
|
onLayout: useCallback(
|
|
(height: number) => onHeaderHeightChange?.(file.path, height),
|
|
[file.path, onHeaderHeightChange],
|
|
),
|
|
});
|
|
const pressableStyle = useCallback(
|
|
({ pressed }: PressableStateCallbackType) => [
|
|
styles.header,
|
|
!showsBodyState && workspaceTreeRowStyles.row,
|
|
showsBodyState && styles.documentHeader,
|
|
depth > 0
|
|
? inlineUnistylesStyle({
|
|
paddingLeft: treeRowPaddingLeft(depth),
|
|
})
|
|
: null,
|
|
fileHeaderInteractionStyle({
|
|
isHovered: hover.isHovered,
|
|
pressed,
|
|
showsBodyState,
|
|
isSelected,
|
|
}),
|
|
],
|
|
[depth, hover.isHovered, isSelected, showsBodyState],
|
|
);
|
|
const accessibilityState = useMemo(
|
|
() => fileHeaderAccessibilityState({ showsBodyState, bodyVisible, isSelected }),
|
|
[bodyVisible, isSelected, showsBodyState],
|
|
);
|
|
const fileName = fileNameForPath(file.path);
|
|
const nameStyle = fileHeaderNameStyle(showsBodyState, hover.isHovered);
|
|
const documentChangeIcon = documentFileChangeIcon(file, showsBodyState);
|
|
const treeChangeIcon = treeFileChangeIcon(file, showsBodyState);
|
|
const rightStyle = fileHeaderRightStyle(showsBodyState);
|
|
const content = (
|
|
<View
|
|
style={[styles.content, showsBodyState && styles.documentContent]}
|
|
testID={testID ? `${testID}-header-content` : undefined}
|
|
>
|
|
<View ref={dragSourceRef} style={showDir ? styles.left : [styles.left, styles.leftTree]}>
|
|
{showDir ? null : (
|
|
<View style={styles.icon}>
|
|
<MaterialFileIcon fileName={fileName} size={WORKSPACE_TREE_ICON_SIZE} />
|
|
</View>
|
|
)}
|
|
<Text style={nameStyle} numberOfLines={1} testID={testID ? `${testID}-name` : undefined}>
|
|
{fileName}
|
|
</Text>
|
|
{showDir ? (
|
|
<Text style={styles.directory} numberOfLines={1}>
|
|
{directorySuffix(file.path)}
|
|
</Text>
|
|
) : (
|
|
<View style={styles.directorySpacer} />
|
|
)}
|
|
{documentChangeIcon}
|
|
</View>
|
|
<View style={rightStyle}>
|
|
<DiffStat
|
|
additions={file.additions}
|
|
deletions={file.deletions}
|
|
testID={testID ? `${testID}-stat` : undefined}
|
|
/>
|
|
{treeChangeIcon}
|
|
</View>
|
|
</View>
|
|
);
|
|
let trigger: ReactElement;
|
|
if (interactive) {
|
|
trigger = (
|
|
<ContextMenuTrigger
|
|
testID={testID ? `${testID}-toggle` : undefined}
|
|
style={pressableStyle}
|
|
highlightStyle={fileHeaderPressFeedbackStyle(showsBodyState)}
|
|
cancelable={false}
|
|
onPressIn={interaction.onPressIn}
|
|
onPressOut={interaction.onPressOut}
|
|
onLongPress={interaction.onLongPress}
|
|
onContextMenu={interaction.select}
|
|
onPress={interaction.activate}
|
|
accessibilityState={accessibilityState}
|
|
aria-expanded={expandedAriaValue(showsBodyState, bodyVisible)}
|
|
aria-selected={isSelected}
|
|
>
|
|
{content}
|
|
</ContextMenuTrigger>
|
|
);
|
|
} else {
|
|
trigger = <View style={pressableStyle({ pressed: false })}>{content}</View>;
|
|
}
|
|
return (
|
|
<View
|
|
style={[styles.container, fileHeaderContainerStyle(showsBodyState)]}
|
|
onLayout={interaction.onLayout}
|
|
onPointerEnter={hover.handlePointerEnter}
|
|
onPointerLeave={hover.handlePointerLeave}
|
|
testID={testID}
|
|
>
|
|
<ContextMenu>
|
|
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
|
|
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
|
<TooltipContent side="bottom" align="start" offset={6} maxWidth={520}>
|
|
<Text style={styles.tooltip}>{file.path}</Text>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
{interactive ? (
|
|
<FileHeaderMenu file={file} testID={testID} {...actions} bodyVisible={bodyVisible} />
|
|
) : null}
|
|
</ContextMenu>
|
|
</View>
|
|
);
|
|
});
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
container: { width: "100%", overflow: "hidden", userSelect: "none" },
|
|
documentContainer: {
|
|
height: 30,
|
|
backgroundColor: theme.colors.surface0,
|
|
borderBottomWidth: theme.borderWidth[1],
|
|
borderBottomColor: theme.colors.borderAccent,
|
|
},
|
|
header: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingLeft: theme.spacing[3],
|
|
paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
|
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
|
gap: theme.spacing[1],
|
|
minWidth: 0,
|
|
zIndex: 2,
|
|
elevation: 2,
|
|
userSelect: "none",
|
|
},
|
|
documentHeader: {
|
|
height: 28,
|
|
paddingLeft: 0,
|
|
paddingRight: 0,
|
|
paddingVertical: 0,
|
|
},
|
|
content: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
userSelect: "none",
|
|
},
|
|
documentContent: { height: 28, paddingHorizontal: theme.spacing[3] },
|
|
documentActive: { backgroundColor: theme.colors.surface1 },
|
|
documentPressFeedback: { backgroundColor: theme.colors.surface1 },
|
|
left: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
flex: 1,
|
|
minWidth: 0,
|
|
userSelect: "none",
|
|
},
|
|
leftTree: { gap: WORKSPACE_TREE_ICON_LABEL_GAP },
|
|
right: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
flexShrink: 0,
|
|
userSelect: "none",
|
|
},
|
|
treeRight: { gap: theme.spacing[2] },
|
|
icon: {
|
|
width: WORKSPACE_TREE_ICON_SIZE,
|
|
height: WORKSPACE_TREE_ICON_SIZE,
|
|
flexShrink: 0,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
name: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.foreground,
|
|
flexShrink: 1,
|
|
minWidth: 0,
|
|
userSelect: "none",
|
|
},
|
|
directory: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
color: theme.colors.foregroundMuted,
|
|
flex: 1,
|
|
minWidth: 0,
|
|
userSelect: "none",
|
|
},
|
|
directorySpacer: { flex: 1, minWidth: 0 },
|
|
tooltip: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm },
|
|
}));
|