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 RepositorySection } 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 REPOSITORY_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 repositoryInitial(label: string): string { const parts = label.split("/"); const name = parts[parts.length - 1] ?? label; return name.charAt(0).toUpperCase(); } function repositoryIconColor(label: string): string { let hash = 0; for (let i = 0; i < label.length; i++) { hash = (hash * 31 + label.charCodeAt(i)) | 0; } return REPOSITORY_COLORS[Math.abs(hash) % REPOSITORY_COLORS.length]!; } function isPullRequestSidebarItem(task: Task): boolean { return task.id.startsWith("pr:"); } export const Sidebar = memo(function Sidebar({ repositories, newTaskRepos, selectedNewTaskRepoId, activeId, onSelect, onCreate, onSelectNewTaskRepo, onMarkUnread, onRenameTask, onRenameBranch, onReorderRepositories, taskOrderByRepository, onReorderTasks, onReloadOrganization, onReloadPullRequests, onReloadRepository, onReloadPullRequest, onToggleSidebar, }: { repositories: RepositorySection[]; 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; onReorderRepositories: (fromIndex: number, toIndex: number) => void; taskOrderByRepository: Record; onReorderTasks: (repositoryId: string, fromIndex: number, toIndex: number) => 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 [collapsedRepositories, setCollapsedRepositories] = useState>({}); const [hoveredRepositoryId, setHoveredRepositoryId] = useState(null); const [headerMenuOpen, setHeaderMenuOpen] = useState(false); const headerMenuRef = useRef(null); const scrollRef = useRef(null); // Mouse-based drag and drop state type DragState = | { type: "repository"; fromIdx: number; overIdx: number | null } | { type: "task"; repositoryId: string; fromIdx: number; overIdx: number | null } | null; const [drag, setDrag] = useState(null); const dragRef = useRef(null); const startYRef = useRef(0); const didDragRef = useRef(false); // Attach global mousemove/mouseup when dragging useEffect(() => { if (!drag) return; const onMove = (e: MouseEvent) => { // Detect which element is under the cursor using data attributes const el = document.elementFromPoint(e.clientX, e.clientY); if (!el) return; const repositoryEl = (el as HTMLElement).closest?.("[data-repository-idx]") as HTMLElement | null; const taskEl = (el as HTMLElement).closest?.("[data-task-idx]") as HTMLElement | null; if (drag.type === "repository" && repositoryEl) { const overIdx = Number(repositoryEl.dataset.repositoryIdx); if (overIdx !== drag.overIdx) { setDrag({ ...drag, overIdx }); dragRef.current = { ...drag, overIdx }; } } else if (drag.type === "task" && taskEl) { const overRepositoryId = taskEl.dataset.taskRepositoryId ?? ""; const overIdx = Number(taskEl.dataset.taskIdx); if (overRepositoryId === drag.repositoryId && overIdx !== drag.overIdx) { setDrag({ ...drag, overIdx }); dragRef.current = { ...drag, overIdx }; } } // Mark that we actually moved (to distinguish from clicks) if (Math.abs(e.clientY - startYRef.current) > 4) { didDragRef.current = true; } }; const onUp = () => { const d = dragRef.current; if (d && didDragRef.current && d.overIdx !== null && d.fromIdx !== d.overIdx) { if (d.type === "repository") { onReorderRepositories(d.fromIdx, d.overIdx); } else { onReorderTasks(d.repositoryId, d.fromIdx, d.overIdx); } } dragRef.current = null; didDragRef.current = false; setDrag(null); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); return () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; }, [drag, onReorderRepositories, onReorderTasks]); 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: "repository-header"; repository: RepositorySection; repositoryIndex: number } | { key: string; type: "task"; repository: RepositorySection; repositoryIndex: number; task: Task; taskIndex: number } | { key: string; type: "task-drop-zone"; repository: RepositorySection; repositoryIndex: number; taskCount: number } | { key: string; type: "repository-drop-zone"; repositoryCount: number }; const flatItems = useMemo(() => { const items: FlatItem[] = []; repositories.forEach((repository, repositoryIndex) => { items.push({ key: `repository:${repository.id}`, type: "repository-header", repository, repositoryIndex }); if (!collapsedRepositories[repository.id]) { const orderedTaskIds = taskOrderByRepository[repository.id]; const orderedTasks = orderedTaskIds ? (() => { const byId = new Map(repository.tasks.map((t) => [t.id, t])); const sorted = orderedTaskIds.map((id) => byId.get(id)).filter(Boolean) as typeof repository.tasks; for (const t of repository.tasks) { if (!orderedTaskIds.includes(t.id)) sorted.push(t); } return sorted; })() : repository.tasks; orderedTasks.forEach((task, taskIndex) => { items.push({ key: `task:${task.id}`, type: "task" as const, repository, repositoryIndex, task, taskIndex }); }); items.push({ key: `task-drop:${repository.id}`, type: "task-drop-zone", repository, repositoryIndex, taskCount: orderedTasks.length }); } }); items.push({ key: "repository-drop-zone", type: "repository-drop-zone", repositoryCount: repositories.length }); return items; }, [collapsedRepositories, repositories, taskOrderByRepository]); 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 ? (