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 ; if (file.isDeleted) return ; return null; } function treeFileChangeIcon(file: ParsedDiffFile, showsBodyState: boolean) { return showsBodyState ? null : ; } 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 ( ); } 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 = ( {showDir ? null : ( )} {fileName} {showDir ? ( {directorySuffix(file.path)} ) : ( )} {documentChangeIcon} {treeChangeIcon} ); let trigger: ReactElement; if (interactive) { trigger = ( {content} ); } else { trigger = {content}; } return ( {trigger} {file.path} {interactive ? ( ) : null} ); }); 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 }, }));