spool-memory 0.1.1

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
import * as React from 'react'
import { useQuery } from '@tanstack/react-query'
import { useConfig } from '@/state/config'
import { ipc } from '@/lib/api/desktop'
import { asEnvelope } from '@/lib/error'
import { qk } from '@/lib/queryKeys'
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ErrorBanner } from '@/components/error-banner'
import { Loader2 } from 'lucide-react'

interface IndexSection {
  heading: string
  entries: IndexEntry[]
}

interface IndexEntry {
  title: string
  memoryType: string | null
  recordId: string | null
  raw: string
}

function parseIndexMarkdown(markdown: string): IndexSection[] {
  const sections: IndexSection[] = []
  let current: IndexSection | null = null

  for (const line of markdown.split('\n')) {
    const headingMatch = line.match(/^##\s+(.+)/)
    if (headingMatch) {
      current = { heading: headingMatch[1], entries: [] }
      sections.push(current)
      continue
    }
    const entryMatch = line.match(/^-\s+(.+)/)
    if (entryMatch && current) {
      const raw = entryMatch[1]
      const typeMatch = raw.match(/\[(\w+)\]/)
      const recordIdMatch = raw.match(/`([^`]+)`/)
      const titleMatch = raw.match(/\]\s*(.+?)\s*—/)
      current.entries.push({
        title: titleMatch ? titleMatch[1].trim() : raw.replace(/[★·]\s*/, '').replace(/\[.*?\]\s*/, '').replace(/\s*—.*/, '').trim(),
        memoryType: typeMatch ? typeMatch[1] : null,
        recordId: recordIdMatch ? recordIdMatch[1] : null,
        raw,
      })
    }
  }
  return sections
}

export function IndexNavPage({
  onNavigateToRecord,
}: {
  onNavigateToRecord?: (recordId: string) => void
}) {
  const cfg = useConfig()
  const configPath = cfg.configPath

  const indexQuery = useQuery({
    queryKey: qk.wikiIndex(configPath, null),
    queryFn: () =>
      ipc.readWikiIndex({
        config_path: configPath,
        project_id: null,
      }),
    enabled: configPath.trim().length > 0,
  })

  const markdown = indexQuery.data?.markdown ?? null
  const error = indexQuery.error
  const errorEnvelope = error ? asEnvelope(error) : null
  const loading = indexQuery.isFetching

  const sections = React.useMemo(
    () => (markdown ? parseIndexMarkdown(markdown) : []),
    [markdown]
  )

  return (
    <div className="space-y-4">
      {errorEnvelope && <ErrorBanner envelope={errorEnvelope} />}
      <Card>
        <CardHeader className="pb-3">
          <div className="flex items-center justify-between">
            <div>
              <CardTitle className="text-base flex items-center gap-2">
                知识导航
                {loading && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
              </CardTitle>
              <CardDescription className="text-xs">
                按 scope 分组的记忆索引
              </CardDescription>
            </div>
          </div>
        </CardHeader>
        <CardContent>
          {loading && sections.length === 0 && (
            <div className="flex items-center justify-center py-8">
              <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
            </div>
          )}
          {!loading && markdown === null && (
            <div className="py-8 text-center">
              <p className="text-sm text-muted-foreground">
                INDEX 尚未生成。
              </p>
              <p className="mt-2 text-xs text-muted-foreground">
                运行 <code className="rounded bg-muted px-1 py-0.5">spool memory sync-index</code> 生成索引。
              </p>
            </div>
          )}
          {!loading && markdown !== null && sections.length === 0 && (
            <div className="py-8 text-center">
              <p className="text-sm text-muted-foreground">INDEX 为空。</p>
            </div>
          )}
          {sections.length > 0 && (
            <div className="space-y-4">
              {sections.map((section) => (
                <div key={section.heading}>
                  <h3 className="mb-2 text-sm font-medium text-foreground">
                    {section.heading}
                  </h3>
                  <ul className="space-y-1">
                    {section.entries.map((entry, i) => (
                      <li key={`${section.heading}-${i}`} className="flex items-center gap-2">
                        {entry.recordId && onNavigateToRecord ? (
                          <button
                            type="button"
                            className="text-left text-sm text-primary transition-colors hover:underline"
                            onClick={() => onNavigateToRecord(entry.recordId!)}
                          >
                            {entry.title}
                          </button>
                        ) : (
                          <span className="text-sm">{entry.title}</span>
                        )}
                        {entry.memoryType && (
                          <Badge variant="outline" className="text-[10px]">
                            {entry.memoryType}
                          </Badge>
                        )}
                      </li>
                    ))}
                  </ul>
                </div>
              ))}
            </div>
          )}
        </CardContent>
      </Card>
    </div>
  )
}