import { useCallback, useState, type PropsWithChildren, type ReactElement, type ReactNode, } from "react"; import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Check, Copy, ExternalLink, FileDiff, Folder, GitBranch, Server, } from "lucide-react-native"; import { getForgePresentation, normalizeForge } from "@/git/forge"; import { ForgeBrandIcon } from "@/git/forge-icon"; import type { Theme } from "@/styles/theme"; import { DiffStat } from "@/components/diff-stat"; import { Pressable } from "react-native"; import type { GestureResponderEvent } from "react-native"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { PrHint } from "@/git/use-pr-status-query"; import { openExternalUrl } from "@/utils/open-external-url"; import { copyToClipboard } from "@/utils/copy-to-clipboard"; import { PrBadge } from "@/components/sidebar-workspace-list"; import { useIsCompactFormFactor } from "@/constants/layout"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; import { useHosts } from "@/runtime/host-runtime"; import { COUNTED_CHECK_PRESENTATIONS, countCheckPresentations, type CountedCheckPresentation, } from "@/git/check-presentation"; import { formatCheckPresentationCountsLabel } from "@/git/check-presentation-copy"; import { CheckPresentationIcon, getCheckPresentationTone } from "@/git/check-presentation.view"; import { buildForgeChecksUrl } from "@/git/forge-url"; const HOVER_CARD_WIDTH = 260; interface WorkspaceHoverCardProps { workspace: SidebarWorkspaceEntry; prHint: PrHint | null; isDragging: boolean; disabled?: boolean; } export function WorkspaceHoverCard({ workspace, prHint, isDragging, disabled = false, children, }: PropsWithChildren): ReactNode { const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); if (isCompact) { return children; } return ( {children} ); } function WorkspaceHoverCardContent({ workspace, prHint, }: { workspace: SidebarWorkspaceEntry; prHint: PrHint | null; }): ReactElement { const { t } = useTranslation(); return ( <> {workspace.name} {prHint ? : null} {workspace.diffStat ? ( ) : null} {workspace.currentBranch ? ( ) : null} {workspace.workspaceDirectoryLabel ? ( ) : null} {prHint?.checks && prHint.checks.length > 0 ? ( <> ) : null} ); } const ThemedGitBranch = withUnistyles(GitBranch); const ThemedFolder = withUnistyles(Folder); const ThemedServer = withUnistyles(Server); const ThemedFileDiff = withUnistyles(FileDiff); type CardInfoIcon = React.ComponentType>; function HostRow({ serverId }: { serverId: string }): ReactElement | null { const hosts = useHosts(); const host = hosts.find((h) => h.serverId === serverId); const label = host?.label?.trim() || serverId; return ; } const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedCopy = withUnistyles(Copy); const ThemedCheck = withUnistyles(Check); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); function InfoRow({ icon: Icon, value, testID, }: { icon: CardInfoIcon; value: string; testID: string; }) { return ( {value} ); } function renderChecksSummaryForgeIcon(icon: string, iconUniProps: typeof foregroundColorMapping) { return ; } function CopyableInfoRow({ icon: Icon, value, copyValue, copyLabel, testID, }: { icon: CardInfoIcon; value: string; copyValue: string; copyLabel: string; testID: string; }) { const [isHovered, setIsHovered] = useState(false); const [copied, setCopied] = useState(false); const handlePressIn = useCallback((event: GestureResponderEvent) => { event.stopPropagation(); }, []); const handlePress = useCallback(() => { void copyToClipboard(copyValue); setCopied(true); setTimeout(() => { setCopied(false); }, 2000); }, [copyValue]); const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverOut = useCallback(() => setIsHovered(false), []); let iconUniProps = foregroundMutedColorMapping; if (copied || isHovered) { iconUniProps = foregroundColorMapping; } const textStyle = copied || isHovered ? [styles.cardInfoText, styles.cardInfoTextHovered] : styles.cardInfoText; return ( {(() => { if (copied) { return ; } if (isHovered) { return ; } return ; })()} {value} ); } function ChecksSummaryPill({ count, presentation, }: { count: number; presentation: CountedCheckPresentation; }) { if (count === 0) return null; return ( {count} ); } function checksSummaryTextStyle(presentation: CountedCheckPresentation) { const tone = getCheckPresentationTone(presentation); if (tone === "success") return styles.checksStatusTextPassed; if (tone === "danger") return styles.checksStatusTextFailed; if (tone === "warning") return styles.checksStatusTextPending; return styles.checksStatusTextMuted; } function ChecksSummaryContent({ checks, forge, hovered, }: { checks: NonNullable; forge: PrHint["forge"]; hovered: boolean; }) { const { t } = useTranslation(); const counts = countCheckPresentations(checks); const labelStyle = hovered ? [styles.checksSummaryLabel, styles.checksSummaryLabelHovered] : styles.checksSummaryLabel; const iconUniProps = hovered ? foregroundColorMapping : foregroundMutedColorMapping; const icon = getForgePresentation(normalizeForge(forge)).icon; return ( <> {hovered ? ( ) : ( renderChecksSummaryForgeIcon(icon, iconUniProps) )} {t("workspace.git.pr.sections.checks")} {COUNTED_CHECK_PRESENTATIONS.map((presentation) => ( ))} ); } function ChecksSummaryPressable({ checks, forge, url, }: { checks: NonNullable; forge: PrHint["forge"]; url: string; }) { const { t } = useTranslation(); const counts = countCheckPresentations(checks); const accessibilityLabel = formatCheckPresentationCountsLabel( counts, t("workspace.git.pr.sections.checks"), t, ); const handlePress = useCallback(() => { void openExternalUrl(buildForgeChecksUrl(forge, url) ?? url); }, [forge, url]); const renderChildren = useCallback( ({ hovered }: { pressed: boolean; hovered?: boolean }) => ( ), [checks, forge], ); return ( {renderChildren} ); } function checksSummaryPressableStyle({ hovered = false }: { pressed: boolean; hovered?: boolean }) { return [styles.checksSummaryRow, hovered && styles.listRowHovered]; } const styles = StyleSheet.create((theme) => ({ card: { backgroundColor: theme.colors.surface1, borderWidth: 1, borderColor: theme.colors.borderAccent, borderRadius: theme.borderRadius.lg, paddingTop: theme.spacing[2], width: HOVER_CARD_WIDTH, shadowColor: "#000", shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8, elevation: 8, }, cardHeader: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[2], }, cardTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, flex: 1, minWidth: 0, }, cardInfoRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1.5], paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[2], }, cardInfoText: { flex: 1, minWidth: 0, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, cardInfoTextHovered: { color: theme.colors.foreground, }, separator: { height: 1, backgroundColor: theme.colors.border, }, listRowHovered: { backgroundColor: theme.colors.surface2, }, checksSummaryRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1.5], paddingHorizontal: theme.spacing[3], paddingVertical: 6, minHeight: 28, }, checksSummaryLabel: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, checksSummaryLabelHovered: { color: theme.colors.foreground, }, checksSummaryCounts: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], flex: 1, justifyContent: "flex-end", }, checksSummaryPill: { flexDirection: "row", alignItems: "center", gap: 3, }, checksStatusTextFailed: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.statusDanger, }, checksStatusTextPending: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.statusWarning, }, checksStatusTextPassed: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.statusSuccess, }, checksStatusTextMuted: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, }));