mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +08:00
Merge commit '00733838'
This commit is contained in:
commit
62980223cf
@ -110,8 +110,10 @@ describe('MemorySettings', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('Project Memory')).toBeInTheDocument()
|
expect(await screen.findByText('Project Memory')).toBeInTheDocument()
|
||||||
expect(memoryApiMock.listProjects).toHaveBeenCalledWith('/workspace/demo')
|
expect(memoryApiMock.listProjects).toHaveBeenCalledWith('/workspace/demo')
|
||||||
expect(await screen.findByText('/workspace/demo')).toBeInTheDocument()
|
expect(await screen.findAllByText('workspace/demo')).not.toHaveLength(0)
|
||||||
expect(await screen.findByText('Project conventions.')).toBeInTheDocument()
|
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.queryByPlaceholderText('MEMORY.md or notes/project.md')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByRole('button', { name: /create memory file/i })).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({
|
memoryApiMock.listProjects.mockResolvedValue({
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
@ -159,20 +161,86 @@ describe('MemorySettings', () => {
|
|||||||
|
|
||||||
render(<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()
|
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' },
|
target: { value: 'beta' },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(screen.queryByText('workspace/alpha')).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', { name: /workspace\/alpha/ })).not.toBeInTheDocument()
|
||||||
expect(screen.getByText('workspace/beta')).toBeInTheDocument()
|
expect(screen.getAllByText('workspace/beta').length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Toggle workspace/beta' }))
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(useMemoryStore.getState().selectedProjectId).toBe('-workspace-beta')
|
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 () => {
|
it('keeps frontmatter editable but removes it from the rendered preview', async () => {
|
||||||
memoryApiMock.readFile.mockResolvedValue({
|
memoryApiMock.readFile.mockResolvedValue({
|
||||||
file: {
|
file: {
|
||||||
|
|||||||
@ -556,8 +556,10 @@ export const en = {
|
|||||||
'settings.memory.refresh': 'Refresh',
|
'settings.memory.refresh': 'Refresh',
|
||||||
'settings.memory.projects': 'Projects',
|
'settings.memory.projects': 'Projects',
|
||||||
'settings.memory.files': 'Memory files',
|
'settings.memory.files': 'Memory files',
|
||||||
|
'settings.memory.resourceManager': 'Resource manager',
|
||||||
'settings.memory.editor': 'Editor',
|
'settings.memory.editor': 'Editor',
|
||||||
'settings.memory.preview': 'Preview',
|
'settings.memory.preview': 'Preview',
|
||||||
|
'settings.memory.rendered': 'Rendered',
|
||||||
'settings.memory.emptyProjects': 'No memory projects found.',
|
'settings.memory.emptyProjects': 'No memory projects found.',
|
||||||
'settings.memory.emptyFiles': 'No memory files found yet.',
|
'settings.memory.emptyFiles': 'No memory files found yet.',
|
||||||
'settings.memory.selectFile': 'Select a memory file.',
|
'settings.memory.selectFile': 'Select a memory file.',
|
||||||
@ -572,9 +574,11 @@ export const en = {
|
|||||||
'settings.memory.revert': 'Revert',
|
'settings.memory.revert': 'Revert',
|
||||||
'settings.memory.projectSearchPlaceholder': 'Search projects by path...',
|
'settings.memory.projectSearchPlaceholder': 'Search projects by path...',
|
||||||
'settings.memory.fileSearchPlaceholder': 'Search memory files...',
|
'settings.memory.fileSearchPlaceholder': 'Search memory files...',
|
||||||
|
'settings.memory.resourceSearchPlaceholder': 'Search projects or memory files...',
|
||||||
'settings.memory.noProjectMatches': 'No projects match this search.',
|
'settings.memory.noProjectMatches': 'No projects match this search.',
|
||||||
'settings.memory.noFileMatches': 'No memory files match this search.',
|
'settings.memory.noFileMatches': 'No memory files match this search.',
|
||||||
'settings.memory.clearSearch': 'Clear search',
|
'settings.memory.clearSearch': 'Clear search',
|
||||||
|
'settings.memory.toggleFolder': 'Toggle {name}',
|
||||||
|
|
||||||
// Settings > Plugins
|
// Settings > Plugins
|
||||||
'settings.plugins.title': 'Installed Plugins',
|
'settings.plugins.title': 'Installed Plugins',
|
||||||
|
|||||||
@ -558,8 +558,10 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.memory.refresh': '刷新',
|
'settings.memory.refresh': '刷新',
|
||||||
'settings.memory.projects': '项目',
|
'settings.memory.projects': '项目',
|
||||||
'settings.memory.files': '记忆文件',
|
'settings.memory.files': '记忆文件',
|
||||||
|
'settings.memory.resourceManager': '资源管理器',
|
||||||
'settings.memory.editor': '编辑',
|
'settings.memory.editor': '编辑',
|
||||||
'settings.memory.preview': '预览',
|
'settings.memory.preview': '预览',
|
||||||
|
'settings.memory.rendered': '已渲染',
|
||||||
'settings.memory.emptyProjects': '还没有找到记忆项目。',
|
'settings.memory.emptyProjects': '还没有找到记忆项目。',
|
||||||
'settings.memory.emptyFiles': '还没有找到记忆文件。',
|
'settings.memory.emptyFiles': '还没有找到记忆文件。',
|
||||||
'settings.memory.selectFile': '选择一个记忆文件。',
|
'settings.memory.selectFile': '选择一个记忆文件。',
|
||||||
@ -574,9 +576,11 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.memory.revert': '还原',
|
'settings.memory.revert': '还原',
|
||||||
'settings.memory.projectSearchPlaceholder': '按路径搜索项目...',
|
'settings.memory.projectSearchPlaceholder': '按路径搜索项目...',
|
||||||
'settings.memory.fileSearchPlaceholder': '搜索记忆文件...',
|
'settings.memory.fileSearchPlaceholder': '搜索记忆文件...',
|
||||||
|
'settings.memory.resourceSearchPlaceholder': '搜索项目或记忆文件...',
|
||||||
'settings.memory.noProjectMatches': '没有匹配的项目。',
|
'settings.memory.noProjectMatches': '没有匹配的项目。',
|
||||||
'settings.memory.noFileMatches': '没有匹配的记忆文件。',
|
'settings.memory.noFileMatches': '没有匹配的记忆文件。',
|
||||||
'settings.memory.clearSearch': '清空搜索',
|
'settings.memory.clearSearch': '清空搜索',
|
||||||
|
'settings.memory.toggleFolder': '展开或折叠 {name}',
|
||||||
|
|
||||||
// Settings > Plugins
|
// Settings > Plugins
|
||||||
'settings.plugins.title': '已安装插件',
|
'settings.plugins.title': '已安装插件',
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import type { ReactNode } 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 { Button } from '../components/shared/Button'
|
||||||
import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
|
import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
|
||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
@ -37,8 +37,9 @@ export function MemorySettings() {
|
|||||||
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||||
const pendingMemoryPath = useUIStore((s) => s.pendingMemoryPath)
|
const pendingMemoryPath = useUIStore((s) => s.pendingMemoryPath)
|
||||||
const setPendingMemoryPath = useUIStore((s) => s.setPendingMemoryPath)
|
const setPendingMemoryPath = useUIStore((s) => s.setPendingMemoryPath)
|
||||||
const [projectQuery, setProjectQuery] = useState('')
|
const [resourceQuery, setResourceQuery] = useState('')
|
||||||
const [fileQuery, setFileQuery] = useState('')
|
const [expandedProjectId, setExpandedProjectId] = useState<string | null>(null)
|
||||||
|
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const activeSession = useMemo(
|
const activeSession = useMemo(
|
||||||
() => sessions.find((session) => session.id === activeSessionId),
|
() => sessions.find((session) => session.id === activeSessionId),
|
||||||
@ -48,12 +49,16 @@ export function MemorySettings() {
|
|||||||
const selectedProject = projects.find((project) => project.id === selectedProjectId) ?? null
|
const selectedProject = projects.find((project) => project.id === selectedProjectId) ?? null
|
||||||
const isDirty = Boolean(selectedFile && draftContent !== selectedFile.content)
|
const isDirty = Boolean(selectedFile && draftContent !== selectedFile.content)
|
||||||
const filteredProjects = useMemo(
|
const filteredProjects = useMemo(
|
||||||
() => filterProjects(projects, projectQuery),
|
() => filterProjects(projects, resourceQuery, selectedProjectId, files),
|
||||||
[projectQuery, projects],
|
[files, projects, resourceQuery, selectedProjectId],
|
||||||
)
|
)
|
||||||
const filteredFiles = useMemo(
|
const filteredFiles = useMemo(
|
||||||
() => filterFiles(files, fileQuery),
|
() => filterFiles(files, resourceQuery),
|
||||||
[fileQuery, files],
|
[files, resourceQuery],
|
||||||
|
)
|
||||||
|
const fileTree = useMemo(
|
||||||
|
() => buildMemoryFileTree(filteredFiles),
|
||||||
|
[filteredFiles],
|
||||||
)
|
)
|
||||||
const previewContent = stripMarkdownFrontmatter(draftContent)
|
const previewContent = stripMarkdownFrontmatter(draftContent)
|
||||||
|
|
||||||
@ -67,10 +72,9 @@ export function MemorySettings() {
|
|||||||
}, [fetchFiles, selectedProjectId])
|
}, [fetchFiles, selectedProjectId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectQuery.trim() || filteredProjects.length === 0) return
|
if (!selectedProjectId) return
|
||||||
if (selectedProjectId && filteredProjects.some((project) => project.id === selectedProjectId)) return
|
setExpandedProjectId(selectedProjectId)
|
||||||
selectProject(filteredProjects[0]!.id)
|
}, [selectedProjectId])
|
||||||
}, [filteredProjects, projectQuery, selectProject, selectedProjectId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProjectId || selectedFile || isLoadingFiles || isLoadingFile) return
|
if (!selectedProjectId || selectedFile || isLoadingFiles || isLoadingFile) return
|
||||||
@ -118,33 +122,60 @@ export function MemorySettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleProjectSelect = (projectId: string) => {
|
const handleProjectToggle = (projectId: string) => {
|
||||||
if (projectId === selectedProjectId) return
|
if (expandedProjectId === projectId) {
|
||||||
|
setExpandedProjectId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExpandedProjectId(projectId)
|
||||||
|
if (projectId !== selectedProjectId) {
|
||||||
selectProject(projectId)
|
selectProject(projectId)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleFileOpen = (file: MemoryFile) => {
|
const handleFileOpen = (file: MemoryFile) => {
|
||||||
if (!selectedProjectId || file.path === selectedFile?.path) return
|
if (!selectedProjectId || file.path === selectedFile?.path) return
|
||||||
void openFile(selectedProjectId, file.path)
|
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 (
|
return (
|
||||||
<div className="flex h-full min-h-[640px] flex-col gap-4">
|
<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 gap-4 border-b border-[var(--color-border)] pb-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
<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="grid min-w-0 gap-2">
|
<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">
|
||||||
<div className="flex items-center gap-2">
|
<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)]">
|
||||||
<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" />
|
<BookOpenText size={16} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
|
<div className="min-w-0">
|
||||||
|
<h2 className="truncate text-base font-semibold text-[var(--color-text-primary)]">
|
||||||
{t('settings.memory.title')}
|
{t('settings.memory.title')}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
<p className="truncate text-xs text-[var(--color-text-tertiary)]">
|
||||||
<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)]">
|
{t('settings.memory.projects')}
|
||||||
<span className="truncate font-mono">{activeCwd ? projectDisplayName(activeCwd) : '~/.claude/projects'}</span>
|
</p>
|
||||||
<span>{t('settings.memory.fileCount', { count: files.length })}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@ -155,98 +186,6 @@ export function MemorySettings() {
|
|||||||
>
|
>
|
||||||
{t('settings.memory.refresh')}
|
{t('settings.memory.refresh')}
|
||||||
</Button>
|
</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)]">
|
|
||||||
{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">
|
|
||||||
<PanelHeader
|
|
||||||
icon={<FolderGit2 size={15} aria-hidden="true" />}
|
|
||||||
title={t('settings.memory.projects')}
|
|
||||||
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')}
|
|
||||||
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')} />
|
|
||||||
) : 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>
|
|
||||||
</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">
|
|
||||||
<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>
|
|
||||||
{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 gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -269,13 +208,96 @@ export function MemorySettings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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') : String(projects.length)}
|
||||||
|
/>
|
||||||
|
<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')} />
|
||||||
|
) : filteredProjects.length === 0 ? (
|
||||||
|
<EmptyState icon={<Search size={18} />} text={t('settings.memory.noProjectMatches')} />
|
||||||
|
) : (
|
||||||
|
<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 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 ? (
|
{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="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="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>{t('settings.memory.editor')}</span>
|
||||||
<span>{formatBytes(selectedFile.bytes)}</span>
|
<span>MARKDOWN</span>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
aria-label={t('settings.memory.editor')}
|
aria-label={t('settings.memory.editor')}
|
||||||
@ -286,9 +308,9 @@ export function MemorySettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 overflow-y-auto">
|
<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>{t('settings.memory.preview')}</span>
|
||||||
<span>{selectedFile.updatedAt ? formatDate(selectedFile.updatedAt) : ''}</span>
|
<span>{t('settings.memory.rendered')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<MarkdownRenderer content={previewContent || ' '} variant="document" />
|
<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({
|
function SearchField({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@ -359,38 +411,80 @@ function PanelHeader({ icon, title, meta }: { icon?: ReactNode; title: string; m
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectRow({
|
function ProjectTreeRow({
|
||||||
project,
|
project,
|
||||||
|
expanded,
|
||||||
active,
|
active,
|
||||||
onSelect,
|
loading,
|
||||||
|
fileTree,
|
||||||
|
activePath,
|
||||||
|
collapsedFolders,
|
||||||
|
forceExpanded,
|
||||||
|
onToggle,
|
||||||
|
onToggleFolder,
|
||||||
|
onFileSelect,
|
||||||
|
emptyText,
|
||||||
}: {
|
}: {
|
||||||
project: MemoryProject
|
project: MemoryProject
|
||||||
|
expanded: boolean
|
||||||
active: 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 t = useTranslation()
|
||||||
const display = projectDisplayName(project.label)
|
const display = projectDisplayName(project.label)
|
||||||
return (
|
return (
|
||||||
|
<div className="mb-0.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSelect}
|
onClick={onToggle}
|
||||||
title={project.label}
|
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)] ${
|
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
|
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)]'
|
: '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">
|
{expanded ? <ChevronDown size={14} className="shrink-0" aria-hidden="true" /> : <ChevronRight size={14} className="shrink-0" aria-hidden="true" />}
|
||||||
<span className="min-w-0 truncate text-sm font-semibold">{display}</span>
|
<Folder size={15} className="shrink-0 text-[var(--color-brand)]" aria-hidden="true" />
|
||||||
{project.isCurrent && <Badge>{t('settings.memory.current')}</Badge>}
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">{display}</span>
|
||||||
</div>
|
<span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">
|
||||||
<p className="mt-1 truncate text-xs text-[var(--color-text-tertiary)]">{project.label}</p>
|
{project.exists ? project.fileCount : t('settings.memory.missing')}
|
||||||
<div className="mt-1.5 flex items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
|
</span>
|
||||||
<span>{t('settings.memory.fileCount', { count: project.fileCount })}</span>
|
|
||||||
{!project.exists ? <span>{t('settings.memory.missing')}</span> : null}
|
|
||||||
</div>
|
|
||||||
</button>
|
</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,
|
file,
|
||||||
active,
|
active,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
depth = 0,
|
||||||
}: {
|
}: {
|
||||||
file: MemoryFile
|
file: MemoryFile
|
||||||
active: boolean
|
active: boolean
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
|
depth?: number
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslation()
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSelect}
|
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
|
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)]'
|
: '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" />
|
<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 className="min-w-0 flex-1 truncate text-sm">{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>
|
|
||||||
</button>
|
</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 }) {
|
function Badge({ children }: { children: string }) {
|
||||||
return (
|
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)]">
|
<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()
|
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)
|
const normalized = normalizeSearch(query)
|
||||||
if (!normalized) return projects
|
if (!normalized) return projects
|
||||||
return projects.filter((project) =>
|
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
|
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 {
|
function formatDate(value: string): string {
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
if (Number.isNaN(date.getTime())) return ''
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user