546 lines · 18.9 KB · TypeScript
RawDownload
/** * SidebarHeader - Linear-style header with project name and worktree selector. * * Layout (2 stacked horizontal bars): * - Row 1: Project name (left-aligned after macOS traffic lights) * - Row 2: Worktree selector (full-width button) * * Visual requirements: * - Row 1 is the drag region for window movement * - Row 1 reserves left space for macOS traffic lights via shared layout CSS variable * - Row 2 is a full-width button with no side margins */ import { useEffect, useRef, useState } from 'react'; import { isElectronMode } from '@renderer/api';import { HEADER_ROW1_HEIGHT, HEADER_ROW2_HEIGHT } from '@renderer/constants/layout';import { useStore } from '@renderer/store';import { truncateMiddle } from '@renderer/utils/stringUtils';import { Check, ChevronDown, GitBranch, PanelLeft } from 'lucide-react';import { useShallow } from 'zustand/react/shallow'; import { WorktreeBadge } from '../common/WorktreeBadge'; import type { Worktree, WorktreeSource } from '@renderer/types/data'; /** * Group worktrees by source for organized dropdown display. * Returns: main worktree first, then groups sorted by most recent activity. */interface WorktreeGroup {  source: WorktreeSource;  label: string;  worktrees: Worktree[];  mostRecent: number;} const SOURCE_LABELS: Record<WorktreeSource, string> = {  'vibe-kanban': 'Vibe Kanban',  conductor: 'Conductor',  'auto-claude': 'Auto Claude',  '21st': '21st',  'claude-desktop': 'Claude Desktop',  ccswitch: 'ccswitch',  git: 'Git',  unknown: 'Other',}; function groupWorktreesBySource(worktrees: Worktree[]): {  mainWorktree: Worktree | null;  groups: WorktreeGroup[];} {  // Find main worktree  const mainWorktree = worktrees.find((w) => w.isMainWorktree) ?? null;   // Group remaining worktrees by source  const groupMap = new Map<WorktreeSource, Worktree[]>();   for (const wt of worktrees) {    if (wt.isMainWorktree) continue; // Skip main, handled separately     const existing = groupMap.get(wt.source) ?? [];    existing.push(wt);    groupMap.set(wt.source, existing);  }   // Convert to array and sort each group internally by most recent  const groups: WorktreeGroup[] = [];   for (const [source, wts] of groupMap) {    // Sort worktrees within group by most recent    const sorted = [...wts].sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));     const mostRecent = Math.max(...sorted.map((w) => w.mostRecentSession ?? 0));     groups.push({      source,      label: SOURCE_LABELS[source] ?? source,      worktrees: sorted,      mostRecent,    });  }   // Sort groups by most recent activity  groups.sort((a, b) => b.mostRecent - a.mostRecent);   return { mainWorktree, groups };} /** * Individual worktree item in the dropdown. */interface WorktreeItemProps {  worktree: Worktree;  isSelected: boolean;  onSelect: () => void;} const WorktreeItem = ({  worktree,  isSelected,  onSelect,}: Readonly<WorktreeItemProps>): React.JSX.Element => {  const [isHovered, setIsHovered] = useState(false);   const buttonStyle: React.CSSProperties = isSelected    ? { backgroundColor: 'var(--color-surface-raised)', color: 'var(--color-text)' }    : {        backgroundColor: isHovered ? 'var(--color-surface-raised)' : 'transparent',        opacity: isHovered ? 0.5 : 1,      };   return (    <button      onClick={onSelect}      onMouseEnter={() => setIsHovered(true)}      onMouseLeave={() => setIsHovered(false)}      className="flex w-full items-center gap-1.5 px-4 py-1.5 text-left transition-colors"      style={buttonStyle}    >      <GitBranch        className="size-3.5 shrink-0"        style={{ color: isSelected ? '#34d399' : 'var(--color-text-muted)' }}      />      {/* Only show badge for main worktree - others are grouped by header */}      {worktree.isMainWorktree && <WorktreeBadge source={worktree.source} isMain />}      <span        className="flex-1 truncate font-mono text-xs"        style={{ color: isSelected ? 'var(--color-text)' : 'var(--color-text-muted)' }}      >        {truncateMiddle(worktree.name, 28)}      </span>      <span className="shrink-0 text-[10px]" style={{ color: 'var(--color-text-muted)' }}>        {worktree.sessions.length}      </span>      {isSelected && <Check className="size-3.5 shrink-0 text-indigo-400" />}    </button>  );}; /** * Individual project/repository item in the dropdown. */interface ProjectDropdownItemProps {  name: string;  path?: string;  sessionCount: number;  isSelected: boolean;  onSelect: () => void;} const ProjectDropdownItem = ({  name,  path,  sessionCount,  isSelected,  onSelect,}: Readonly<ProjectDropdownItemProps>): React.JSX.Element => {  const [isHovered, setIsHovered] = useState(false);   const buttonStyle: React.CSSProperties = isSelected    ? { backgroundColor: 'var(--color-surface-raised)', color: 'var(--color-text)' }    : {        backgroundColor: isHovered ? 'var(--color-surface-raised)' : 'transparent',        opacity: isHovered ? 0.5 : 1,      };   return (    <button      onClick={onSelect}      onMouseEnter={() => setIsHovered(true)}      onMouseLeave={() => setIsHovered(false)}      className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors"      style={buttonStyle}    >      <div className="min-w-0 flex-1">        <span          className={`block truncate text-sm ${isSelected ? 'font-medium' : ''}`}          style={{ color: isSelected ? 'var(--color-text)' : 'var(--color-text-muted)' }}        >          {name}        </span>        {path && (          <span className="block truncate text-[10px]" style={{ color: 'var(--color-text-muted)' }}>            {path}          </span>        )}      </div>      <span className="shrink-0 text-[10px]" style={{ color: 'var(--color-text-muted)' }}>        {sessionCount}      </span>      {isSelected && <Check className="size-3.5 shrink-0 text-indigo-400" />}    </button>  );}; export const SidebarHeader = (): React.JSX.Element => {  const isMacElectron =    isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac');   const {    repositoryGroups,    selectedRepositoryId,    selectedWorktreeId,    selectWorktree,    selectRepository,    viewMode,    projects,    activeProjectId,    setActiveProject,    fetchRepositoryGroups,    fetchProjects,    toggleSidebar,  } = useStore(    useShallow((s) => ({      repositoryGroups: s.repositoryGroups,      selectedRepositoryId: s.selectedRepositoryId,      selectedWorktreeId: s.selectedWorktreeId,      selectWorktree: s.selectWorktree,      selectRepository: s.selectRepository,      viewMode: s.viewMode,      projects: s.projects,      activeProjectId: s.activeProjectId,      setActiveProject: s.setActiveProject,      fetchRepositoryGroups: s.fetchRepositoryGroups,      fetchProjects: s.fetchProjects,      toggleSidebar: s.toggleSidebar,    }))  );   // Fetch data on mount based on view mode  useEffect(() => {    if (viewMode === 'grouped' && repositoryGroups.length === 0) {      void fetchRepositoryGroups();    } else if (viewMode === 'flat' && projects.length === 0) {      void fetchProjects();    }  }, [viewMode, repositoryGroups.length, projects.length, fetchRepositoryGroups, fetchProjects]);   const [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false);  const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);  const worktreeDropdownRef = useRef<HTMLDivElement>(null);  const projectDropdownRef = useRef<HTMLDivElement>(null);   // Find the active repository and worktree  const activeRepo = repositoryGroups.find((r) => r.id === selectedRepositoryId);  const activeWorktree = activeRepo?.worktrees.find((w) => w.id === selectedWorktreeId);  // Filter worktrees to only show those with sessions  const worktrees = (activeRepo?.worktrees ?? []).filter((w) => w.sessions.length > 0);  const hasMultipleWorktrees = worktrees.length > 1;   // Group worktrees by source for organized dropdown  const worktreeGroupingResult = groupWorktreesBySource(worktrees);  const mainWorktree = worktreeGroupingResult.mainWorktree;  const worktreeGroups = worktreeGroupingResult.groups;   // For flat mode  const activeProject = projects.find((p) => p.id === activeProjectId);   // Get display name  const projectName =    viewMode === 'grouped'      ? (activeRepo?.name ?? 'Select Project')      : (activeProject?.name ?? 'Select Project');   const worktreeName = activeWorktree?.name ?? 'main';  const hasSelection = viewMode === 'grouped' ? !!activeRepo : !!activeProject;   // Close dropdowns on outside click  useEffect(() => {    function handleClickOutside(event: MouseEvent): void {      if (        worktreeDropdownRef.current &&        !worktreeDropdownRef.current.contains(event.target as Node)      ) {        setIsWorktreeDropdownOpen(false);      }      if (        projectDropdownRef.current &&        !projectDropdownRef.current.contains(event.target as Node)      ) {        setIsProjectDropdownOpen(false);      }    }    document.addEventListener('mousedown', handleClickOutside);    return () => document.removeEventListener('mousedown', handleClickOutside);  }, []);   // Close on escape  useEffect(() => {    function handleEscape(event: KeyboardEvent): void {      if (event.key === 'Escape') {        setIsWorktreeDropdownOpen(false);        setIsProjectDropdownOpen(false);      }    }    document.addEventListener('keydown', handleEscape);    return () => document.removeEventListener('keydown', handleEscape);  }, []);   const handleSelectWorktree = (worktree: Worktree): void => {    selectWorktree(worktree.id);    setIsWorktreeDropdownOpen(false);  };   const handleSelectRepo = (repoId: string): void => {    selectRepository(repoId);    setIsProjectDropdownOpen(false);  };   const handleSelectProject = (projectId: string): void => {    setActiveProject(projectId);    setIsProjectDropdownOpen(false);  };   // Items for project dropdown - filter out repositories/projects with 0 sessions  const projectItems =    viewMode === 'grouped'      ? repositoryGroups.filter((r) => r.totalSessions > 0)      : projects.filter((p) => p.sessions.length > 0);   const [isCollapseHovered, setIsCollapseHovered] = useState(false);   return (    <div      className="flex w-full flex-col"      style={{ backgroundColor: 'var(--color-surface-sidebar)' }}    >      {/* ROW 1: Project Identity (Title Bar / Drag Region) */}      <div        ref={projectDropdownRef}        className="relative flex select-none items-center gap-2 pr-2"        style={          {            height: `${HEADER_ROW1_HEIGHT}px`,            paddingLeft: isMacElectron ? 'var(--macos-traffic-light-padding-left, 72px)' : '16px',            WebkitAppRegion: isMacElectron ? 'drag' : undefined,          } as React.CSSProperties        }      >        {/* Project name dropdown button */}        <button          onClick={() => setIsProjectDropdownOpen(!isProjectDropdownOpen)}          className="flex min-w-0 items-center gap-2 transition-opacity hover:opacity-80"          style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}        >          <span            className="min-w-0 truncate text-sm font-bold tracking-tight"            style={{ color: hasSelection ? 'var(--color-text)' : 'var(--color-text-muted)' }}          >            {projectName}          </span>          <ChevronDown            className={`size-3.5 shrink-0 transition-transform ${isProjectDropdownOpen ? 'rotate-180' : ''}`}            style={{ color: 'var(--color-text-muted)' }}          />        </button>         {/* Collapse sidebar button */}        <button          onClick={toggleSidebar}          onMouseEnter={() => setIsCollapseHovered(true)}          onMouseLeave={() => setIsCollapseHovered(false)}          className="ml-auto shrink-0 rounded-md p-1.5 transition-colors"          style={            {              WebkitAppRegion: 'no-drag',              color: isCollapseHovered ? 'var(--color-text-secondary)' : 'var(--color-text-muted)',              backgroundColor: isCollapseHovered ? 'var(--color-surface-raised)' : 'transparent',            } as React.CSSProperties          }          title="Collapse sidebar (⌘B)"        >          <PanelLeft className="size-4" />        </button>         {/* Project Dropdown */}        {isProjectDropdownOpen && (          <>            <div              role="presentation"              className="fixed inset-0 z-10"              onClick={() => setIsProjectDropdownOpen(false)}            />            <div              className="absolute inset-x-4 top-full z-20 mt-1 max-h-[350px] overflow-y-auto rounded-lg py-1 shadow-xl"              style={{                backgroundColor: 'var(--color-surface-sidebar)',                borderWidth: '1px',                borderStyle: 'solid',                borderColor: 'var(--color-border)',              }}            >              <div                className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider"                style={{ color: 'var(--color-text-muted)' }}              >                Switch {viewMode === 'grouped' ? 'Repository' : 'Project'}              </div>               {projectItems.length === 0 ? (                <div className="p-3 text-sm" style={{ color: 'var(--color-text-muted)' }}>                  No {viewMode === 'grouped' ? 'repositories' : 'projects'} found                </div>              ) : (                projectItems.map((item) => {                  const isSelected =                    viewMode === 'grouped'                      ? item.id === selectedRepositoryId                      : item.id === activeProjectId;                  const itemSessions =                    viewMode === 'grouped'                      ? (item as (typeof repositoryGroups)[0]).totalSessions                      : (item as (typeof projects)[0]).sessions.length;                  // Get path for display                  const itemPath =                    viewMode === 'grouped'                      ? (item as (typeof repositoryGroups)[0]).worktrees[0]?.path                      : (item as (typeof projects)[0]).path;                   return (                    <ProjectDropdownItem                      key={item.id}                      name={item.name}                      path={itemPath}                      sessionCount={itemSessions}                      isSelected={isSelected}                      onSelect={() =>                        viewMode === 'grouped'                          ? handleSelectRepo(item.id)                          : handleSelectProject(item.id)                      }                    />                  );                })              )}            </div>          </>        )}      </div>       {/* ROW 2: Worktree Selector (Full Width) */}      {viewMode === 'grouped' && activeRepo && (        <div ref={worktreeDropdownRef} className="relative w-full">          <button            onClick={() =>              hasMultipleWorktrees && setIsWorktreeDropdownOpen(!isWorktreeDropdownOpen)            }            disabled={!hasMultipleWorktrees}            className={`flex w-full items-center justify-between px-4 text-left transition-colors ${hasMultipleWorktrees ? 'cursor-pointer' : 'cursor-default'}`}            style={{              height: `${HEADER_ROW2_HEIGHT}px`,              backgroundColor: isWorktreeDropdownOpen                ? 'var(--color-surface-raised)'                : 'var(--color-surface-sidebar)',              color: isWorktreeDropdownOpen ? 'var(--color-text)' : 'var(--color-text-muted)',            }}          >            <div className="flex flex-1 items-center gap-1.5 overflow-hidden">              <GitBranch                className="size-4 shrink-0"                style={{ color: isWorktreeDropdownOpen ? '#34d399' : 'rgba(52, 211, 153, 0.7)' }}              />              {activeWorktree?.isMainWorktree ? (                <WorktreeBadge source={activeWorktree.source} isMain />              ) : (                activeWorktree?.source && <WorktreeBadge source={activeWorktree.source} />              )}              <span className="truncate font-mono text-xs">{truncateMiddle(worktreeName, 28)}</span>            </div>            {hasMultipleWorktrees && (              <ChevronDown                className={`size-4 shrink-0 transition-transform ${isWorktreeDropdownOpen ? 'rotate-180' : ''}`}                style={{ color: 'var(--color-text-muted)' }}              />            )}          </button>           {/* Worktree Dropdown */}          {isWorktreeDropdownOpen && hasMultipleWorktrees && (            <>              <div                role="presentation"                className="fixed inset-0 z-10"                onClick={() => setIsWorktreeDropdownOpen(false)}              />              <div                className="absolute inset-x-0 top-full z-20 mt-0 max-h-[400px] overflow-y-auto py-1 shadow-xl"                style={{                  backgroundColor: 'var(--color-surface-sidebar)',                  borderWidth: '1px',                  borderTopWidth: '0',                  borderStyle: 'solid',                  borderColor: 'var(--color-border)',                }}              >                <div                  className="px-4 py-2 text-[10px] font-semibold uppercase tracking-wider"                  style={{ color: 'var(--color-text-muted)' }}                >

Lines 1–500 of 546.