Make project memory navigation feel like a file tree

The memory settings surface previously split projects and memory files into separate selection regions. This change keeps the desktop settings frame but makes the memory resource area behave like a single resource tree: project folders expand in place, nested memory folders/files remain in the same hierarchy, and visual badges are removed from the tree rows to keep the UI quiet.

Constraint: The desktop settings shell and existing memory store/API contracts stay unchanged.
Rejected: Keep project and file lists as separate panels | the requested interaction needs project folders and their memory files in one tree.
Rejected: Keep file-type badges in tree rows | they made the navigation look noisy and artificial.
Confidence: high
Scope-risk: moderate
Directive: Keep memory navigation tree rows plain; do not reintroduce badge-heavy file rows without a UX pass.
Tested: cd desktop && bun run test src/__tests__/memorySettings.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: local agent-browser screenshot of settings memory tree
This commit is contained in:
程序员阿江(Relakkes) 2026-05-15 09:14:03 +08:00
parent 36b86343d5
commit 00733838ef
4 changed files with 480 additions and 171 deletions

View File

@ -110,8 +110,10 @@ describe('MemorySettings', () => {
expect(await screen.findByText('Project Memory')).toBeInTheDocument()
expect(memoryApiMock.listProjects).toHaveBeenCalledWith('/workspace/demo')
expect(await screen.findByText('/workspace/demo')).toBeInTheDocument()
expect(await screen.findByText('Project conventions.')).toBeInTheDocument()
expect(await screen.findAllByText('workspace/demo')).not.toHaveLength(0)
expect(await screen.findAllByText('MEMORY.md')).not.toHaveLength(0)
expect(screen.queryByText('Project conventions.')).not.toBeInTheDocument()
expect(screen.queryByText('Index')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('MEMORY.md or notes/project.md')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /create memory file/i })).not.toBeInTheDocument()
@ -135,7 +137,7 @@ describe('MemorySettings', () => {
})
})
it('filters projects by path so large memory lists are navigable', async () => {
it('filters the unified resource tree by project path', async () => {
memoryApiMock.listProjects.mockResolvedValue({
projects: [
{
@ -159,20 +161,86 @@ describe('MemorySettings', () => {
render(<MemorySettings />)
expect(await screen.findByText('workspace/alpha')).toBeInTheDocument()
expect(await screen.findAllByText('workspace/alpha')).not.toHaveLength(0)
expect(await screen.findByText('workspace/beta')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Search projects by path...'), {
fireEvent.change(screen.getByLabelText('Search projects or memory files...'), {
target: { value: 'beta' },
})
expect(screen.queryByText('workspace/alpha')).not.toBeInTheDocument()
expect(screen.getByText('workspace/beta')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /workspace\/alpha/ })).not.toBeInTheDocument()
expect(screen.getAllByText('workspace/beta').length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: 'Toggle workspace/beta' }))
await waitFor(() => {
expect(useMemoryStore.getState().selectedProjectId).toBe('-workspace-beta')
})
})
it('renders nested memory files as a collapsible resource tree', async () => {
memoryApiMock.listFiles.mockResolvedValue({
files: [
{
path: 'MEMORY.md',
name: 'MEMORY.md',
title: 'MEMORY.md',
bytes: 18,
updatedAt: '2026-05-01T00:00:00.000Z',
type: 'project',
description: 'Project conventions.',
isIndex: true,
},
{
path: 'notes/manual.md',
name: 'manual.md',
title: 'Manual',
bytes: 42,
updatedAt: '2026-05-01T00:02:00.000Z',
type: 'guidance',
description: 'Operator workflow.',
isIndex: false,
},
{
path: 'notes/archive/old.md',
name: 'old.md',
title: 'Old note',
bytes: 24,
updatedAt: '2026-05-01T00:03:00.000Z',
isIndex: false,
},
],
})
memoryApiMock.readFile.mockImplementation((_projectId: string, path: string) => Promise.resolve({
file: {
path,
content: path === 'notes/manual.md' ? '# Manual\n' : '# Project Memory\n',
updatedAt: '2026-05-01T00:00:00.000Z',
bytes: 18,
},
}))
render(<MemorySettings />)
expect(await screen.findByRole('button', { name: 'Toggle notes' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Toggle archive' })).toBeInTheDocument()
expect(screen.getByText('Manual')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Toggle notes' }))
expect(screen.queryByText('Manual')).not.toBeInTheDocument()
fireEvent.change(screen.getByLabelText('Search projects or memory files...'), {
target: { value: 'manual' },
})
expect(screen.getByText('Manual')).toBeInTheDocument()
fireEvent.click(screen.getByText('Manual'))
await waitFor(() => {
expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-demo', 'notes/manual.md')
})
expect(await screen.findByLabelText('Editor')).toHaveValue('# Manual\n')
})
it('keeps frontmatter editable but removes it from the rendered preview', async () => {
memoryApiMock.readFile.mockResolvedValue({
file: {

View File

@ -556,8 +556,10 @@ export const en = {
'settings.memory.refresh': 'Refresh',
'settings.memory.projects': 'Projects',
'settings.memory.files': 'Memory files',
'settings.memory.resourceManager': 'Resource manager',
'settings.memory.editor': 'Editor',
'settings.memory.preview': 'Preview',
'settings.memory.rendered': 'Rendered',
'settings.memory.emptyProjects': 'No memory projects found.',
'settings.memory.emptyFiles': 'No memory files found yet.',
'settings.memory.selectFile': 'Select a memory file.',
@ -572,9 +574,11 @@ export const en = {
'settings.memory.revert': 'Revert',
'settings.memory.projectSearchPlaceholder': 'Search projects by path...',
'settings.memory.fileSearchPlaceholder': 'Search memory files...',
'settings.memory.resourceSearchPlaceholder': 'Search projects or memory files...',
'settings.memory.noProjectMatches': 'No projects match this search.',
'settings.memory.noFileMatches': 'No memory files match this search.',
'settings.memory.clearSearch': 'Clear search',
'settings.memory.toggleFolder': 'Toggle {name}',
// Settings > Plugins
'settings.plugins.title': 'Installed Plugins',

View File

@ -558,8 +558,10 @@ export const zh: Record<TranslationKey, string> = {
'settings.memory.refresh': '刷新',
'settings.memory.projects': '项目',
'settings.memory.files': '记忆文件',
'settings.memory.resourceManager': '资源管理器',
'settings.memory.editor': '编辑',
'settings.memory.preview': '预览',
'settings.memory.rendered': '已渲染',
'settings.memory.emptyProjects': '还没有找到记忆项目。',
'settings.memory.emptyFiles': '还没有找到记忆文件。',
'settings.memory.selectFile': '选择一个记忆文件。',
@ -574,9 +576,11 @@ export const zh: Record<TranslationKey, string> = {
'settings.memory.revert': '还原',
'settings.memory.projectSearchPlaceholder': '按路径搜索项目...',
'settings.memory.fileSearchPlaceholder': '搜索记忆文件...',
'settings.memory.resourceSearchPlaceholder': '搜索项目或记忆文件...',
'settings.memory.noProjectMatches': '没有匹配的项目。',
'settings.memory.noFileMatches': '没有匹配的记忆文件。',
'settings.memory.clearSearch': '清空搜索',
'settings.memory.toggleFolder': '展开或折叠 {name}',
// Settings > Plugins
'settings.plugins.title': '已安装插件',

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { BookOpenText, Database, FileText, FolderGit2, RefreshCw, RotateCcw, Save, Search, X } from 'lucide-react'
import { BookOpenText, ChevronDown, ChevronRight, Database, FileText, Folder, FolderGit2, RefreshCw, RotateCcw, Save, Search, X } from 'lucide-react'
import { Button } from '../components/shared/Button'
import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
import { useTranslation } from '../i18n'
@ -37,8 +37,9 @@ export function MemorySettings() {
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const pendingMemoryPath = useUIStore((s) => s.pendingMemoryPath)
const setPendingMemoryPath = useUIStore((s) => s.setPendingMemoryPath)
const [projectQuery, setProjectQuery] = useState('')
const [fileQuery, setFileQuery] = useState('')
const [resourceQuery, setResourceQuery] = useState('')
const [expandedProjectId, setExpandedProjectId] = useState<string | null>(null)
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set())
const activeSession = useMemo(
() => sessions.find((session) => session.id === activeSessionId),
@ -48,12 +49,16 @@ export function MemorySettings() {
const selectedProject = projects.find((project) => project.id === selectedProjectId) ?? null
const isDirty = Boolean(selectedFile && draftContent !== selectedFile.content)
const filteredProjects = useMemo(
() => filterProjects(projects, projectQuery),
[projectQuery, projects],
() => filterProjects(projects, resourceQuery, selectedProjectId, files),
[files, projects, resourceQuery, selectedProjectId],
)
const filteredFiles = useMemo(
() => filterFiles(files, fileQuery),
[fileQuery, files],
() => filterFiles(files, resourceQuery),
[files, resourceQuery],
)
const fileTree = useMemo(
() => buildMemoryFileTree(filteredFiles),
[filteredFiles],
)
const previewContent = stripMarkdownFrontmatter(draftContent)
@ -67,10 +72,9 @@ export function MemorySettings() {
}, [fetchFiles, selectedProjectId])
useEffect(() => {
if (!projectQuery.trim() || filteredProjects.length === 0) return
if (selectedProjectId && filteredProjects.some((project) => project.id === selectedProjectId)) return
selectProject(filteredProjects[0]!.id)
}, [filteredProjects, projectQuery, selectProject, selectedProjectId])
if (!selectedProjectId) return
setExpandedProjectId(selectedProjectId)
}, [selectedProjectId])
useEffect(() => {
if (!selectedProjectId || selectedFile || isLoadingFiles || isLoadingFile) return
@ -118,9 +122,15 @@ export function MemorySettings() {
}
}
const handleProjectSelect = (projectId: string) => {
if (projectId === selectedProjectId) return
selectProject(projectId)
const handleProjectToggle = (projectId: string) => {
if (expandedProjectId === projectId) {
setExpandedProjectId(null)
return
}
setExpandedProjectId(projectId)
if (projectId !== selectedProjectId) {
selectProject(projectId)
}
}
const handleFileOpen = (file: MemoryFile) => {
@ -128,55 +138,98 @@ export function MemorySettings() {
void openFile(selectedProjectId, file.path)
}
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 gap-4">
<header className="grid gap-4 border-b border-[var(--color-border)] pb-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div className="grid min-w-0 gap-2">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-brand)]">
<BookOpenText size={16} aria-hidden="true" />
</span>
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
<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>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 pl-10 text-xs text-[var(--color-text-tertiary)]">
<span className="truncate font-mono">{activeCwd ? projectDisplayName(activeCwd) : '~/.claude/projects'}</span>
<span>{t('settings.memory.fileCount', { count: files.length })}</span>
<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>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleRefresh}
loading={isLoadingProjects || isLoadingFiles}
icon={<RefreshCw size={15} aria-hidden="true" />}
>
{t('settings.memory.refresh')}
</Button>
</header>
{error && (
<div className="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)]">
<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 gap-4 xl:grid-cols-[390px_minmax(0,1fr)]">
<aside className="grid min-h-0 grid-rows-[minmax(210px,0.9fr)_minmax(260px,1.1fr)] overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<section className="flex min-h-0 flex-col">
<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={<FolderGit2 size={15} aria-hidden="true" />}
title={t('settings.memory.projects')}
icon={<Database size={15} aria-hidden="true" />}
title={t('settings.memory.resourceManager')}
meta={isLoadingProjects ? t('common.loading') : String(projects.length)}
/>
<div className="px-3 py-3">
<SearchField
value={projectQuery}
onChange={setProjectQuery}
placeholder={t('settings.memory.projectSearchPlaceholder')}
ariaLabel={t('settings.memory.projectSearchPlaceholder')}
value={resourceQuery}
onChange={setResourceQuery}
placeholder={t('settings.memory.resourceSearchPlaceholder')}
ariaLabel={t('settings.memory.resourceSearchPlaceholder')}
clearLabel={t('settings.memory.clearSearch')}
/>
</div>
@ -186,58 +239,41 @@ export function MemorySettings() {
) : filteredProjects.length === 0 ? (
<EmptyState icon={<Search size={18} />} text={t('settings.memory.noProjectMatches')} />
) : (
filteredProjects.map((project) => (
<ProjectRow
key={project.id}
project={project}
active={project.id === selectedProjectId}
onSelect={() => handleProjectSelect(project.id)}
/>
))
)}
</div>
</section>
<section className="flex min-h-0 flex-col border-t border-[var(--color-border)]">
<PanelHeader
icon={<Database size={15} aria-hidden="true" />}
title={t('settings.memory.files')}
meta={isLoadingFiles ? t('common.loading') : `${files.length}`}
/>
<div className="px-3 py-3">
<SearchField
value={fileQuery}
onChange={setFileQuery}
placeholder={t('settings.memory.fileSearchPlaceholder')}
ariaLabel={t('settings.memory.fileSearchPlaceholder')}
clearLabel={t('settings.memory.clearSearch')}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{files.length === 0 && !isLoadingFiles ? (
<EmptyState icon={<FileText size={18} />} text={t('settings.memory.emptyFiles')} />
) : filteredFiles.length === 0 ? (
<EmptyState icon={<Search size={18} />} text={t('settings.memory.noFileMatches')} />
) : (
filteredFiles.map((file) => (
<FileRow
key={file.path}
file={file}
active={file.path === selectedFile?.path}
onSelect={() => handleFileOpen(file)}
/>
))
<div className="py-1">
{filteredProjects.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}
loading={isSelected && isLoadingFiles}
fileTree={visibleFileTree}
activePath={selectedFile?.path ?? null}
collapsedFolders={collapsedFolders}
forceExpanded={forceExpandFiles}
onToggle={() => handleProjectToggle(project.id)}
onToggleFolder={toggleFolder}
onFileSelect={handleFileOpen}
emptyText={t('settings.memory.emptyFiles')}
/>
)
})}
</div>
)}
</div>
</section>
</aside>
<section className="min-h-0 overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<div className="grid gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<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-base font-semibold text-[var(--color-text-primary)]">
{selectedFile?.path ?? t('settings.memory.noFileSelected')}
<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>}
@ -246,36 +282,22 @@ export function MemorySettings() {
{selectedProject?.memoryDir ?? t('settings.memory.selectProject')}
</p>
</div>
<div className="flex shrink-0 gap-2">
<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 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-lowest)] px-3 text-xs font-medium uppercase tracking-normal text-[var(--color-text-tertiary)]">
<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>{formatBytes(selectedFile.bytes)}</span>
<span>MARKDOWN</span>
</div>
<textarea
aria-label={t('settings.memory.editor')}
@ -286,9 +308,9 @@ export function MemorySettings() {
/>
</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-lowest)] px-3 text-xs font-medium uppercase tracking-normal text-[var(--color-text-tertiary)]">
<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>{selectedFile.updatedAt ? formatDate(selectedFile.updatedAt) : ''}</span>
<span>{t('settings.memory.rendered')}</span>
</div>
<div className="p-6">
<MarkdownRenderer content={previewContent || ' '} variant="document" />
@ -306,6 +328,36 @@ export function MemorySettings() {
)
}
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,
@ -359,38 +411,80 @@ function PanelHeader({ icon, title, meta }: { icon?: ReactNode; title: string; m
)
}
function ProjectRow({
function ProjectTreeRow({
project,
expanded,
active,
onSelect,
loading,
fileTree,
activePath,
collapsedFolders,
forceExpanded,
onToggle,
onToggleFolder,
onFileSelect,
emptyText,
}: {
project: MemoryProject
expanded: boolean
active: boolean
onSelect: () => void
loading: boolean
fileTree: MemoryTreeNode[]
activePath: string | null
collapsedFolders: Set<string>
forceExpanded: boolean
onToggle: () => void
onToggleFolder: (path: string) => void
onFileSelect: (file: MemoryFile) => void
emptyText: string
}) {
const t = useTranslation()
const display = projectDisplayName(project.label)
return (
<button
type="button"
onClick={onSelect}
title={project.label}
className={`mb-1 w-full rounded-md px-3 py-2.5 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)] shadow-[inset_3px_0_0_var(--color-brand)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate text-sm font-semibold">{display}</span>
{project.isCurrent && <Badge>{t('settings.memory.current')}</Badge>}
</div>
<p className="mt-1 truncate text-xs text-[var(--color-text-tertiary)]">{project.label}</p>
<div className="mt-1.5 flex items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<span>{t('settings.memory.fileCount', { count: project.fileCount })}</span>
{!project.exists ? <span>{t('settings.memory.missing')}</span> : null}
</div>
</button>
<div className="mb-0.5">
<button
type="button"
onClick={onToggle}
title={project.label}
aria-expanded={expanded}
aria-label={t('settings.memory.toggleFolder', { name: display })}
className={`flex min-h-8 w-full items-center gap-1.5 rounded-sm px-1.5 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)]'
}`}
>
{expanded ? <ChevronDown size={14} className="shrink-0" aria-hidden="true" /> : <ChevronRight size={14} className="shrink-0" aria-hidden="true" />}
<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>
<span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">
{project.exists ? project.fileCount : t('settings.memory.missing')}
</span>
</button>
{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>
)
}
@ -398,46 +492,95 @@ function FileRow({
file,
active,
onSelect,
depth = 0,
}: {
file: MemoryFile
active: boolean
onSelect: () => void
depth?: number
}) {
const t = useTranslation()
return (
<button
type="button"
onClick={onSelect}
className={`mb-1 w-full rounded-md px-3 py-2.5 text-left transition-colors focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
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)] shadow-[inset_3px_0_0_var(--color-brand)]'
? '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)]'
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-2">
<FileText size={14} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
<span className="min-w-0 truncate text-sm font-semibold">{file.title}</span>
</span>
<span className="flex shrink-0 items-center gap-1.5">
{file.isIndex ? <Badge>{t('settings.memory.indexFile')}</Badge> : null}
{file.type && <Badge>{file.type}</Badge>}
</span>
</div>
<p className="mt-1 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">{file.path}</p>
{file.description && (
<p className="mt-1 truncate text-xs leading-5 text-[var(--color-text-secondary)]">
{file.description}
</p>
)}
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
<span>{formatBytes(file.bytes)}</span>
{file.updatedAt ? <span>{formatDate(file.updatedAt)}</span> : null}
</div>
<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>
<span className="ml-auto text-[11px] font-normal text-[var(--color-text-tertiary)]">{node.fileCount}</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 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)]">
@ -463,11 +606,19 @@ function normalizeSearch(value: string): string {
return value.toLowerCase().replace(/\\/g, '/').replace(/\/+/g, '/').trim()
}
function filterProjects(projects: MemoryProject[], query: string): MemoryProject[] {
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),
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),
)),
)
}
@ -516,6 +667,88 @@ function resolveMemoryFileTarget(projects: MemoryProject[], absolutePath: string
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 ''