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 { 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;
};
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"
>;
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: 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,
};
}
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,
className,
ref,
onMouseEnter,
onMouseLeave,
...props
}: SessionItemProps,
) {
let { onPin: actionPin, onUnpin: actionUnpin, ...menu } = actions(data, {
onPin,
onUnpin,
onFork,
onInfo,
onLeave,
onDelete,
onArchive,
onRename,
onRebuild,
});
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 (
);
}