import type { ComponentPropsWithRef, KeyboardEvent, MouseEvent, ReactNode } from "react"; import { useEffect, useRef, useState } from "react"; import { Facepile } from "../facepile/facepile"; import { cn } from "../../lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip"; import { RowMenu, SessionActions } from "./session-item-actions"; import { hasVisibleActions } from "./actions"; import { RowGlyph } from "./session-item-glyph"; import type { MoveTarget, SidebarRow } from "./session-item.types"; import { IconArrowUpRight as ArrowUpRight, IconCircleCheck as CircleCheck, IconCircleDashed as CircleDotted, IconCircleX as CircleXmark, } from "../../icons"; const PRESENCE_CAP = 3; const PRESENCE_SIZE = 20; const HOVER_EXPAND_DELAY = 1_200; const EXPANDED_CONTENT_PADDING_BOTTOM = "data-[open=true]:pb-(--session-item-expanded-padding-bottom)"; const TITLE_TO_EXPANDED_CONTENT_GAP = "data-[open=true]:mt-(--session-item-title-to-content-gap)"; const EXPANDED_CONTENT_ROW_GAP = "gap-y-1"; export type SessionItemProps = ComponentPropsWithRef<"button"> & { data: SidebarRow; selected?: boolean; pinned?: boolean; hoverExpand?: boolean; hoverExpandDelay?: number; expanded?: boolean; expandedContent?: ReactNode | ((data: SidebarRow) => ReactNode); onPin?: (data: SidebarRow) => void; onUnpin?: (data: SidebarRow) => void; onArchive?: (data: SidebarRow) => void; onFork?: (data: SidebarRow) => void; onInfo?: (data: SidebarRow) => void; onLeave?: (data: SidebarRow) => void; onDelete?: (data: SidebarRow) => void; onRebuild?: (data: SidebarRow) => void; onRename?: (data: SidebarRow) => void; onMoveTargets?: (data: SidebarRow) => Promise; onMove?: (data: SidebarRow, target: MoveTarget) => Promise; }; function avatar(member: SidebarRow["online"][number]) { return { src: member.avatar || `https://github.com/${member.id}.png?size=40`, alt: member.name, }; } function names(members: SidebarRow["online"]) { let shown = members.slice(0, PRESENCE_CAP).map((member) => member.name); let more = members.length - shown.length; if (more > 0) shown.push(`+${more}`); return shown.join(", "); } function Presence({ members }: { members: SidebarRow["online"] }) { if (members.length === 0) return null; let label = names(members); let visible = members.map(avatar); return ( } > {label} ); } function messageText(count: number) { if (count <= 0) return undefined; return `${count} new ${count === 1 ? "message" : "messages"}`; } function ciText(pr: NonNullable) { if (pr.ci === "none") return undefined; if (pr.ci === "failed") { let count = pr.checks?.failed ?? 1; return `${count} ${count === 1 ? "test" : "tests"} failed`; } if (pr.ci === "passed") return "Tests passed"; if (pr.ci === "pending") { if (pr.checks) { let left = Math.max(pr.checks.total - pr.checks.completed, 0); return `${left}/${pr.checks.total} tests left`; } return "tests running"; } return undefined; } function CiIcon({ ci }: { ci: NonNullable["ci"] }) { if (ci === "failed") return ; if (ci === "passed") return ; return ; } function openPr(event: MouseEvent | KeyboardEvent, url?: string) { if (!url) return; event.preventDefault(); event.stopPropagation(); window.open(url, "_blank", "noopener,noreferrer"); } function keyPr(event: KeyboardEvent, url?: string) { if (event.key === "Enter") openPr(event, url); } function PrLink({ pr }: { pr: NonNullable }) { let interactive = Boolean(pr.url); return ( openPr(event, pr.url)} onKeyDown={(event) => keyPr(event, pr.url)} > {pr.number} ); } function defaultExpandedContent(data: SidebarRow) { let pr = data.pr; let ci = pr ? ciText(pr) : undefined; if (pr && ci) { return ( {ci} ); } let messages = messageText(data.unreadCount); if (!messages) return null; return ( {messages} ); } function hasContent(content: ReactNode): boolean { if (Array.isArray(content)) return content.some(hasContent); if (content === null || content === undefined || content === false) return false; if (typeof content === "string") return content.trim().length > 0; return true; } type RowActions = Pick< SessionItemProps, | "onPin" | "onUnpin" | "onFork" | "onInfo" | "onLeave" | "onDelete" | "onArchive" | "onRename" | "onRebuild" | "onMoveTargets" | "onMove" >; function actions(data: SidebarRow, props: RowActions): RowActions { if (data.kind === "lobby") return { onRebuild: props.onRebuild }; let pins = { onPin: props.onPin, onUnpin: props.onUnpin }; if (data.lifecycle === "archived") { return { ...pins, onDelete: data.capabilities?.delete === false ? undefined : props.onDelete }; } return { ...pins, onFork: props.onFork, onInfo: props.onInfo, onLeave: props.onLeave, onArchive: data.capabilities?.archive === false ? undefined : props.onArchive, onRename: data.capabilities?.rename === false ? undefined : props.onRename, ...(data.capabilities?.move ? { onMoveTargets: props.onMoveTargets, onMove: props.onMove } : {}), }; } function expandedBody(data: SidebarRow, content: SessionItemProps["expandedContent"]) { return typeof content === "function" ? content(data) : content ?? defaultExpandedContent(data); } function buttonClass( hoverExpand: boolean | undefined, hasActions: boolean, online: number, className?: string, ) { return cn( "relative flex h-[calc(var(--spacing)*8-2px)] w-full px-1 text-left", hoverExpand ? "flex-col items-stretch justify-center gap-0" : "items-center gap-1.5", "rounded-lg squircle border border-transparent bg-clip-padding", "data-[status=active]:z-1 data-[status=active]:bg-popover/35 dark:data-[status=active]:bg-black/32", "data-[status=active]:backdrop-blur-[1px]", "data-[status=active]:selected-surface", "data-[status=active]:text-accent-text data-[status=active]:font-medium", hoverExpand ? "transition-[background-color,filter,padding-right] duration-150 ease-out motion-reduce:transition-none data-[expanded=true]:rounded-b-none data-[expanded=true]:bg-popover data-[expanded=true]:hover:bg-popover" : "transition-[padding-right] duration-150 ease-out motion-reduce:transition-none", "group-data-[primary=true]/menu:active:translate-y-px group-data-[primary=true]/menu:active:brightness-95", "outline-2 outline-offset-0 outline-transparent focus-visible:outline-ring/50", hasActions && online === 0 && "group-hover/row:pr-13 group-focus-within/row:pr-13 group-data-[menu-open=true]/menu:pr-13", online > 0 && "pr-17", className, ); } /** * A single session row in the sidebar. */ export function SessionItem( { data, selected, pinned, hoverExpand, hoverExpandDelay = HOVER_EXPAND_DELAY, expanded, expandedContent, onPin, onUnpin, onArchive, onFork, onInfo, onLeave, onDelete, onRebuild, onRename, onMoveTargets, onMove, className, ref, onMouseEnter, onMouseLeave, ...props }: SessionItemProps, ) { let { onPin: actionPin, onUnpin: actionUnpin, ...menu } = actions(data, { onPin, onUnpin, onFork, onInfo, onLeave, onDelete, onArchive, onRename, onRebuild, onMoveTargets, onMove, }); let hasActions = hasVisibleActions({ pinned, onPin: actionPin, onUnpin: actionUnpin }); let [hovered, setHovered] = useState(false); let timer = useRef | undefined>(undefined); let content = hoverExpand ? expandedBody(data, expandedContent) : null; let canExpand = hoverExpand && !selected && hasContent(content); let open = canExpand && (expanded || hovered); let status = selected ? "active" : undefined; let expansion = open ? "true" : undefined; let preview = data.summary && data.summary.length > 180 ? `${data.summary.slice(0, 177).trimEnd()}…` : data.summary; let line = ( <> {data.name} ); function enter(event: MouseEvent) { onMouseEnter?.(event); if (!canExpand || expanded) return; clearTimeout(timer.current); timer.current = setTimeout(() => setHovered(true), hoverExpandDelay); } function leave(event: MouseEvent) { onMouseLeave?.(event); clearTimeout(timer.current); setHovered(false); } useEffect(() => { return () => clearTimeout(timer.current); }, []); return (
); }