import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; 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, 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]!; } export const Sidebar = memo(function Sidebar({ projects, newTaskRepos, selectedNewTaskRepoId, activeId, onSelect, onCreate, onSelectNewTaskRepo, onMarkUnread, onRenameTask, onRenameBranch, onReorderProjects, taskOrderByProject, onReorderTasks, 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; onReorderProjects: (fromIndex: number, toIndex: number) => void; taskOrderByProject: Record; onReorderTasks: (projectId: string, fromIndex: number, toIndex: number) => void; onToggleSidebar?: () => void; }) { const [css] = useStyletron(); const t = useFoundryTokens(); const contextMenu = useContextMenu(); const [collapsedProjects, setCollapsedProjects] = useState>({}); const [hoveredProjectId, setHoveredProjectId] = useState(null); // Mouse-based drag and drop state type DragState = | { type: "project"; fromIdx: number; overIdx: number | null } | { type: "task"; projectId: 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 projectEl = (el as HTMLElement).closest?.("[data-project-idx]") as HTMLElement | null; const taskEl = (el as HTMLElement).closest?.("[data-task-idx]") as HTMLElement | null; if (drag.type === "project" && projectEl) { const overIdx = Number(projectEl.dataset.projectIdx); if (overIdx !== drag.overIdx) { setDrag({ ...drag, overIdx }); dragRef.current = { ...drag, overIdx }; } } else if (drag.type === "task" && taskEl) { const overProjectId = taskEl.dataset.taskProjectId ?? ""; const overIdx = Number(taskEl.dataset.taskIdx); if (overProjectId === drag.projectId && 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 === "project") { onReorderProjects(d.fromIdx, d.overIdx); } else { onReorderTasks(d.projectId, 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, onReorderProjects, onReorderTasks]); const [createSelectOpen, setCreateSelectOpen] = useState(false); const selectOptions = useMemo(() => newTaskRepos.map((repo) => ({ id: repo.id, label: stripCommonOrgPrefix(repo.label, newTaskRepos) })), [newTaskRepos]); 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 ? (