230 lines · 7.4 KB · TypeScript
RawDownload
/** * RepositoryDropdown - Dropdown for selecting repository groups. * * Features: * - Shows repository groups (not individual worktrees) * - Displays worktree count and total sessions * - Click outside to close * - Keyboard navigation (Escape to close) * - Filter out already selected items */ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useStore } from '@renderer/store';import { ChevronDown, FolderOpen, GitBranch } from 'lucide-react'; import type { RepositoryDropdownItem } from '@renderer/components/settings/hooks/useSettingsConfig'; interface RepositoryDropdownProps {  /** Callback when a repository is selected */  onSelect: (item: RepositoryDropdownItem) => void;  /** IDs of items to exclude from the list */  excludeIds?: string[];  /** Placeholder text */  placeholder?: string;  /** Whether the dropdown is disabled */  disabled?: boolean;  /** Whether to drop up instead of down */  dropUp?: boolean;  /** Custom class for the container */  className?: string;} export const RepositoryDropdown = ({  onSelect,  excludeIds = [],  placeholder = 'Select repository...',  disabled = false,  dropUp = false,  className = '',}: Readonly<RepositoryDropdownProps>): React.JSX.Element => {  const [isOpen, setIsOpen] = useState(false);  const containerRef = useRef<HTMLDivElement>(null);   // Get repository groups from store  const repositoryGroups = useStore((state) => state.repositoryGroups);  const fetchRepositoryGroups = useStore((state) => state.fetchRepositoryGroups);   // Fetch data if not loaded  useEffect(() => {    if (repositoryGroups.length === 0) {      void fetchRepositoryGroups();    }  }, [repositoryGroups.length, fetchRepositoryGroups]);   // Convert repository groups to dropdown items  const allItems = useMemo((): RepositoryDropdownItem[] => {    return repositoryGroups.map((group) => ({      id: group.id,      name: group.name,      path: group.worktrees[0]?.path ?? '',      worktreeCount: group.worktrees.length,      totalSessions: group.totalSessions,    }));  }, [repositoryGroups]);   // Filter out excluded items  const availableItems = useMemo(() => {    return allItems.filter((item) => !excludeIds.includes(item.id));  }, [allItems, excludeIds]);   // Close dropdown on outside click  useEffect(() => {    const handleClickOutside = (event: MouseEvent): void => {      if (containerRef.current && !containerRef.current.contains(event.target as Node)) {        setIsOpen(false);      }    };     if (isOpen) {      document.addEventListener('mousedown', handleClickOutside);      return () => document.removeEventListener('mousedown', handleClickOutside);    }  }, [isOpen]);   // Close on escape  useEffect(() => {    const handleEscape = (event: KeyboardEvent): void => {      if (event.key === 'Escape') {        setIsOpen(false);      }    };     if (isOpen) {      document.addEventListener('keydown', handleEscape);      return () => document.removeEventListener('keydown', handleEscape);    }  }, [isOpen]);   const handleSelect = (item: RepositoryDropdownItem): void => {    onSelect(item);    setIsOpen(false);  };   const isEmpty = availableItems.length === 0;   return (    <div ref={containerRef} className={`relative ${className}`}>      {/* Trigger Button */}      <button        type="button"        onClick={() => !disabled && !isEmpty && setIsOpen(!isOpen)}        disabled={disabled || isEmpty}        className={`flex w-full items-center justify-between gap-2 rounded border border-border bg-transparent px-2 py-1.5 text-xs text-text-secondary transition-colors hover:bg-surface-raised ${disabled || isEmpty ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} `}      >        <span className="flex items-center gap-2">          <FolderOpen className="size-3" />          {isEmpty ? 'No repositories available' : placeholder}        </span>        <ChevronDown          className={`size-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}        />      </button>       {/* Dropdown Menu */}      {isOpen && !isEmpty && (        <div          className={`absolute inset-x-0 z-50 max-h-64 overflow-y-auto rounded border border-border bg-surface-overlay py-1 shadow-lg ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'} `}        >          {availableItems.map((item) => (            <RepositoryDropdownItemComponent              key={item.id}              item={item}              onSelect={() => handleSelect(item)}            />          ))}        </div>      )}    </div>  );}; /** * Individual item in the dropdown. */const RepositoryDropdownItemComponentInner = ({  item,  onSelect,}: Readonly<{  item: RepositoryDropdownItem;  onSelect: () => void;}>): React.JSX.Element => {  return (    <button      type="button"      onClick={onSelect}      className="flex w-full items-center gap-2 px-2 py-1.5 text-left transition-colors hover:bg-surface-raised"    >      <FolderOpen className="size-3 shrink-0 text-indigo-400" />      <div className="min-w-0 flex-1">        <div className="flex items-center gap-1.5">          <span className="truncate text-xs text-text">{item.name}</span>          {item.worktreeCount > 1 && (            <span className="flex shrink-0 items-center gap-0.5 rounded bg-surface-raised px-1 py-0.5 text-[10px] text-text-muted">              <GitBranch className="size-2.5" />              {item.worktreeCount}            </span>          )}          <span className="shrink-0 text-[10px] text-text-muted">            {item.totalSessions} session{item.totalSessions !== 1 ? 's' : ''}          </span>        </div>        <span className="block truncate text-[10px] text-text-muted">{item.path}</span>      </div>    </button>  );}; const RepositoryDropdownItemComponent = React.memo(RepositoryDropdownItemComponentInner); /** * Selected repository item with remove button. */const SelectedRepositoryItemInner = ({  item,  onRemove,  disabled = false,}: Readonly<{  item: RepositoryDropdownItem;  onRemove: () => void;  disabled?: boolean;}>): React.JSX.Element => {  return (    <div className="flex items-center gap-2 border-b border-border-subtle py-1.5">      <FolderOpen className="size-3 shrink-0 text-indigo-400" />      <div className="min-w-0 flex-1">        <div className="flex items-center gap-1.5">          <span className="truncate text-xs text-text">{item.name}</span>          {item.worktreeCount > 1 && (            <span className="flex shrink-0 items-center gap-0.5 rounded bg-surface-raised px-1 py-0.5 text-[10px] text-text-muted">              <GitBranch className="size-2.5" />              {item.worktreeCount}            </span>          )}        </div>        <span className="truncate text-[10px] text-text-muted" title={item.path}>          {item.path}        </span>      </div>      <button        type="button"        onClick={onRemove}        disabled={disabled}        className={`shrink-0 rounded p-1 text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 ${disabled ? 'cursor-not-allowed opacity-50' : ''} `}        aria-label="Remove repository"      >        <svg className="size-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">          <path            strokeLinecap="round"            strokeLinejoin="round"            strokeWidth={2}            d="M6 18L18 6M6 6l12 12"          />        </svg>      </button>    </div>  );}; export const SelectedRepositoryItem = React.memo(SelectedRepositoryItemInner);