import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useStyletron } from "baseui"; import { LabelSmall, LabelXSmall } from "baseui/typography"; import { Select, type Value } from "baseui/select"; import { ChevronDown, ChevronRight, ChevronUp, CloudUpload, CreditCard, GitPullRequestDraft, ListChecks, LogOut, MoreHorizontal, PanelLeft, Plus, Settings, User, } from "lucide-react"; import { formatRelativeAge, type Task, type ProjectSection } from "./view-model"; import { ContextMenuOverlay, TaskIndicator, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui"; import { activeMockOrganization, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../../lib/mock-app"; import { useFoundryTokens } from "../../app/theme"; import type { FoundryTokens } from "../../styles/tokens"; const PROJECT_COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"]; /** Strip the org prefix (e.g. "rivet-dev/") when all repos share the same org. */ function stripCommonOrgPrefix(label: string, repos: Array<{ label: string }>): string { const slashIdx = label.indexOf("/"); if (slashIdx < 0) return label; const prefix = label.slice(0, slashIdx + 1); if (repos.every((r) => r.label.startsWith(prefix))) { return label.slice(slashIdx + 1); } return label; } function projectInitial(label: string): string { const parts = label.split("/"); const name = parts[parts.length - 1] ?? label; return name.charAt(0).toUpperCase(); } function projectIconColor(label: string): string { let hash = 0; for (let i = 0; i < label.length; i++) { hash = (hash * 31 + label.charCodeAt(i)) | 0; } return PROJECT_COLORS[Math.abs(hash) % PROJECT_COLORS.length]!; } function isPullRequestSidebarItem(task: Task): boolean { return task.id.startsWith("pr:"); } export const Sidebar = memo(function Sidebar({ projects, newTaskRepos, selectedNewTaskRepoId, activeId, onSelect, onCreate, onSelectNewTaskRepo, onMarkUnread, onRenameTask, onRenameBranch, onReloadOrganization, onReloadPullRequests, onReloadRepository, onReloadPullRequest, onToggleSidebar, }: { projects: ProjectSection[]; newTaskRepos: Array<{ id: string; label: string }>; selectedNewTaskRepoId: string; activeId: string; onSelect: (id: string) => void; onCreate: (repoId?: string) => void; onSelectNewTaskRepo: (repoId: string) => void; onMarkUnread: (id: string) => void; onRenameTask: (id: string) => void; onRenameBranch: (id: string) => void; onReloadOrganization: () => void; onReloadPullRequests: () => void; onReloadRepository: (repoId: string) => void; onReloadPullRequest: (repoId: string, prNumber: number) => void; onToggleSidebar?: () => void; }) { const [css] = useStyletron(); const t = useFoundryTokens(); const contextMenu = useContextMenu(); const [collapsedProjects, setCollapsedProjects] = useState>({}); const [hoveredProjectId, setHoveredProjectId] = useState(null); const [headerMenuOpen, setHeaderMenuOpen] = useState(false); const headerMenuRef = useRef(null); const scrollRef = useRef(null); useEffect(() => { if (!headerMenuOpen) { return; } const onMouseDown = (event: MouseEvent) => { if (headerMenuRef.current?.contains(event.target as Node)) { return; } setHeaderMenuOpen(false); }; document.addEventListener("mousedown", onMouseDown); return () => document.removeEventListener("mousedown", onMouseDown); }, [headerMenuOpen]); const [createSelectOpen, setCreateSelectOpen] = useState(false); const selectOptions = useMemo(() => newTaskRepos.map((repo) => ({ id: repo.id, label: stripCommonOrgPrefix(repo.label, newTaskRepos) })), [newTaskRepos]); type FlatItem = { key: string; type: "project-header"; project: ProjectSection } | { key: string; type: "task"; project: ProjectSection; task: Task }; const flatItems = useMemo( () => projects.flatMap((project) => { const items: FlatItem[] = [{ key: `project:${project.id}`, type: "project-header", project }]; if (!collapsedProjects[project.id]) { items.push(...project.tasks.map((task) => ({ key: `task:${task.id}`, type: "task" as const, project, task }))); } return items; }), [collapsedProjects, projects], ); const virtualizer = useVirtualizer({ count: flatItems.length, getItemKey: (index) => flatItems[index]?.key ?? index, getScrollElement: () => scrollRef.current, estimateSize: () => 40, overscan: 12, measureElement: (element) => element.getBoundingClientRect().height, }); return ( {import.meta.env.VITE_DESKTOP ? (
{onToggleSidebar ? (
{ if (event.key === "Enter" || event.key === " ") onToggleSidebar(); }} className={css({ width: "26px", height: "26px", borderRadius: "6px", color: t.textTertiary, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, ":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover }, })} >
) : null}
) : null} Tasks {!import.meta.env.VITE_DESKTOP && onToggleSidebar ? (
{ if (event.key === "Enter" || event.key === " ") onToggleSidebar(); }} className={css({ width: "26px", height: "26px", borderRadius: "6px", color: t.textTertiary, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, ":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover }, })} >
) : null} {createSelectOpen ? (