cc-haha/desktop/src/pages/MemorySettings.tsx
程序员阿江(Relakkes) 294e02e6f4 Refine project memory tree actions
The memory resource tree needed to match the project-first interaction model: projects expand in place, project rows do not show noisy counts or explicit expand affordances, and common project actions live behind the row menu.

Constraint: Must reuse the existing open-target Finder/file-manager flow instead of shelling out from the view.

Rejected: Reuse the session sidebar project filter menu | it is a filter surface, not the memory resource tree.

Confidence: high

Scope-risk: narrow

Directive: Keep memory project actions backed by openTargetStore so platform-specific file managers stay centralized.

Tested: cd desktop && bun run lint

Tested: cd desktop && bun run test -- src/__tests__/memorySettings.test.tsx

Tested: cd desktop && bun run build

Tested: Playwright screenshot smoke of Settings > Memory project menu at /tmp/cc-haha-memory-ui.png
2026-05-15 13:49:17 +08:00

1005 lines
37 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import type { MouseEvent, ReactNode } from 'react'
import { BookOpenText, ChevronDown, ChevronRight, Database, FileText, Folder, FolderGit2, FolderOpen, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, Save, Search, X } from 'lucide-react'
import { Button } from '../components/shared/Button'
import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
import { useTranslation } from '../i18n'
import { formatBytes } from '../lib/formatBytes'
import { useMemoryStore } from '../stores/memoryStore'
import { useOpenTargetStore } from '../stores/openTargetStore'
import { useSessionStore } from '../stores/sessionStore'
import { useUIStore } from '../stores/uiStore'
import type { MemoryFile, MemoryProject } from '../types/memory'
const DEFAULT_MEMORY_PATH = 'MEMORY.md'
const PINNED_MEMORY_PROJECTS_STORAGE_KEY = 'cc-haha-memory-pinned-projects'
export function MemorySettings() {
const t = useTranslation()
const {
projects,
files,
selectedProjectId,
selectedFile,
draftContent,
isLoadingProjects,
isLoadingFiles,
isLoadingFile,
isSaving,
error,
lastSavedAt,
fetchProjects,
selectProject,
fetchFiles,
openFile,
updateDraft,
saveFile,
} = useMemoryStore()
const sessions = useSessionStore((s) => s.sessions)
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const addToast = useUIStore((s) => s.addToast)
const pendingMemoryPath = useUIStore((s) => s.pendingMemoryPath)
const setPendingMemoryPath = useUIStore((s) => s.setPendingMemoryPath)
const [resourceQuery, setResourceQuery] = useState('')
const [expandedProjectId, setExpandedProjectId] = useState<string | null>(null)
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set())
const [pinnedProjectIds, setPinnedProjectIds] = useState<Set<string>>(() => readPinnedMemoryProjects())
const [projectMenu, setProjectMenu] = useState<{ projectId: string; x: number; y: number } | null>(null)
const activeSession = useMemo(
() => sessions.find((session) => session.id === activeSessionId),
[activeSessionId, sessions],
)
const activeCwd = activeSession?.workDir || activeSession?.projectPath || undefined
const selectedProject = projects.find((project) => project.id === selectedProjectId) ?? null
const isDirty = Boolean(selectedFile && draftContent !== selectedFile.content)
const filteredProjects = useMemo(
() => filterProjects(projects, resourceQuery, selectedProjectId, files),
[files, projects, resourceQuery, selectedProjectId],
)
const orderedProjects = useMemo(
() => orderMemoryProjects(filteredProjects, pinnedProjectIds),
[filteredProjects, pinnedProjectIds],
)
const pinnedProjects = useMemo(
() => orderedProjects.filter((project) => pinnedProjectIds.has(project.id)),
[orderedProjects, pinnedProjectIds],
)
const regularProjects = useMemo(
() => orderedProjects.filter((project) => !pinnedProjectIds.has(project.id)),
[orderedProjects, pinnedProjectIds],
)
const filteredFiles = useMemo(
() => filterFiles(files, resourceQuery),
[files, resourceQuery],
)
const fileTree = useMemo(
() => buildMemoryFileTree(filteredFiles),
[filteredFiles],
)
const previewContent = stripMarkdownFrontmatter(draftContent)
useEffect(() => {
void fetchProjects(activeCwd)
}, [activeCwd, fetchProjects])
useEffect(() => {
if (!selectedProjectId) return
void fetchFiles(selectedProjectId)
}, [fetchFiles, selectedProjectId])
useEffect(() => {
if (!selectedProjectId) return
setExpandedProjectId(selectedProjectId)
}, [selectedProjectId])
useEffect(() => {
if (!projectMenu) return
const close = () => setProjectMenu(null)
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setProjectMenu(null)
}
document.addEventListener('click', close)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('click', close)
document.removeEventListener('keydown', handleKeyDown)
}
}, [projectMenu])
useEffect(() => {
if (!selectedProjectId || selectedFile || isLoadingFiles || isLoadingFile) return
if (pendingMemoryPath) return
const firstFile = files[0]
if (firstFile) {
void openFile(selectedProjectId, firstFile.path)
}
}, [files, isLoadingFile, isLoadingFiles, openFile, pendingMemoryPath, selectedFile, selectedProjectId])
useEffect(() => {
if (!pendingMemoryPath || isLoadingProjects || projects.length === 0) return
const target = resolveMemoryFileTarget(projects, pendingMemoryPath)
if (!target) {
setPendingMemoryPath(null)
return
}
if (selectedProjectId !== target.projectId) {
selectProject(target.projectId)
return
}
if (selectedFile?.path === target.path && !isLoadingFile) {
setPendingMemoryPath(null)
return
}
void openFile(target.projectId, target.path).then(() => {
setPendingMemoryPath(null)
})
}, [
isLoadingFile,
isLoadingProjects,
openFile,
pendingMemoryPath,
projects,
selectProject,
selectedFile?.path,
selectedProjectId,
setPendingMemoryPath,
])
const handleRefresh = () => {
void fetchProjects(activeCwd)
if (selectedProjectId) {
void fetchFiles(selectedProjectId)
}
}
const handleProjectToggle = (projectId: string) => {
if (expandedProjectId === projectId) {
setExpandedProjectId(null)
return
}
setExpandedProjectId(projectId)
if (projectId !== selectedProjectId) {
selectProject(projectId)
}
}
const handleFileOpen = (file: MemoryFile) => {
if (!selectedProjectId || file.path === selectedFile?.path) return
void openFile(selectedProjectId, file.path)
}
const togglePinnedProject = (projectId: string) => {
setProjectMenu(null)
setPinnedProjectIds((previous) => {
const next = new Set(previous)
if (next.has(projectId)) {
next.delete(projectId)
} else {
next.add(projectId)
}
writePinnedMemoryProjects(next)
return next
})
}
const openProjectInFinder = async (project: MemoryProject) => {
setProjectMenu(null)
try {
const store = useOpenTargetStore.getState()
await store.ensureTargets()
const latest = useOpenTargetStore.getState()
const target = latest.targets.find((item) => item.id === 'finder')
?? latest.targets.find((item) => item.kind === 'file_manager')
if (!target) {
throw new Error(t('settings.memory.openInFinderUnavailable'))
}
await latest.openTarget(target.id, resolveMemoryProjectOpenPath(project))
} catch (error) {
addToast({
type: 'error',
message: error instanceof Error ? error.message : t('settings.memory.openInFinderFailed'),
})
}
}
const toggleFolder = (path: string) => {
setCollapsedFolders((previous) => {
const next = new Set(previous)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
return next
})
}
const forceExpandFiles = Boolean(resourceQuery.trim())
return (
<div className="flex h-full min-h-[640px] flex-col overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<header className="grid min-h-[58px] border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] lg:grid-cols-[280px_minmax(0,1fr)]">
<div className="flex min-w-0 items-center gap-3 border-b border-[var(--color-border)] px-4 py-3 lg:border-b-0 lg:border-r">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-brand)]">
<BookOpenText size={16} aria-hidden="true" />
</span>
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-[var(--color-text-primary)]">
{t('settings.memory.title')}
</h2>
<p className="truncate text-xs text-[var(--color-text-tertiary)]">
{t('settings.memory.projects')}
</p>
</div>
</div>
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3 px-4 py-3">
<Breadcrumb
project={selectedProject}
filePath={selectedFile?.path}
fallbackProject={activeCwd ? projectDisplayName(activeCwd) : '~/.claude/projects'}
fallbackFile={t('settings.memory.noFileSelected')}
/>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleRefresh}
loading={isLoadingProjects || isLoadingFiles}
icon={<RefreshCw size={15} aria-hidden="true" />}
>
{t('settings.memory.refresh')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!selectedFile || !isDirty || isSaving}
onClick={() => selectedFile && updateDraft(selectedFile.content)}
icon={<RotateCcw size={14} aria-hidden="true" />}
>
{t('settings.memory.revert')}
</Button>
<Button
type="button"
size="sm"
disabled={!selectedFile || !isDirty}
loading={isSaving}
onClick={() => void saveFile()}
icon={<Save size={14} aria-hidden="true" />}
>
{t('common.save')}
</Button>
</div>
</div>
</header>
{error && (
<div className="m-3 rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error)]/10 px-3 py-2 text-sm text-[var(--color-error)]">
{error}
</div>
)}
<div className="grid min-h-0 flex-1 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="min-h-0 overflow-hidden border-b border-[var(--color-border)] lg:border-b-0 lg:border-r">
<section className="flex h-full min-h-0 flex-col bg-[var(--color-surface-container-lowest)]">
<PanelHeader
icon={<Database size={15} aria-hidden="true" />}
title={t('settings.memory.resourceManager')}
meta={isLoadingProjects ? t('common.loading') : undefined}
/>
<div className="px-3 py-3">
<SearchField
value={resourceQuery}
onChange={setResourceQuery}
placeholder={t('settings.memory.resourceSearchPlaceholder')}
ariaLabel={t('settings.memory.resourceSearchPlaceholder')}
clearLabel={t('settings.memory.clearSearch')}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{projects.length === 0 && !isLoadingProjects ? (
<EmptyState icon={<FolderGit2 size={18} />} text={t('settings.memory.emptyProjects')} />
) : orderedProjects.length === 0 ? (
<EmptyState icon={<Search size={18} />} text={t('settings.memory.noProjectMatches')} />
) : (
<div className="space-y-3 py-1">
{pinnedProjects.length > 0 ? (
<MemoryProjectSection title={t('settings.memory.pinnedProjects')}>
{pinnedProjects.map((project) => {
const isExpanded = project.id === expandedProjectId
const isSelected = project.id === selectedProjectId
const visibleFileTree = isSelected ? fileTree : []
return (
<ProjectTreeRow
key={project.id}
project={project}
expanded={isExpanded}
active={isSelected}
pinned
loading={isSelected && isLoadingFiles}
fileTree={visibleFileTree}
activePath={selectedFile?.path ?? null}
collapsedFolders={collapsedFolders}
forceExpanded={forceExpandFiles}
onToggle={() => handleProjectToggle(project.id)}
onOpenMenu={(event) => {
event.stopPropagation()
setProjectMenu(positionProjectMenu(project.id, event.clientX, event.clientY))
}}
onToggleFolder={toggleFolder}
onFileSelect={handleFileOpen}
emptyText={t('settings.memory.emptyFiles')}
/>
)
})}
</MemoryProjectSection>
) : null}
{regularProjects.length > 0 ? (
<MemoryProjectSection title={pinnedProjects.length > 0 ? t('settings.memory.projects') : undefined}>
{regularProjects.map((project) => {
const isExpanded = project.id === expandedProjectId
const isSelected = project.id === selectedProjectId
const visibleFileTree = isSelected ? fileTree : []
return (
<ProjectTreeRow
key={project.id}
project={project}
expanded={isExpanded}
active={isSelected}
pinned={false}
loading={isSelected && isLoadingFiles}
fileTree={visibleFileTree}
activePath={selectedFile?.path ?? null}
collapsedFolders={collapsedFolders}
forceExpanded={forceExpandFiles}
onToggle={() => handleProjectToggle(project.id)}
onOpenMenu={(event) => {
event.stopPropagation()
setProjectMenu(positionProjectMenu(project.id, event.clientX, event.clientY))
}}
onToggleFolder={toggleFolder}
onFileSelect={handleFileOpen}
emptyText={t('settings.memory.emptyFiles')}
/>
)
})}
</MemoryProjectSection>
) : null}
</div>
)}
</div>
</section>
</aside>
<section className="min-h-0 overflow-hidden bg-[var(--color-surface-container-lowest)]">
<div className="grid gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
{selectedFile?.path ? fileNameFromPath(selectedFile.path) : t('settings.memory.noFileSelected')}
</h3>
{isDirty && <Badge>{t('settings.memory.unsaved')}</Badge>}
{lastSavedAt && !isDirty && <Badge>{t('settings.memory.saved')}</Badge>}
</div>
<p className="mt-1 truncate text-xs text-[var(--color-text-tertiary)]">
{selectedProject?.memoryDir ?? t('settings.memory.selectProject')}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
{selectedFile ? (
<>
<span>{formatBytes(selectedFile.bytes)}</span>
{selectedFile.updatedAt ? <span>{formatDate(selectedFile.updatedAt)}</span> : null}
</>
) : null}
</div>
</div>
{selectedFile ? (
<div className="grid min-h-[560px] grid-rows-[minmax(300px,1fr)_minmax(260px,0.95fr)] 2xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] 2xl:grid-rows-1">
<div className="min-h-0 border-b border-[var(--color-border)] 2xl:border-b-0 2xl:border-r">
<div className="flex h-10 items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 text-xs font-medium uppercase tracking-normal text-[var(--color-text-tertiary)]">
<span>{t('settings.memory.editor')}</span>
<span>MARKDOWN</span>
</div>
<textarea
aria-label={t('settings.memory.editor')}
value={draftContent}
onChange={(event) => updateDraft(event.target.value)}
spellCheck={false}
className="h-[calc(100%-40px)] w-full resize-none overflow-auto bg-transparent p-5 font-mono text-[13px] leading-6 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
</div>
<div className="min-h-0 overflow-y-auto">
<div className="flex h-10 items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 text-xs font-medium uppercase tracking-normal text-[var(--color-text-tertiary)]">
<span>{t('settings.memory.preview')}</span>
<span>{t('settings.memory.rendered')}</span>
</div>
<div className="p-6">
<MarkdownRenderer content={previewContent || ' '} variant="document" />
</div>
</div>
</div>
) : (
<div className="flex min-h-[520px] items-center justify-center p-8">
<EmptyState icon={<FileText size={20} />} text={isLoadingFile ? t('common.loading') : t('settings.memory.selectFile')} />
</div>
)}
</section>
</div>
{projectMenu ? (
<MemoryProjectMenu
project={orderedProjects.find((project) => project.id === projectMenu.projectId) ?? null}
x={projectMenu.x}
y={projectMenu.y}
pinned={pinnedProjectIds.has(projectMenu.projectId)}
onTogglePin={() => togglePinnedProject(projectMenu.projectId)}
onOpenInFinder={(project) => void openProjectInFinder(project)}
/>
) : null}
</div>
)
}
function Breadcrumb({
project,
filePath,
fallbackProject,
fallbackFile,
}: {
project: MemoryProject | null
filePath?: string
fallbackProject: string
fallbackFile: string
}) {
const projectLabel = project ? projectDisplayName(project.label) : fallbackProject
const parts = filePath ? [projectLabel, ...filePath.split('/').filter(Boolean)] : [projectLabel, fallbackFile]
return (
<nav aria-label="Memory file path" className="flex min-w-0 items-center gap-1 text-sm text-[var(--color-text-tertiary)]">
{parts.map((part, index) => {
const isLast = index === parts.length - 1
return (
<span key={`${part}-${index}`} className="flex min-w-0 items-center gap-1">
{index > 0 ? <ChevronRight size={14} className="shrink-0" aria-hidden="true" /> : null}
<span className={`truncate ${isLast ? 'font-semibold text-[var(--color-text-primary)]' : ''}`}>
{part}
</span>
</span>
)
})}
</nav>
)
}
function SearchField({
value,
onChange,
placeholder,
ariaLabel,
clearLabel,
}: {
value: string
onChange: (value: string) => void
placeholder: string
ariaLabel: string
clearLabel: string
}) {
return (
<div className="relative">
<Search
size={15}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)]"
aria-hidden="true"
/>
<input
aria-label={ariaLabel}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className="h-10 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] pl-9 pr-9 text-sm text-[var(--color-text-primary)] outline-none transition-colors duration-150 placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]"
/>
{value ? (
<button
type="button"
aria-label={clearLabel}
onClick={() => onChange('')}
className="absolute right-2 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<X size={14} aria-hidden="true" />
</button>
) : null}
</div>
)
}
function PanelHeader({ icon, title, meta }: { icon?: ReactNode; title: string; meta?: string }) {
return (
<div className="flex h-11 items-center justify-between border-b border-[var(--color-border)] px-3">
<h3 className="flex min-w-0 items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
{icon ? <span className="text-[var(--color-text-tertiary)]">{icon}</span> : null}
<span className="truncate">{title}</span>
</h3>
{meta ? <span className="text-xs text-[var(--color-text-tertiary)]">{meta}</span> : null}
</div>
)
}
function MemoryProjectSection({ title, children }: { title?: string; children: ReactNode }) {
return (
<div>
{title ? (
<div className="px-2 pb-1 text-[11px] font-semibold text-[var(--color-text-tertiary)]">
{title}
</div>
) : null}
{children}
</div>
)
}
function ProjectTreeRow({
project,
expanded,
active,
pinned,
loading,
fileTree,
activePath,
collapsedFolders,
forceExpanded,
onToggle,
onOpenMenu,
onToggleFolder,
onFileSelect,
emptyText,
}: {
project: MemoryProject
expanded: boolean
active: boolean
pinned: boolean
loading: boolean
fileTree: MemoryTreeNode[]
activePath: string | null
collapsedFolders: Set<string>
forceExpanded: boolean
onToggle: () => void
onOpenMenu: (event: MouseEvent<HTMLButtonElement>) => void
onToggleFolder: (path: string) => void
onFileSelect: (file: MemoryFile) => void
emptyText: string
}) {
const t = useTranslation()
const display = projectDisplayName(project.label)
return (
<div className="mb-0.5">
<div
data-testid="memory-project-row"
className={`group flex min-h-9 w-full items-center gap-1.5 rounded-md px-2 py-1 text-left transition-colors focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
active
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
<button
type="button"
onClick={onToggle}
title={project.label}
aria-expanded={expanded}
aria-label={t('settings.memory.toggleFolder', { name: display })}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<Folder size={15} className="shrink-0 text-[var(--color-brand)]" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-sm font-medium">{display}</span>
{pinned ? <Pin size={13} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" /> : null}
{!project.exists ? (
<span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">{t('settings.memory.missing')}</span>
) : null}
</button>
<span className="ml-0.5 flex shrink-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
<button
type="button"
onClick={onOpenMenu}
aria-label={t('settings.memory.projectActions', { name: display })}
className="flex h-7 w-7 items-center justify-center rounded-md text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<MoreHorizontal size={16} aria-hidden="true" />
</button>
</span>
</div>
{expanded ? (
<div className="ml-[18px] border-l border-[var(--color-border)] pl-2">
{loading ? (
<div className="px-2 py-1.5 text-xs text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
) : fileTree.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-[var(--color-text-tertiary)]">{emptyText}</div>
) : (
fileTree.map((node) => (
<MemoryTreeRow
key={node.id}
node={node}
depth={1}
activePath={activePath}
collapsedFolders={collapsedFolders}
forceExpanded={forceExpanded}
onToggleFolder={onToggleFolder}
onFileSelect={onFileSelect}
/>
))
)}
</div>
) : null}
</div>
)
}
function FileRow({
file,
active,
onSelect,
depth = 0,
}: {
file: MemoryFile
active: boolean
onSelect: () => void
depth?: number
}) {
return (
<button
type="button"
onClick={onSelect}
style={{ paddingLeft: `${4 + Math.max(depth - 1, 0) * 16}px` }}
className={`mb-0.5 flex min-h-8 w-full items-center gap-1.5 rounded-sm py-1 pr-2 text-left transition-colors focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
active
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
<FileText size={14} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-sm">{file.title}</span>
</button>
)
}
function MemoryTreeRow({
node,
depth,
activePath,
collapsedFolders,
forceExpanded,
onToggleFolder,
onFileSelect,
}: {
node: MemoryTreeNode
depth: number
activePath: string | null
collapsedFolders: Set<string>
forceExpanded: boolean
onToggleFolder: (path: string) => void
onFileSelect: (file: MemoryFile) => void
}) {
const t = useTranslation()
if (node.kind === 'file') {
return (
<FileRow
file={node.file}
active={node.file.path === activePath}
depth={depth}
onSelect={() => onFileSelect(node.file)}
/>
)
}
const isCollapsed = !forceExpanded && collapsedFolders.has(node.path)
return (
<div>
<button
type="button"
onClick={() => onToggleFolder(node.path)}
aria-expanded={!isCollapsed}
aria-label={t('settings.memory.toggleFolder', { name: node.name })}
className="mb-0.5 flex min-h-8 w-full items-center gap-1.5 rounded-sm py-1 pr-2 text-left text-sm text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
style={{ paddingLeft: `${4 + Math.max(depth - 1, 0) * 16}px` }}
>
{isCollapsed ? <ChevronRight size={14} aria-hidden="true" /> : <ChevronDown size={14} aria-hidden="true" />}
<Folder size={14} className="shrink-0 text-[var(--color-brand)]" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate font-medium">{node.name}</span>
</button>
{!isCollapsed ? (
<div className="ml-[18px] border-l border-[var(--color-border)] pl-2">
{node.children.map((child) => (
<MemoryTreeRow
key={child.id}
node={child}
depth={depth + 1}
activePath={activePath}
collapsedFolders={collapsedFolders}
forceExpanded={forceExpanded}
onToggleFolder={onToggleFolder}
onFileSelect={onFileSelect}
/>
))}
</div>
) : null}
</div>
)
}
function MemoryProjectMenu({
project,
x,
y,
pinned,
onTogglePin,
onOpenInFinder,
}: {
project: MemoryProject | null
x: number
y: number
pinned: boolean
onTogglePin: () => void
onOpenInFinder: (project: MemoryProject) => void
}) {
const t = useTranslation()
if (!project) return null
return (
<div
role="menu"
className="fixed z-50 min-w-[220px] overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[var(--shadow-dropdown)]"
style={{ left: x, top: y }}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
role="menuitem"
onClick={onTogglePin}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-semibold text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)]"
>
{pinned ? <PinOff size={17} aria-hidden="true" /> : <Pin size={17} aria-hidden="true" />}
<span>{pinned ? t('settings.memory.unpinProject') : t('settings.memory.pinProject')}</span>
</button>
<button
type="button"
role="menuitem"
onClick={() => onOpenInFinder(project)}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-semibold text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)]"
>
<FolderOpen size={17} aria-hidden="true" />
<span>{t('settings.memory.openInFinder')}</span>
</button>
</div>
)
}
function Badge({ children }: { children: string }) {
return (
<span className="shrink-0 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)]">
{children}
</span>
)
}
function EmptyState({ icon, text }: { icon?: ReactNode; text: string }) {
return (
<div className="grid place-items-center gap-2 px-3 py-8 text-center text-sm text-[var(--color-text-tertiary)]">
{icon ? (
<span className="flex h-9 w-9 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]">
{icon}
</span>
) : null}
<span>{text}</span>
</div>
)
}
function normalizeSearch(value: string): string {
return value.toLowerCase().replace(/\\/g, '/').replace(/\/+/g, '/').trim()
}
function filterProjects(
projects: MemoryProject[],
query: string,
selectedProjectId: string | null,
selectedProjectFiles: MemoryFile[],
): MemoryProject[] {
const normalized = normalizeSearch(query)
if (!normalized) return projects
return projects.filter((project) =>
normalizeSearch(`${project.label} ${project.memoryDir} ${project.id}`).includes(normalized) ||
(project.id === selectedProjectId && selectedProjectFiles.some((file) =>
normalizeSearch(`${file.title} ${file.path} ${file.description ?? ''} ${file.type ?? ''}`).includes(normalized),
)),
)
}
function filterFiles(files: MemoryFile[], query: string): MemoryFile[] {
const normalized = normalizeSearch(query)
if (!normalized) return files
return files.filter((file) =>
normalizeSearch(`${file.title} ${file.path} ${file.description ?? ''} ${file.type ?? ''}`).includes(normalized),
)
}
function orderMemoryProjects(projects: MemoryProject[], pinnedProjectIds: Set<string>): MemoryProject[] {
return [...projects].sort((a, b) => {
const aPinned = pinnedProjectIds.has(a.id)
const bPinned = pinnedProjectIds.has(b.id)
if (aPinned !== bPinned) return aPinned ? -1 : 1
return 0
})
}
function projectDisplayName(label: string): string {
const normalized = label.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '')
const parts = normalized.split('/').filter(Boolean)
if (parts.length >= 2) return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`
return parts[0] ?? label
}
function readPinnedMemoryProjects(): Set<string> {
if (typeof window === 'undefined') return new Set()
try {
const raw = window.localStorage.getItem(PINNED_MEMORY_PROJECTS_STORAGE_KEY)
if (!raw) return new Set()
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return new Set()
return new Set(parsed.filter((item): item is string => typeof item === 'string' && item.length > 0))
} catch {
return new Set()
}
}
function writePinnedMemoryProjects(projectIds: Set<string>) {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(PINNED_MEMORY_PROJECTS_STORAGE_KEY, JSON.stringify([...projectIds]))
} catch {
// Ignore storage failures; pinning remains a session-local UI preference.
}
}
function positionProjectMenu(projectId: string, clientX: number, clientY: number) {
const viewportWidth = typeof window === 'undefined' ? 1024 : window.innerWidth
const viewportHeight = typeof window === 'undefined' ? 768 : window.innerHeight
return {
projectId,
x: Math.max(8, Math.min(clientX, viewportWidth - 236)),
y: Math.max(8, Math.min(clientY + 6, viewportHeight - 120)),
}
}
function resolveMemoryProjectOpenPath(project: MemoryProject): string {
return isLikelyAbsolutePath(project.label) ? project.label : project.memoryDir
}
function isLikelyAbsolutePath(value: string): boolean {
return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value)
}
function stripMarkdownFrontmatter(content: string): string {
if (!content.startsWith('---')) return content
const end = content.indexOf('\n---', 3)
if (end < 0) return content
const after = content.indexOf('\n', end + 4)
return after < 0 ? '' : content.slice(after + 1).trimStart()
}
function normalizeFsPath(value: string): string {
return value.replace(/\\/g, '/').replace(/\/+$/, '')
}
function resolveMemoryFileTarget(projects: MemoryProject[], absolutePath: string): { projectId: string; path: string } | null {
const target = normalizeFsPath(absolutePath)
for (const project of projects) {
const memoryDir = normalizeFsPath(project.memoryDir)
if (!memoryDir) continue
if (target === memoryDir) {
return { projectId: project.id, path: DEFAULT_MEMORY_PATH }
}
if (target.startsWith(`${memoryDir}/`)) {
return {
projectId: project.id,
path: target.slice(memoryDir.length + 1),
}
}
}
return null
}
type MemoryTreeNode =
| {
kind: 'folder'
id: string
name: string
path: string
fileCount: number
children: MemoryTreeNode[]
}
| {
kind: 'file'
id: string
name: string
path: string
file: MemoryFile
}
type MutableFolderNode = Extract<MemoryTreeNode, { kind: 'folder' }>
function buildMemoryFileTree(files: MemoryFile[]): MemoryTreeNode[] {
const root: MutableFolderNode = {
kind: 'folder',
id: '__root__',
name: '__root__',
path: '',
fileCount: 0,
children: [],
}
const folders = new Map<string, MutableFolderNode>([['', root]])
for (const file of files) {
const parts = file.path.split('/').filter(Boolean)
let parent = root
parts.slice(0, -1).forEach((part, index) => {
const folderPath = parts.slice(0, index + 1).join('/')
let folder = folders.get(folderPath)
if (!folder) {
folder = {
kind: 'folder',
id: `folder:${folderPath}`,
name: part,
path: folderPath,
fileCount: 0,
children: [],
}
folders.set(folderPath, folder)
parent.children.push(folder)
}
folder.fileCount += 1
parent = folder
})
parent.children.push({
kind: 'file',
id: `file:${file.path}`,
name: parts[parts.length - 1] ?? file.name,
path: file.path,
file,
})
}
sortMemoryTree(root.children)
return root.children
}
function sortMemoryTree(nodes: MemoryTreeNode[]): void {
nodes.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'folder' ? -1 : 1
const aIndex = a.kind === 'file' ? a.file.isIndex : false
const bIndex = b.kind === 'file' ? b.file.isIndex : false
if (aIndex !== bIndex) return aIndex ? -1 : 1
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
})
for (const node of nodes) {
if (node.kind === 'folder') sortMemoryTree(node.children)
}
}
function fileNameFromPath(path: string): string {
const parts = path.split('/').filter(Boolean)
return parts[parts.length - 1] ?? path
}
function formatDate(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}