mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
36b86343d5
commit
00733838ef
@ -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: {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '已安装插件',
|
||||
|
||||
@ -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 ''
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user