diff --git a/docs/SIDEBAR_ITEM.md b/docs/SIDEBAR_ITEM.md new file mode 100644 index 0000000..b8a0237 --- /dev/null +++ b/docs/SIDEBAR_ITEM.md @@ -0,0 +1,56 @@ +# Sidebar Item + +Sidebar branch items are split into small components under +`src/lib/components/templates/sidebar`: + +- `sidebar-branch-item.tsx` renders the clickable row, branch name, status pill, + last commit age, and worktree removal action. +- `sidebar-item-icon.tsx` renders the branch or pull request icon. +- `sidebar-item-status.ts` classifies each branch item and stores the sidebar + label, icon color classes, and pill color classes in one place. +- `sidebar-project-item.tsx` groups branch items by project and decides which + stale items are visible. + +## Classification + +`getSidebarItemStatus` classifies a `BranchInfo` using this priority order: + +1. `stale-worktree`: `branch.hasWorktree === true` and + `branch.isStale === true`. + - Label: `Stale Worktree` + - Color: danger/red + - Reason: this branch has a local worktree, but the backing branch is stale, + so it needs the strongest warning. +2. `worktree`: `branch.hasWorktree === true`. + - Label: `Worktree` + - Color: success/green + - Reason: this branch has a local runnable worktree. +3. `stale`: `branch.isStale === true`. + - Label: `Stale` + - Color: warning/yellow + - Reason: this branch is stale but does not have a local worktree. +4. `pull-request`: `branch.source === "pull-request"`. + - Label: `PR` + - Color: blue + - Reason: this item came from an open pull request. +5. `branch`: fallback for every other branch. + - Label: `Branch` + - Color: muted + - Reason: this is a regular branch without a worktree, stale marker, or pull + request source. + +The priority order matters. For example, a pull request that is stale is shown +as `Stale`, and a stale branch with a worktree is shown as `Stale Worktree`. + +## Color Source + +Sidebar item colors should be changed only in `sidebar-item-status.ts`. + +Both `SidebarBranchItem` and `SidebarItemIcon` consume the same status config: + +- `pillClassName` controls the status pill color. +- `iconClassName` controls the icon background and icon color. + +This keeps the icon and status pill visually aligned for combined states such as +`Stale Worktree`. Sidebar pills use the `custom` `StatusPill` tone so the +sidebar status config fully owns the background, border, and text colors. diff --git a/src/lib/components/atoms/busy-icon.tsx b/src/lib/components/atoms/busy-icon.tsx new file mode 100644 index 0000000..168dcdb --- /dev/null +++ b/src/lib/components/atoms/busy-icon.tsx @@ -0,0 +1,44 @@ +import type { ComponentPropsWithoutRef } from "react"; + +import { cn } from "@/lib/utils/cn"; + +type BusyIconProps = ComponentPropsWithoutRef<"span"> & { + size?: "sm" | "md"; +}; + +const busyIconSizeClassName = { + md: { + container: "h-5 w-5", + dot: "h-2.5 w-2.5", + }, + sm: { + container: "h-4 w-4", + dot: "h-2 w-2", + }, +}; + +export function BusyIcon({ className, size = "md", ...props }: BusyIconProps) { + const sizeClassName = busyIconSizeClassName[size]; + + return ( + + + + ); +} diff --git a/src/lib/components/atoms/status-pill.tsx b/src/lib/components/atoms/status-pill.tsx index 7154bbc..ef3fca5 100644 --- a/src/lib/components/atoms/status-pill.tsx +++ b/src/lib/components/atoms/status-pill.tsx @@ -5,6 +5,7 @@ import { cn } from "@/lib/utils/cn"; type StatusPillTone = | "branch" | "busy" + | "custom" | "error" | "idle" | "pull-request" @@ -18,6 +19,7 @@ type StatusPillProps = ComponentPropsWithoutRef<"span"> & { const statusPillClassName: Record = { branch: "border-border bg-muted/35 text-muted-foreground", busy: "border-success/25 bg-success/12 text-success-foreground", + custom: "", error: "border-danger/25 bg-danger/12 text-danger-foreground", idle: "border-border bg-muted/25 text-muted-foreground", "pull-request": diff --git a/src/lib/components/molecules/worktree-terminal/terminal-tab-bar.tsx b/src/lib/components/molecules/worktree-terminal/terminal-tab-bar.tsx index 97914a3..0d6ed6e 100644 --- a/src/lib/components/molecules/worktree-terminal/terminal-tab-bar.tsx +++ b/src/lib/components/molecules/worktree-terminal/terminal-tab-bar.tsx @@ -1,6 +1,7 @@ import { Plus, X } from "lucide-react"; import type { MouseEvent } from "react"; +import { BusyIcon } from "@/lib/components/atoms/busy-icon"; import { Button } from "@/lib/components/atoms/button"; import { TabShell } from "@/lib/components/atoms/tab-shell"; import { cn } from "@/lib/utils/cn"; @@ -62,10 +63,7 @@ export function TerminalTabBar({ onClick={() => onSelectTab(tab.id)} > {tab.busyState === "busy" ? ( - + ) : tab.status === "exited" ? ( void runScript(script)} + onClick={() => { + runScript(script); + }} > - - - {isPreparing - ? "Preparing..." - : script.title} + + + + {isPreparing + ? "Preparing..." + : script.title} + + + +  
@@ -227,9 +256,9 @@ export function BranchScriptsSection({ isDisabled={deleteScriptMutation.isPending} type="button" variant="danger" - onPress={() => - void deleteScript(scriptPendingDelete) - } + onPress={() => { + deleteScript(scriptPendingDelete); + }} > {deleteScriptMutation.isPending diff --git a/src/lib/components/templates/global-terminal-panel.tsx b/src/lib/components/templates/global-terminal-panel.tsx new file mode 100644 index 0000000..7949bfa --- /dev/null +++ b/src/lib/components/templates/global-terminal-panel.tsx @@ -0,0 +1,399 @@ +import { ChevronDown, ChevronRight, Terminal, X } from "lucide-react"; +import type { PointerEvent as ReactPointerEvent } from "react"; +import { useEffect, useMemo, useState } from "react"; + +import { BusyIcon } from "@/lib/components/atoms/busy-icon"; +import { Button } from "@/lib/components/atoms/button"; +import { EmptyState } from "@/lib/components/atoms/empty-state"; +import { Surface } from "@/lib/components/atoms/surface"; +import { TerminalPane } from "@/lib/components/molecules/worktree-terminal/terminal-pane"; +import { useWorktreeTerminalStore } from "@/lib/hooks/store/use-worktree-terminal-store"; +import { cn } from "@/lib/utils/cn"; +import type { ProjectGroup } from "@/types/pr-run"; + +type GlobalTerminalPanelProps = { + groups: ProjectGroup[]; + height: number; + isOpen: boolean; + sidebarWidth: number; + selectedTerminalKey: string | null; + onBeginSidebarResize: (event: ReactPointerEvent) => void; + onBeginResize: (event: ReactPointerEvent) => void; + onClose: () => void; + onSelectTerminal: (terminalKey: string) => void; +}; + +type TerminalTreeGroup = { + branchName: string; + id: string; + isBusy: boolean; + ownerKey: string; + projectId: string; + title: string; + terminals: TerminalTreeTab[]; +}; + +type TerminalTreeTab = { + branchName: string; + busyState: "idle" | "busy" | "unknown"; + id: string; + isAlive: boolean; + label: string; + ownerKey: string; + projectId: string; + sessionId: string; + terminalKey: string; +}; + +export function GlobalTerminalPanel({ + groups, + height, + isOpen, + sidebarWidth, + selectedTerminalKey, + onBeginSidebarResize, + onBeginResize, + onClose, + onSelectTerminal, +}: GlobalTerminalPanelProps) { + const owners = useWorktreeTerminalStore((state) => state.owners); + const setActiveTab = useWorktreeTerminalStore( + (state) => state.setActiveTab, + ); + const tree = useMemo( + () => buildTerminalTree(groups, owners), + [groups, owners], + ); + const terminals = useMemo(() => flattenTerminalTree(tree), [tree]); + const selectedTerminal = + terminals.find( + (terminal) => terminal.terminalKey === selectedTerminalKey, + ) ?? + terminals[0] ?? + null; + const [expandedGroupIds, setExpandedGroupIds] = useState>( + () => new Set(), + ); + + useEffect(() => { + if (!isOpen || !selectedTerminal) { + return; + } + + setExpandedGroupIds((current) => { + if (current.has(selectedTerminal.ownerKey)) { + return current; + } + + return new Set([...current, selectedTerminal.ownerKey]); + }); + }, [isOpen, selectedTerminal]); + + if (!isOpen) { + return null; + } + + function selectTerminal(terminal: TerminalTreeTab) { + setActiveTab(terminal.ownerKey, terminal.id); + onSelectTerminal(terminal.terminalKey); + } + + return ( +
+
+ +
+
+ {selectedTerminal ? ( + + ) : ( + + } + title="No terminals" + /> + + )} +
+ +
+
+
+ + +
+
+ ); +} + +function buildTerminalTree( + groups: ProjectGroup[], + owners: ReturnType["owners"], +) { + const projects = groups.flatMap((group) => group.projects); + const projectNameById = new Map( + projects.map((project) => [project.id, project.name]), + ); + const projectOrder = new Map( + projects.map((project, index) => [project.id, index]), + ); + const groupMap = new Map(); + + for (const [ownerKey, owner] of Object.entries(owners)) { + if (owner.tabs.length === 0) { + continue; + } + + const { branchName, projectId } = parseOwnerKey(ownerKey); + const projectName = projectNameById.get(projectId) ?? projectId; + const group = ensureTerminalGroup( + groupMap, + ownerKey, + branchName, + projectId, + `${projectName} - ${branchName}`, + ); + + group.terminals.push( + ...owner.tabs.map((tab) => ({ + branchName, + busyState: tab.busyState, + id: tab.id, + isAlive: tab.status === "alive", + label: tab.label, + ownerKey, + projectId, + sessionId: tab.sessionId, + terminalKey: getTerminalKey(ownerKey, tab.id), + })), + ); + group.isBusy = group.terminals.some( + (terminal) => terminal.isAlive && terminal.busyState === "busy", + ); + } + + return [...groupMap.values()].sort( + (left, right) => + (projectOrder.get(left.projectId) ?? Number.MAX_SAFE_INTEGER) - + (projectOrder.get(right.projectId) ?? + Number.MAX_SAFE_INTEGER) || + left.branchName.localeCompare(right.branchName), + ); +} + +function ensureTerminalGroup( + groupMap: Map, + ownerKey: string, + branchName: string, + projectId: string, + title: string, +) { + const existing = groupMap.get(ownerKey); + + if (existing) { + return existing; + } + + const group: TerminalTreeGroup = { + branchName, + id: ownerKey, + isBusy: false, + ownerKey, + projectId, + title, + terminals: [], + }; + + groupMap.set(ownerKey, group); + return group; +} + +function flattenTerminalTree(tree: TerminalTreeGroup[]) { + return tree.flatMap((group) => group.terminals); +} + +export function getTerminalKey(ownerKey: string, tabId: string) { + return `${ownerKey}::${tabId}`; +} + +function parseOwnerKey(ownerKey: string) { + const separatorIndex = ownerKey.indexOf(":"); + + if (separatorIndex === -1) { + return { + branchName: ownerKey, + projectId: ownerKey, + }; + } + + return { + branchName: ownerKey.slice(separatorIndex + 1), + projectId: ownerKey.slice(0, separatorIndex), + }; +} + +function toggleSetValue(set: Set, value: string) { + const next = new Set(set); + + if (next.has(value)) { + next.delete(value); + } else { + next.add(value); + } + + return next; +} diff --git a/src/lib/components/templates/main-panel/branch-empty-state.tsx b/src/lib/components/templates/main-panel/branch-empty-state.tsx index 5de3df8..1658070 100644 --- a/src/lib/components/templates/main-panel/branch-empty-state.tsx +++ b/src/lib/components/templates/main-panel/branch-empty-state.tsx @@ -5,7 +5,7 @@ import { EmptyState } from "@/lib/components/atoms/empty-state"; export function BranchEmptyState() { return (
void; }; @@ -17,6 +19,7 @@ const tabs: { label: string; value: BranchPageTab }[] = [ export function BranchPageTabs({ activeTab, + isRunTabBusy, onSelectTab, }: BranchPageTabsProps) { return ( @@ -32,12 +35,16 @@ export function BranchPageTabs({ > diff --git a/src/lib/components/templates/main-panel/index.tsx b/src/lib/components/templates/main-panel/index.tsx index 499989c..da5c7ba 100644 --- a/src/lib/components/templates/main-panel/index.tsx +++ b/src/lib/components/templates/main-panel/index.tsx @@ -75,6 +75,9 @@ export function MainPanel({ const [activeTab, setActiveTab] = useState("general"); const selectedKey = project && branchName ? `${project.id}:${branchName}` : ""; + const selectedTerminalOwner = useWorktreeTerminalStore((state) => + selectedKey ? state.owners[selectedKey] : undefined, + ); const branchesQuery = useProjectBranchesQuery( project?.id, Boolean(project), @@ -98,6 +101,11 @@ export function MainPanel({ const isAwaitingCommitPassphrase = isHandledSshPromptError( commitsQuery.error, ); + const isRunTabBusy = Boolean( + selectedTerminalOwner?.tabs.some( + (tab) => tab.status === "alive" && tab.busyState === "busy", + ), + ); useEffect(() => { setActiveTab("general"); @@ -175,7 +183,7 @@ export function MainPanel({ return (
store.owners); + const [isTerminalPanelOpen, setIsTerminalPanelOpen] = useState(false); + const [terminalPanelHeight, setTerminalPanelHeight] = useState( + TERMINAL_PANEL_DEFAULT_HEIGHT, + ); + const [terminalPanelSidebarWidth, setTerminalPanelSidebarWidth] = useState( + TERMINAL_PANEL_SIDEBAR_DEFAULT_WIDTH, + ); + const [selectedGlobalTerminalKey, setSelectedGlobalTerminalKey] = useState< + string | null + >(null); + const preferredGlobalTerminalKey = useMemo( + () => getPreferredGlobalTerminalKey(terminalOwners), + [terminalOwners], + ); + + function openGlobalTerminalPanel() { + setSelectedGlobalTerminalKey( + (current) => current ?? preferredGlobalTerminalKey, + ); + setIsTerminalPanelOpen(true); + } + + function beginTerminalPanelResize( + event: ReactPointerEvent, + startHeightOverride?: number, + ) { + event.preventDefault(); + + const startY = event.clientY; + const startHeight = startHeightOverride ?? terminalPanelHeight; + const maxHeight = Math.min( + TERMINAL_PANEL_MAX_HEIGHT, + Math.floor(window.innerHeight * 0.78), + ); + + function handlePointerMove(moveEvent: PointerEvent) { + setTerminalPanelHeight( + clamp( + startHeight + startY - moveEvent.clientY, + TERMINAL_PANEL_MIN_HEIGHT, + maxHeight, + ), + ); + } + + function handlePointerUp() { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + } + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + } + + function openAndBeginTerminalPanelResize( + event: ReactPointerEvent, + ) { + openGlobalTerminalPanel(); + beginTerminalPanelResize( + event, + isTerminalPanelOpen + ? terminalPanelHeight + : TERMINAL_PANEL_MIN_HEIGHT, + ); + } + + function beginTerminalPanelSidebarResize( + event: ReactPointerEvent, + ) { + event.preventDefault(); + + const startX = event.clientX; + const startWidth = terminalPanelSidebarWidth; + + function handlePointerMove(moveEvent: PointerEvent) { + setTerminalPanelSidebarWidth( + clamp( + startWidth + moveEvent.clientX - startX, + TERMINAL_PANEL_SIDEBAR_MIN_WIDTH, + TERMINAL_PANEL_SIDEBAR_MAX_WIDTH, + ), + ); + } + + function handlePointerUp() { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + } + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + } if (state.configError) { return ( @@ -55,11 +167,14 @@ export function PrRunApp() { variant="plain" > - +
+ + setIsTerminalPanelOpen(false)} + onSelectTerminal={setSelectedGlobalTerminalKey} + /> + +
); } + +function getPreferredGlobalTerminalKey( + owners: ReturnType["owners"], +) { + const fallback = getFirstTerminalKey(owners); + + for (const [ownerKey, owner] of Object.entries(owners)) { + const busyTab = owner.tabs.find( + (tab) => tab.status === "alive" && tab.busyState === "busy", + ); + + if (busyTab) { + return getTerminalKey(ownerKey, busyTab.id); + } + } + + return fallback; +} + +function getFirstTerminalKey( + owners: ReturnType["owners"], +) { + for (const [ownerKey, owner] of Object.entries(owners)) { + const firstTab = owner.tabs[0]; + + if (firstTab) { + return getTerminalKey(ownerKey, firstTab.id); + } + } + + return null; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} diff --git a/src/lib/components/templates/pr-run-app/use-app-status-summary.ts b/src/lib/components/templates/pr-run-app/use-app-status-summary.ts new file mode 100644 index 0000000..8f7b2b0 --- /dev/null +++ b/src/lib/components/templates/pr-run-app/use-app-status-summary.ts @@ -0,0 +1,72 @@ +import { useQueries } from "@tanstack/react-query"; +import { useMemo } from "react"; + +import { projectBranchesQueryOptions } from "@/lib/hooks/query/use-project-branches-query"; +import { + getBusyTerminalSummary, + useWorktreeTerminalStore, +} from "@/lib/hooks/store/use-worktree-terminal-store"; +import type { ProjectConfig } from "@/types/pr-run"; + +export type AppStatusSummary = { + branchCount: number; + busyOwnerKeys: Set; + busyProjectIds: Set; + busyTerminalCount: number; + isLoadingBranchCounts: boolean; + openPullRequestCount: number; + staleWorktreeCount: number; + worktreeCount: number; +}; + +export function useAppStatusSummary( + projects: ProjectConfig[], +): AppStatusSummary { + const owners = useWorktreeTerminalStore((state) => state.owners); + const branchQueries = useQueries({ + queries: projects.map((project) => ({ + ...projectBranchesQueryOptions(project.id), + enabled: projects.length > 0, + })), + }); + + const busySummary = useMemo(() => getBusyTerminalSummary(owners), [owners]); + + return useMemo(() => { + let branchCount = 0; + let openPullRequestCount = 0; + let staleWorktreeCount = 0; + let worktreeCount = 0; + + for (const query of branchQueries) { + for (const branch of query.data ?? []) { + if (branch.source === "branch") { + branchCount += 1; + } + + if (branch.source === "pull-request") { + openPullRequestCount += 1; + } + + if (branch.hasWorktree) { + worktreeCount += 1; + } + + if (branch.hasWorktree && branch.isStale) { + staleWorktreeCount += 1; + } + } + } + + return { + ...busySummary, + branchCount, + isLoadingBranchCounts: branchQueries.some( + (query) => query.isPending, + ), + openPullRequestCount, + staleWorktreeCount, + worktreeCount, + }; + }, [branchQueries, busySummary]); +} diff --git a/src/lib/components/templates/pr-run-app/use-pr-run-app-state.ts b/src/lib/components/templates/pr-run-app/use-pr-run-app-state.ts index 975ef11..8215816 100644 --- a/src/lib/components/templates/pr-run-app/use-pr-run-app-state.ts +++ b/src/lib/components/templates/pr-run-app/use-pr-run-app-state.ts @@ -22,6 +22,7 @@ import type { SelectedBranchState, SelectedBranchView, } from "@/lib/components/templates/pr-run-app/types"; +import { useAppStatusSummary } from "@/lib/components/templates/pr-run-app/use-app-status-summary"; const SIDEBAR_WIDTH_STORAGE_KEY = "pr-run.sidebar.width"; const SIDEBAR_MIN_WIDTH = 256; @@ -65,6 +66,7 @@ export function usePrRunAppState() { [groups], ); usePreloadProjects(projects); + const statusSummary = useAppStatusSummary(projects); const selectedProject = useMemo( () => projects.find( @@ -306,11 +308,15 @@ export function usePrRunAppState() { pendingProjectUpdateId: updateProjectWorktreesMutation.isPending ? updateProjectWorktreesMutation.variables : undefined, + pendingWorktreeCheckoutKey: checkoutBranchMutation.isPending + ? `${checkoutBranchMutation.variables?.projectId}:${checkoutBranchMutation.variables?.branchName}` + : undefined, pendingWorktreeRemovalKey: removeWorktreeMutation.isPending ? `${removeWorktreeMutation.variables?.projectId}:${removeWorktreeMutation.variables?.branchName}` : undefined, selectedBranchView, sidebarWidth, + statusSummary, theme, beginResize: () => setIsResizingSidebar(true), closeAddProject: () => setIsAddProjectOpen(false), diff --git a/src/lib/components/templates/sidebar/index.tsx b/src/lib/components/templates/sidebar/index.tsx index f9d9c51..4e68f88 100644 --- a/src/lib/components/templates/sidebar/index.tsx +++ b/src/lib/components/templates/sidebar/index.tsx @@ -6,11 +6,14 @@ import { SidebarShell } from "@/lib/components/templates/sidebar/sidebar-shell"; import type { SidebarProps } from "@/lib/components/templates/sidebar/types"; export function Sidebar({ + busyOwnerKeys, + busyProjectIds, collapsedProjects, expandedGroups, groups, isCreatingScript, pendingProjectUpdateId, + pendingWorktreeCheckoutKey, pendingWorktreeRemovalKey, selectedBranchName, selectedProjectId, @@ -18,6 +21,7 @@ export function Sidebar({ theme, onAddProject, onBeginResize, + onCheckoutBranch, onCreateScript, onOpenSshPassphrase, onRemoveWorktree, @@ -41,14 +45,18 @@ export function Sidebar({ {groups.map((group) => ( Promise; onRemoveWorktree: (branchName: string) => Promise; onSelectBranch: (branchName: string) => void; }; export function SidebarBranchItem({ branch, + isBusy, + isCollapsedPreview = false, + isCheckingOutWorktree, isRemovingWorktree, isSelected, + onCheckoutBranch, onRemoveWorktree, onSelectBranch, }: SidebarBranchItemProps) { - const status = getBranchStatus(branch); + const status = getSidebarItemStatus(branch); + const isActionPending = isCheckingOutWorktree || isRemovingWorktree; return (
onSelectBranch(branch.name)} > - + + + {isBusy ? ( + + ) : null} + - {status.label} + + {status.label} + {formatBranchAge(branch.lastCommitTimestamp)} - {branch.hasWorktree ? ( -
+
+ {branch.hasWorktree ? ( -
- ) : null} + ) : ( + + )} +
); } - -function getBranchStatus(branch: BranchInfo): { - label: string; - tone: "branch" | "pull-request" | "stale" | "worktree"; -} { - if (branch.hasWorktree) { - return { label: "Worktree", tone: "worktree" }; - } - - if (branch.isStale) { - return { label: "Stale", tone: "stale" }; - } - - if (branch.source === "pull-request") { - return { label: "PR", tone: "pull-request" }; - } - - return { label: "Branch", tone: "branch" }; -} diff --git a/src/lib/components/templates/sidebar/sidebar-group-section.tsx b/src/lib/components/templates/sidebar/sidebar-group-section.tsx index 4b84b89..367b0e6 100644 --- a/src/lib/components/templates/sidebar/sidebar-group-section.tsx +++ b/src/lib/components/templates/sidebar/sidebar-group-section.tsx @@ -1,16 +1,21 @@ import { SidebarEmptyState } from "@/lib/components/templates/sidebar/sidebar-empty-state"; import { SidebarProjectItem } from "@/lib/components/templates/sidebar/sidebar-project-item"; +import { sortProjectsByBusyState } from "@/lib/components/templates/sidebar/sidebar-sort"; import { SidebarSectionHeader } from "@/lib/components/templates/sidebar/sidebar-section-header"; import type { SidebarProps } from "@/lib/components/templates/sidebar/types"; import type { ProjectGroup } from "@/types/pr-run"; type SidebarGroupSectionProps = Pick< SidebarProps, + | "busyOwnerKeys" + | "busyProjectIds" | "collapsedProjects" | "pendingProjectUpdateId" + | "pendingWorktreeCheckoutKey" | "pendingWorktreeRemovalKey" | "selectedBranchName" | "selectedProjectId" + | "onCheckoutBranch" | "onRemoveWorktree" | "onSelectBranch" | "onToggleGroup" @@ -22,19 +27,28 @@ type SidebarGroupSectionProps = Pick< }; export function SidebarGroupSection({ + busyOwnerKeys, + busyProjectIds, collapsedProjects, group, isExpanded, pendingProjectUpdateId, + pendingWorktreeCheckoutKey, pendingWorktreeRemovalKey, selectedBranchName, selectedProjectId, + onCheckoutBranch, onRemoveWorktree, onSelectBranch, onToggleGroup, onToggleProject, onUpdateProject, }: SidebarGroupSectionProps) { + const sortedProjects = sortProjectsByBusyState( + group.projects, + busyProjectIds, + ); + return (
) : null} - {group.projects.map((project) => ( + {sortedProjects.map((project) => ( {branch.source === "pull-request" ? ( diff --git a/src/lib/components/templates/sidebar/sidebar-item-status.ts b/src/lib/components/templates/sidebar/sidebar-item-status.ts new file mode 100644 index 0000000..9cee80f --- /dev/null +++ b/src/lib/components/templates/sidebar/sidebar-item-status.ts @@ -0,0 +1,74 @@ +import type { BranchInfo } from "@/types/pr-run"; + +export type SidebarItemStatus = + | "stale-worktree" + | "worktree" + | "stale" + | "pull-request" + | "branch"; + +type SidebarItemStatusConfig = { + iconClassName: string; + label: string; + pillClassName: string; + status: SidebarItemStatus; +}; + +const sidebarItemStatusConfigs: Record< + SidebarItemStatus, + SidebarItemStatusConfig +> = { + "stale-worktree": { + iconClassName: "bg-danger/15 text-danger-foreground", + label: "Stale Worktree", + pillClassName: "border-danger/25 bg-danger/15 text-danger-foreground", + status: "stale-worktree", + }, + worktree: { + iconClassName: "bg-success/15 text-success", + label: "Worktree", + pillClassName: + "border-success/25 bg-success/10 text-success-foreground", + status: "worktree", + }, + stale: { + iconClassName: "bg-warning/15 text-warning-foreground", + label: "Stale", + pillClassName: + "border-warning/25 bg-warning/10 text-warning-foreground", + status: "stale", + }, + "pull-request": { + iconClassName: "bg-blue-500/20 text-blue-600 dark:text-blue-300", + label: "PR", + pillClassName: + "border-blue-500/25 bg-blue-500/10 text-blue-700 dark:text-blue-300", + status: "pull-request", + }, + branch: { + iconClassName: "bg-muted/45 text-muted-foreground/75", + label: "Branch", + pillClassName: "border-border bg-muted/35 text-muted-foreground", + status: "branch", + }, +}; + +export function getSidebarItemStatus(branch: BranchInfo) { + if (branch.hasWorktree && branch.isStale) { + return sidebarItemStatusConfigs["stale-worktree"]; + } + + if (branch.hasWorktree) { + return sidebarItemStatusConfigs.worktree; + } + + if (branch.isStale) { + return sidebarItemStatusConfigs.stale; + } + + if (branch.source === "pull-request") { + return sidebarItemStatusConfigs["pull-request"]; + } + + return sidebarItemStatusConfigs.branch; +} diff --git a/src/lib/components/templates/sidebar/sidebar-project-item.tsx b/src/lib/components/templates/sidebar/sidebar-project-item.tsx index 20abd8f..4bba268 100644 --- a/src/lib/components/templates/sidebar/sidebar-project-item.tsx +++ b/src/lib/components/templates/sidebar/sidebar-project-item.tsx @@ -2,24 +2,34 @@ import { ChevronDown, ChevronRight, Folder, RefreshCw } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { isHandledSshPromptError } from "@/lib/api"; +import { BusyIcon } from "@/lib/components/atoms/busy-icon"; import { Button } from "@/lib/components/atoms/button"; import { Skeleton } from "@/lib/components/atoms/skeleton"; import { Surface } from "@/lib/components/atoms/surface"; import { SidebarBranchItem } from "@/lib/components/templates/sidebar/sidebar-branch-item"; +import { + getVisibleSidebarBranches, + sortBranchesByLastCommit, +} from "@/lib/components/templates/sidebar/sidebar-sort"; import { useProjectBranchesQuery } from "@/lib/hooks/query/use-project-branches-query"; import { useSshPassphraseStore } from "@/lib/hooks/store/use-ssh-passphrase-store"; +import { getWorktreeOwnerKey } from "@/lib/hooks/store/use-worktree-terminal-store"; import { shortenPath } from "@/lib/format"; import { cn } from "@/lib/utils/cn"; import { getErrorMessage } from "@/lib/utils/get-error-message"; import type { ProjectConfig } from "@/types/pr-run"; type SidebarProjectItemProps = { + busyOwnerKeys: Set; isExpanded: boolean; + isBusy: boolean; isSelected: boolean; isUpdatingProject: boolean; + pendingWorktreeCheckoutKey?: string; pendingWorktreeRemovalKey?: string; project: ProjectConfig; selectedBranchName?: string; + onCheckoutBranch: (projectId: string, branchName: string) => Promise; onRemoveWorktree: (projectId: string, branchName: string) => Promise; onSelectBranch: (projectId: string, branchName: string) => void; onToggleProject: (projectId: string) => void; @@ -29,12 +39,16 @@ type SidebarProjectItemProps = { const INITIAL_VISIBLE_BRANCH_COUNT = 5; export function SidebarProjectItem({ + busyOwnerKeys, isExpanded, + isBusy, isSelected, isUpdatingProject, + pendingWorktreeCheckoutKey, pendingWorktreeRemovalKey, project, selectedBranchName, + onCheckoutBranch, onRemoveWorktree, onSelectBranch, onToggleProject, @@ -44,7 +58,10 @@ export function SidebarProjectItem({ useState(false); const [areStaleBranchesVisible, setAreStaleBranchesVisible] = useState(false); - const branchesQuery = useProjectBranchesQuery(project.id, isExpanded); + const branchesQuery = useProjectBranchesQuery( + project.id, + isExpanded || isBusy, + ); const isAwaitingSshPassphrase = isHandledSshPromptError( branchesQuery.error, ); @@ -52,26 +69,27 @@ export function SidebarProjectItem({ ? getErrorMessage(branchesQuery.error) : undefined; const sortedBranches = useMemo( - () => - [...(branchesQuery.data ?? [])].sort( - (left, right) => - (right.lastCommitTimestamp ?? 0) - - (left.lastCommitTimestamp ?? 0), - ), + () => sortBranchesByLastCommit(branchesQuery.data ?? []), [branchesQuery.data], ); - const recentBranches = sortedBranches.filter((branch) => !branch.isStale); - const staleBranches = sortedBranches.filter((branch) => branch.isStale); - const hiddenRecentBranchCount = Math.max( - recentBranches.length - INITIAL_VISIBLE_BRANCH_COUNT, - 0, + const { hiddenRecentBranchCount, staleBranches, visibleBranches } = useMemo( + () => + getVisibleSidebarBranches({ + areAllRecentBranchesVisible, + areStaleBranchesVisible, + branches: sortedBranches, + busyOwnerKeys, + initialVisibleBranchCount: INITIAL_VISIBLE_BRANCH_COUNT, + projectId: project.id, + }), + [ + areAllRecentBranchesVisible, + areStaleBranchesVisible, + busyOwnerKeys, + project.id, + sortedBranches, + ], ); - const visibleBranches = [ - ...(areAllRecentBranchesVisible - ? recentBranches - : recentBranches.slice(0, INITIAL_VISIBLE_BRANCH_COUNT)), - ...(areStaleBranchesVisible ? staleBranches : []), - ]; useEffect(() => { if (!isAwaitingSshPassphrase) { @@ -84,6 +102,7 @@ export function SidebarProjectItem({ branchesQuery.refetch().then(() => undefined), ); }, [branchesQuery, isAwaitingSshPassphrase]); + const isActionVisible = isUpdatingProject; return (
@@ -119,10 +138,19 @@ export function SidebarProjectItem({ shrink-0" /> )} - + + + {isBusy ? ( + + ) : null} +
{shortenPath(project.path)}
- {isExpanded ? ( + {isExpanded || isBusy ? (
- {branchesQuery.isPending ? ( + {branchesQuery.isPending && isExpanded ? (
@@ -183,7 +224,9 @@ export function SidebarProjectItem({
) : null} - {!branchesQuery.isPending && isAwaitingSshPassphrase ? ( + {!branchesQuery.isPending && + isExpanded && + isAwaitingSshPassphrase ? ( ) : null} - {(areAllRecentBranchesVisible || + {isExpanded && + (areAllRecentBranchesVisible || hiddenRecentBranchCount === 0) && !areStaleBranchesVisible && staleBranches.length > 0 ? ( diff --git a/src/lib/components/templates/sidebar/sidebar-shell.tsx b/src/lib/components/templates/sidebar/sidebar-shell.tsx index 86c47d3..9300e13 100644 --- a/src/lib/components/templates/sidebar/sidebar-shell.tsx +++ b/src/lib/components/templates/sidebar/sidebar-shell.tsx @@ -9,7 +9,7 @@ export function SidebarShell({ children, sidebarWidth }: SidebarShellProps) { return (