spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
import * as React from 'react'
import { invoke as tauriInvoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { AppSidebar, type SidebarView } from '@/components/app-sidebar'
import {
  CommandPalette,
  useCommandPaletteShortcut,
} from '@/components/command-palette'
import { DraftDialog } from '@/components/draft-dialog'
import { OnboardingWizard } from '@/components/onboarding-wizard'
import { WorkbenchPage } from '@/pages/workbench'
import { SessionsPage } from '@/pages/sessions'
import { IndexNavPage } from '@/pages/index-nav'
import { LintPage } from '@/pages/lint'
import { SettingsPage } from '@/pages/settings'
import { isTauriHost } from '@/lib/api/desktop'
import { useConfig } from '@/state/config'
import { useI18n } from '@/state/i18n'
import { BrainCircuit, Settings } from 'lucide-react'

type Screen = SidebarView | 'settings'

const VIEW_STORAGE_KEY = 'spool.view'

function readStoredView(): SidebarView {
  if (typeof window === 'undefined') return 'inbox'
  const raw = window.sessionStorage.getItem(VIEW_STORAGE_KEY)
  if (raw === 'inbox' || raw === 'sessions' || raw === 'index' || raw === 'lint') {
    return raw
  }
  return 'inbox'
}

export function AppShell() {
  const cfg = useConfig()
  const { t } = useI18n()
  const [view, setView] = React.useState<Screen>(() => readStoredView())
  const [navigateToId, setNavigateToId] = React.useState<string | null>(null)
  const [paletteOpen, setPaletteOpen] = React.useState(false)
  const [draftMode, setDraftMode] = React.useState<'manual' | 'propose' | null>(null)

  React.useEffect(() => {
    if (typeof window === 'undefined') return
    if (view === 'settings') return
    window.sessionStorage.setItem(VIEW_STORAGE_KEY, view)
  }, [view])

  const handleSidebarChange = React.useCallback((next: SidebarView) => {
    setView(next)
  }, [])

  const handleHeaderMouseDown = React.useCallback(
    (e: React.MouseEvent<HTMLElement>) => {
      // Only drag on left button, not on interactive elements
      if (e.button !== 0) return
      const target = e.target as HTMLElement
      if (
        target.closest('button') ||
        target.closest('a') ||
        target.closest('input') ||
        target.closest('[role="button"]')
      ) return
      void getCurrentWindow().startDragging()
    },
    [],
  )

  useCommandPaletteShortcut(setPaletteOpen)

  const commandCtx = React.useMemo(
    () => ({
      onCreateManual: () => {
        setView(readStoredView())
        setDraftMode('manual')
      },
      onCreateProposal: () => {
        setView(readStoredView())
        setDraftMode('propose')
      },
      onImportSession: () => setView('sessions'),
      onNavigateInbox: () => setView('inbox'),
      onNavigateSessions: () => setView('sessions'),
      onNavigateIndex: () => setView('index'),
      onNavigateLint: () => setView('lint'),
      onNavigateSettings: () => setView('settings'),
      onOpenPopover: () => {
        void tauriInvoke('show_popover').catch(() => undefined)
      },
      onOpenMain: () => {
        void tauriInvoke('show_main_window').catch(() => undefined)
      },
    }),
    [],
  )

  if (cfg.ready && !cfg.configPath.trim()) {
    return <OnboardingWizard />
  }

  if (view === 'settings') {
    return (
      <>
        <SettingsPage onBack={() => setView(readStoredView())} />
        <CommandPalette
          open={paletteOpen}
          onOpenChange={setPaletteOpen}
          ctx={commandCtx}
        />
      </>
    )
  }

  return (
    <div className="flex h-screen flex-col bg-bg-deep">
      {/* Header */}
      <header
        onMouseDown={handleHeaderMouseDown}
        className="flex h-11 shrink-0 items-center justify-between border-b border-border-subtle px-4 pl-[76px] select-none cursor-default"
      >
        <div className="flex items-center gap-2.5">
          <div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/15">
            <BrainCircuit className="h-3.5 w-3.5 text-primary" />
          </div>
          <span className="text-sm font-semibold tracking-tight text-foreground">spool</span>
          {isTauriHost() && (
            <div className="flex items-center gap-1 ml-1">
              <div className="h-1.5 w-1.5 rounded-full bg-success animate-pulse" />
              <span className="text-[10px] text-muted-foreground/60">{t('status.connected')}</span>
            </div>
          )}
        </div>
        <button
          onClick={() => setView('settings')}
          className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-all duration-150 hover:bg-bg-elevated hover:text-foreground cursor-pointer"
          title="设置"
        >
          <Settings className="h-3.5 w-3.5" />
        </button>
      </header>

      {/* Body */}
      <div className="flex flex-1 overflow-hidden">
        <AppSidebar view={view} onChange={handleSidebarChange} />

        <main className="flex-1 overflow-auto bg-bg-base p-5">
          <div className="animate-fade-in-up">
            {view === 'inbox' && <WorkbenchPage navigateToId={navigateToId} />}
            {view === 'sessions' && (
              <SessionsPage onImported={() => setView('inbox')} />
            )}
            {view === 'index' && (
              <IndexNavPage
                onNavigateToRecord={(id) => {
                  setNavigateToId(id)
                  setView('inbox')
                }}
              />
            )}
            {view === 'lint' && <LintPage />}
          </div>
        </main>
      </div>

      {draftMode && (
        <DraftDialog
          mode={draftMode}
          open
          onOpenChange={(open) => !open && setDraftMode(null)}
          onCreated={() => setDraftMode(null)}
        />
      )}

      <CommandPalette
        open={paletteOpen}
        onOpenChange={setPaletteOpen}
        ctx={commandCtx}
      />
    </div>
  )
}