spool-memory 0.1.1

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
import * as React from 'react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { useQuery } from '@tanstack/react-query'
import { ChevronsUpDown, Check, Layers } from 'lucide-react'
import { useConfig, buildDaemonRequest } from '@/state/config'
import { ipc } from '@/lib/api/desktop'
import { qk } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'

const ALL_PROJECTS_LABEL = 'All Projects'

export function ProjectSwitcher() {
  const cfg = useConfig()
  const daemon = React.useMemo(() => buildDaemonRequest(cfg), [cfg])
  const configPath = cfg.configPath

  const workbenchQuery = useQuery({
    queryKey: qk.workbench(configPath),
    queryFn: () =>
      ipc.loadWorkbench({
        config_path: configPath,
        daemon,
      }),
    enabled: configPath.trim().length > 0,
  })

  const projectIds = React.useMemo(() => {
    const snap = workbenchQuery.data?.snapshot
    if (!snap) return [] as string[]
    const set = new Set<string>()
    for (const entry of [...snap.pending_review, ...snap.wakeup_ready]) {
      const pid = entry.record.project_id
      if (pid && pid.trim()) set.add(pid)
    }
    return Array.from(set).sort()
  }, [workbenchQuery.data])

  const current = cfg.currentProjectId
  const currentLabel = current ?? ALL_PROJECTS_LABEL

  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger asChild>
        <button
          type="button"
          className={cn(
            'flex w-full items-center gap-2 rounded-md border border-border/60 bg-background px-2 py-1.5 text-left text-xs',
            'transition-colors hover:bg-muted/50 focus:outline-none focus:ring-1 focus:ring-ring',
          )}
        >
          <Layers className="h-3.5 w-3.5 text-muted-foreground" />
          <span className="flex-1 truncate font-medium">{currentLabel}</span>
          <ChevronsUpDown className="h-3 w-3 text-muted-foreground" />
        </button>
      </DropdownMenu.Trigger>
      <DropdownMenu.Portal>
        <DropdownMenu.Content
          align="start"
          sideOffset={4}
          className={cn(
            'z-50 min-w-[10rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
            'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
          )}
        >
          <ProjectMenuItem
            label={ALL_PROJECTS_LABEL}
            active={current === null}
            onSelect={() => cfg.setCurrentProjectId(null)}
          />
          {projectIds.length > 0 && (
            <DropdownMenu.Separator className="my-1 h-px bg-border" />
          )}
          {projectIds.map((pid) => (
            <ProjectMenuItem
              key={pid}
              label={pid}
              active={current === pid}
              onSelect={() => cfg.setCurrentProjectId(pid)}
            />
          ))}
          {projectIds.length === 0 && (
            <div className="px-2 py-1 text-[10px] text-muted-foreground">
              暂无候选项目
            </div>
          )}
        </DropdownMenu.Content>
      </DropdownMenu.Portal>
    </DropdownMenu.Root>
  )
}

function ProjectMenuItem({
  label,
  active,
  onSelect,
}: {
  label: string
  active: boolean
  onSelect: () => void
}) {
  return (
    <DropdownMenu.Item
      onSelect={onSelect}
      className={cn(
        'flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-xs outline-none',
        'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',
      )}
    >
      <Check className={cn('h-3 w-3', active ? 'opacity-100' : 'opacity-0')} />
      <span className="flex-1 truncate">{label}</span>
    </DropdownMenu.Item>
  )
}