diff --git a/desktop/src/__tests__/memorySettings.test.tsx b/desktop/src/__tests__/memorySettings.test.tsx index 6166ede1..eb544f27 100644 --- a/desktop/src/__tests__/memorySettings.test.tsx +++ b/desktop/src/__tests__/memorySettings.test.tsx @@ -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() - 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() + + 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: { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 00c37aa5..d44bee6b 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index deb11810..0ec2d754 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -558,8 +558,10 @@ export const zh: Record = { '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 = { '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': '已安装插件', diff --git a/desktop/src/pages/MemorySettings.tsx b/desktop/src/pages/MemorySettings.tsx index 36513f29..c5661f86 100644 --- a/desktop/src/pages/MemorySettings.tsx +++ b/desktop/src/pages/MemorySettings.tsx @@ -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(null) + const [collapsedFolders, setCollapsedFolders] = useState>(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 ( -
-
-
-
- - -

+
+
+
+ + +
+

{t('settings.memory.title')}

-
-
- {activeCwd ? projectDisplayName(activeCwd) : '~/.claude/projects'} - {t('settings.memory.fileCount', { count: files.length })} +

+ {t('settings.memory.projects')} +

+
+
+
+ +
+ + +
-
{error && ( -
+
{error}
)} -
- -
-
+
+
-

- {selectedFile?.path ?? t('settings.memory.noFileSelected')} +

+ {selectedFile?.path ? fileNameFromPath(selectedFile.path) : t('settings.memory.noFileSelected')}

{isDirty && {t('settings.memory.unsaved')}} {lastSavedAt && !isDirty && {t('settings.memory.saved')}} @@ -246,36 +282,22 @@ export function MemorySettings() { {selectedProject?.memoryDir ?? t('settings.memory.selectProject')}

-
- - +
+ {selectedFile ? ( + <> + {formatBytes(selectedFile.bytes)} + {selectedFile.updatedAt ? {formatDate(selectedFile.updatedAt)} : null} + + ) : null}
{selectedFile ? (
-
+
{t('settings.memory.editor')} - {formatBytes(selectedFile.bytes)} + MARKDOWN