diff --git a/docs/CUSTOM_THEMES.md b/docs/CUSTOM_THEMES.md index 905a6af5..db04adec 100644 --- a/docs/CUSTOM_THEMES.md +++ b/docs/CUSTOM_THEMES.md @@ -82,6 +82,13 @@ OpenChamber supports user-defined themes. Drop a JSON file into the themes direc "infoBackground": "#205EA620", "infoBorder": "#205EA650" }, + "pr": { + "open": "#A0AF54", + "draft": "#878580", + "blocked": "#DA702C", + "merged": "#8B7EC8", + "closed": "#D14D41" + }, "syntax": { "base": { "background": "#1C1B1A", diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 6eef58e4..13e22bbf 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -10,15 +10,16 @@ import { PointerSensor, useSensor, useSensors, - type DragEndEvent, - type DragStartEvent, + type Modifier, } from '@dnd-kit/core'; import { SortableContext, + arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { DropdownMenu, DropdownMenuContent, @@ -40,6 +41,9 @@ import { RiFileCopyLine, RiFolderAddLine, RiGitBranchLine, + RiGitClosePullRequestLine, + RiGitMergeLine, + RiGitPrDraftLine, RiGitPullRequestLine, RiLinkUnlinkM, @@ -62,14 +66,29 @@ import type { WorktreeMetadata } from '@/types/worktree'; import { opencodeClient } from '@/lib/opencode/client'; import { checkIsGitRepository } from '@/lib/gitApi'; import { getSafeStorage } from '@/stores/utils/safeStorage'; -import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; +import { createWorktreeOnly, createWorktreeSession } from '@/lib/worktreeSessionCreator'; +import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { isVSCodeRuntime } from '@/lib/desktop'; import { updateDesktopSettings } from '@/lib/persistence'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import type { GitHubPullRequestStatus } from '@/lib/api/types'; import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog'; import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog'; +const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]); + +const getAttentionDiamondDelay = (index: number): string => { + return index === 4 ? '0ms' : '130ms'; +}; + const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse'; +const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder'; +const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse'; +const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject'; const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents'; +const PR_REVALIDATE_TTL_MS = 90_000; +const PR_REVALIDATE_INTERVAL_MS = 90_000; +const PR_REVALIDATE_CONCURRENCY = 3; const formatDateLabel = (value: string | number) => { const targetDate = new Date(value); @@ -104,6 +123,84 @@ const normalizePath = (value?: string | null) => { return normalized.length === 0 ? '/' : normalized; }; +const toFiniteNumber = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return undefined; +}; + +const getPrVisualState = (status: GitHubPullRequestStatus | null): 'draft' | 'open' | 'blocked' | 'merged' | 'closed' | null => { + const pr = status?.pr; + if (!pr) { + return null; + } + if (pr.state === 'merged') { + return 'merged'; + } + if (pr.state === 'closed') { + return 'closed'; + } + if (pr.draft) { + return 'draft'; + } + const checksFailed = status?.checks?.state === 'failure'; + const notMergeable = status?.canMerge === false || pr.mergeable === false; + if (checksFailed || notMergeable) { + return 'blocked'; + } + return 'open'; +}; + +const getPrTooltipLabel = (status: GitHubPullRequestStatus | null): string => { + const pr = status?.pr; + if (!pr) { + return 'Open pull request'; + } + const parts: string[] = [`PR #${pr.number}`]; + if (pr.state === 'merged') { + parts.push('Merged'); + } else if (pr.state === 'closed') { + parts.push('Closed'); + } else if (pr.draft) { + parts.push('Draft'); + } else { + parts.push('Open'); + } + if (status?.checks?.state === 'failure') { + parts.push('Checks failing'); + } + if (status?.canMerge === false || pr.mergeable === false) { + parts.push('Merge blocked'); + } + return parts.join(' · '); +}; + +type TauriShell = { + shell?: { + open?: (url: string) => Promise; + }; +}; + +const centerDragOverlayUnderPointer: Modifier = ({ transform, activeNodeRect, activatorEvent }) => { + if (!(activatorEvent instanceof MouseEvent) || !activeNodeRect) { + return transform; + } + const overlayHeight = 32; + const pointerLiftY = 16; + return { + ...transform, + x: transform.x, + y: transform.y - overlayHeight / 2 - pointerLiftY, + }; +}; + // Format project label: kebab-case/snake_case → Title Case const formatProjectLabel = (label: string): string => { return label @@ -156,6 +253,8 @@ interface SortableProjectItemProps { sentinelRef: (el: HTMLDivElement | null) => void; children?: React.ReactNode; settingsAutoCreateWorktree: boolean; + showCreateButtons?: boolean; + hideHeader?: boolean; } const SortableProjectItem: React.FC = ({ @@ -187,6 +286,8 @@ const SortableProjectItem: React.FC = ({ sentinelRef, children, settingsAutoCreateWorktree, + showCreateButtons = true, + hideHeader = false, }) => { const { attributes, @@ -199,41 +300,43 @@ const SortableProjectItem: React.FC = ({ return (
- {/* Sentinel for sticky detection */} - {isDesktopShell && ( -