mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-28 15:53:37 +08:00
Refine project memory tree actions
The memory resource tree needed to match the project-first interaction model: projects expand in place, project rows do not show noisy counts or explicit expand affordances, and common project actions live behind the row menu. Constraint: Must reuse the existing open-target Finder/file-manager flow instead of shelling out from the view. Rejected: Reuse the session sidebar project filter menu | it is a filter surface, not the memory resource tree. Confidence: high Scope-risk: narrow Directive: Keep memory project actions backed by openTargetStore so platform-specific file managers stay centralized. Tested: cd desktop && bun run lint Tested: cd desktop && bun run test -- src/__tests__/memorySettings.test.tsx Tested: cd desktop && bun run build Tested: Playwright screenshot smoke of Settings > Memory project menu at /tmp/cc-haha-memory-ui.png
This commit is contained in:
parent
00733838ef
commit
294e02e6f4
@ -8,19 +8,38 @@ import { useSessionStore } from '../stores/sessionStore'
|
|||||||
import { useSettingsStore } from '../stores/settingsStore'
|
import { useSettingsStore } from '../stores/settingsStore'
|
||||||
import { useUIStore } from '../stores/uiStore'
|
import { useUIStore } from '../stores/uiStore'
|
||||||
|
|
||||||
const { memoryApiMock } = vi.hoisted(() => ({
|
const { memoryApiMock, openTargetStoreMock } = vi.hoisted(() => ({
|
||||||
memoryApiMock: {
|
memoryApiMock: {
|
||||||
listProjects: vi.fn(),
|
listProjects: vi.fn(),
|
||||||
listFiles: vi.fn(),
|
listFiles: vi.fn(),
|
||||||
readFile: vi.fn(),
|
readFile: vi.fn(),
|
||||||
saveFile: vi.fn(),
|
saveFile: vi.fn(),
|
||||||
},
|
},
|
||||||
|
openTargetStoreMock: {
|
||||||
|
targets: [{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' }],
|
||||||
|
platform: 'darwin',
|
||||||
|
primaryTargetId: 'finder',
|
||||||
|
lastSuccessfulTargetId: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
fetchedAt: 1,
|
||||||
|
ensureTargets: vi.fn(),
|
||||||
|
refreshTargets: vi.fn(),
|
||||||
|
openTarget: vi.fn(),
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../api/memory', () => ({
|
vi.mock('../api/memory', () => ({
|
||||||
memoryApi: memoryApiMock,
|
memoryApi: memoryApiMock,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('../stores/openTargetStore', () => ({
|
||||||
|
useOpenTargetStore: Object.assign(
|
||||||
|
(selector: (state: typeof openTargetStoreMock) => unknown) => selector(openTargetStoreMock),
|
||||||
|
{ getState: () => openTargetStoreMock },
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('../components/markdown/MarkdownRenderer', () => ({
|
vi.mock('../components/markdown/MarkdownRenderer', () => ({
|
||||||
MarkdownRenderer: ({ content }: { content: string }) => (
|
MarkdownRenderer: ({ content }: { content: string }) => (
|
||||||
<div data-testid="markdown-preview">{content}</div>
|
<div data-testid="markdown-preview">{content}</div>
|
||||||
@ -30,6 +49,11 @@ vi.mock('../components/markdown/MarkdownRenderer', () => ({
|
|||||||
describe('MemorySettings', () => {
|
describe('MemorySettings', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
window.localStorage.removeItem('cc-haha-memory-pinned-projects')
|
||||||
|
openTargetStoreMock.targets = [{ id: 'finder', kind: 'file_manager', label: 'Finder', icon: 'finder', platform: 'darwin' }]
|
||||||
|
openTargetStoreMock.primaryTargetId = 'finder'
|
||||||
|
openTargetStoreMock.ensureTargets.mockResolvedValue(undefined)
|
||||||
|
openTargetStoreMock.openTarget.mockResolvedValue(undefined)
|
||||||
useSettingsStore.setState({ locale: 'en' })
|
useSettingsStore.setState({ locale: 'en' })
|
||||||
useSessionStore.setState({
|
useSessionStore.setState({
|
||||||
sessions: [
|
sessions: [
|
||||||
@ -177,6 +201,56 @@ describe('MemorySettings', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('pins projects above the regular project list from the project menu', async () => {
|
||||||
|
memoryApiMock.listProjects.mockResolvedValue({
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
id: '-workspace-alpha',
|
||||||
|
label: '/workspace/alpha',
|
||||||
|
memoryDir: '/tmp/claude/projects/-workspace-alpha/memory',
|
||||||
|
exists: true,
|
||||||
|
fileCount: 1,
|
||||||
|
isCurrent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '-workspace-beta',
|
||||||
|
label: '/workspace/beta',
|
||||||
|
memoryDir: '/tmp/claude/projects/-workspace-beta/memory',
|
||||||
|
exists: true,
|
||||||
|
fileCount: 2,
|
||||||
|
isCurrent: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<MemorySettings />)
|
||||||
|
|
||||||
|
expect(await screen.findAllByText('workspace/alpha')).not.toHaveLength(0)
|
||||||
|
expect(await screen.findByText('workspace/beta')).toBeInTheDocument()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Project actions for workspace/beta' }))
|
||||||
|
fireEvent.click(await screen.findByRole('menuitem', { name: 'Pin project' }))
|
||||||
|
|
||||||
|
expect(screen.getByText('Pinned projects')).toBeInTheDocument()
|
||||||
|
const projectRows = screen.getAllByTestId('memory-project-row')
|
||||||
|
expect(projectRows[0]).toHaveTextContent('workspace/beta')
|
||||||
|
expect(window.localStorage.getItem('cc-haha-memory-pinned-projects')).toContain('-workspace-beta')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens a memory project in Finder through the existing open target flow', async () => {
|
||||||
|
render(<MemorySettings />)
|
||||||
|
|
||||||
|
expect(await screen.findAllByText('workspace/demo')).not.toHaveLength(0)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Project actions for workspace/demo' }))
|
||||||
|
fireEvent.click(await screen.findByRole('menuitem', { name: 'Open in Finder' }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(openTargetStoreMock.ensureTargets).toHaveBeenCalled()
|
||||||
|
expect(openTargetStoreMock.openTarget).toHaveBeenCalledWith('finder', '/workspace/demo')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('renders nested memory files as a collapsible resource tree', async () => {
|
it('renders nested memory files as a collapsible resource tree', async () => {
|
||||||
memoryApiMock.listFiles.mockResolvedValue({
|
memoryApiMock.listFiles.mockResolvedValue({
|
||||||
files: [
|
files: [
|
||||||
|
|||||||
@ -556,6 +556,7 @@ 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.pinnedProjects': 'Pinned projects',
|
||||||
'settings.memory.resourceManager': 'Resource manager',
|
'settings.memory.resourceManager': 'Resource manager',
|
||||||
'settings.memory.editor': 'Editor',
|
'settings.memory.editor': 'Editor',
|
||||||
'settings.memory.preview': 'Preview',
|
'settings.memory.preview': 'Preview',
|
||||||
@ -579,6 +580,12 @@ export const en = {
|
|||||||
'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.memory.toggleFolder': 'Toggle {name}',
|
||||||
|
'settings.memory.projectActions': 'Project actions for {name}',
|
||||||
|
'settings.memory.pinProject': 'Pin project',
|
||||||
|
'settings.memory.unpinProject': 'Unpin project',
|
||||||
|
'settings.memory.openInFinder': 'Open in Finder',
|
||||||
|
'settings.memory.openInFinderFailed': 'Could not open the project in Finder.',
|
||||||
|
'settings.memory.openInFinderUnavailable': 'No file manager is available.',
|
||||||
|
|
||||||
// Settings > Plugins
|
// Settings > Plugins
|
||||||
'settings.plugins.title': 'Installed Plugins',
|
'settings.plugins.title': 'Installed Plugins',
|
||||||
|
|||||||
@ -558,6 +558,7 @@ 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.pinnedProjects': '置顶项目',
|
||||||
'settings.memory.resourceManager': '资源管理器',
|
'settings.memory.resourceManager': '资源管理器',
|
||||||
'settings.memory.editor': '编辑',
|
'settings.memory.editor': '编辑',
|
||||||
'settings.memory.preview': '预览',
|
'settings.memory.preview': '预览',
|
||||||
@ -581,6 +582,12 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.memory.noFileMatches': '没有匹配的记忆文件。',
|
'settings.memory.noFileMatches': '没有匹配的记忆文件。',
|
||||||
'settings.memory.clearSearch': '清空搜索',
|
'settings.memory.clearSearch': '清空搜索',
|
||||||
'settings.memory.toggleFolder': '展开或折叠 {name}',
|
'settings.memory.toggleFolder': '展开或折叠 {name}',
|
||||||
|
'settings.memory.projectActions': '{name} 的项目操作',
|
||||||
|
'settings.memory.pinProject': '置顶项目',
|
||||||
|
'settings.memory.unpinProject': '取消置顶',
|
||||||
|
'settings.memory.openInFinder': '在“访达”中打开',
|
||||||
|
'settings.memory.openInFinderFailed': '无法在访达中打开项目。',
|
||||||
|
'settings.memory.openInFinderUnavailable': '没有可用的文件管理器。',
|
||||||
|
|
||||||
// Settings > Plugins
|
// Settings > Plugins
|
||||||
'settings.plugins.title': '已安装插件',
|
'settings.plugins.title': '已安装插件',
|
||||||
|
|||||||
@ -1,16 +1,18 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { MouseEvent, ReactNode } from 'react'
|
||||||
import { BookOpenText, ChevronDown, ChevronRight, Database, FileText, Folder, FolderGit2, RefreshCw, RotateCcw, Save, Search, X } from 'lucide-react'
|
import { BookOpenText, ChevronDown, ChevronRight, Database, FileText, Folder, FolderGit2, FolderOpen, MoreHorizontal, Pin, PinOff, RefreshCw, RotateCcw, Save, Search, X } from 'lucide-react'
|
||||||
import { Button } from '../components/shared/Button'
|
import { 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'
|
||||||
import { formatBytes } from '../lib/formatBytes'
|
import { formatBytes } from '../lib/formatBytes'
|
||||||
import { useMemoryStore } from '../stores/memoryStore'
|
import { useMemoryStore } from '../stores/memoryStore'
|
||||||
|
import { useOpenTargetStore } from '../stores/openTargetStore'
|
||||||
import { useSessionStore } from '../stores/sessionStore'
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
import { useUIStore } from '../stores/uiStore'
|
import { useUIStore } from '../stores/uiStore'
|
||||||
import type { MemoryFile, MemoryProject } from '../types/memory'
|
import type { MemoryFile, MemoryProject } from '../types/memory'
|
||||||
|
|
||||||
const DEFAULT_MEMORY_PATH = 'MEMORY.md'
|
const DEFAULT_MEMORY_PATH = 'MEMORY.md'
|
||||||
|
const PINNED_MEMORY_PROJECTS_STORAGE_KEY = 'cc-haha-memory-pinned-projects'
|
||||||
|
|
||||||
export function MemorySettings() {
|
export function MemorySettings() {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
@ -35,11 +37,14 @@ export function MemorySettings() {
|
|||||||
} = useMemoryStore()
|
} = useMemoryStore()
|
||||||
const sessions = useSessionStore((s) => s.sessions)
|
const sessions = useSessionStore((s) => s.sessions)
|
||||||
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
const activeSessionId = useSessionStore((s) => s.activeSessionId)
|
||||||
|
const addToast = useUIStore((s) => s.addToast)
|
||||||
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 [resourceQuery, setResourceQuery] = useState('')
|
const [resourceQuery, setResourceQuery] = useState('')
|
||||||
const [expandedProjectId, setExpandedProjectId] = useState<string | null>(null)
|
const [expandedProjectId, setExpandedProjectId] = useState<string | null>(null)
|
||||||
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set())
|
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set())
|
||||||
|
const [pinnedProjectIds, setPinnedProjectIds] = useState<Set<string>>(() => readPinnedMemoryProjects())
|
||||||
|
const [projectMenu, setProjectMenu] = useState<{ projectId: string; x: number; y: number } | null>(null)
|
||||||
|
|
||||||
const activeSession = useMemo(
|
const activeSession = useMemo(
|
||||||
() => sessions.find((session) => session.id === activeSessionId),
|
() => sessions.find((session) => session.id === activeSessionId),
|
||||||
@ -52,6 +57,18 @@ export function MemorySettings() {
|
|||||||
() => filterProjects(projects, resourceQuery, selectedProjectId, files),
|
() => filterProjects(projects, resourceQuery, selectedProjectId, files),
|
||||||
[files, projects, resourceQuery, selectedProjectId],
|
[files, projects, resourceQuery, selectedProjectId],
|
||||||
)
|
)
|
||||||
|
const orderedProjects = useMemo(
|
||||||
|
() => orderMemoryProjects(filteredProjects, pinnedProjectIds),
|
||||||
|
[filteredProjects, pinnedProjectIds],
|
||||||
|
)
|
||||||
|
const pinnedProjects = useMemo(
|
||||||
|
() => orderedProjects.filter((project) => pinnedProjectIds.has(project.id)),
|
||||||
|
[orderedProjects, pinnedProjectIds],
|
||||||
|
)
|
||||||
|
const regularProjects = useMemo(
|
||||||
|
() => orderedProjects.filter((project) => !pinnedProjectIds.has(project.id)),
|
||||||
|
[orderedProjects, pinnedProjectIds],
|
||||||
|
)
|
||||||
const filteredFiles = useMemo(
|
const filteredFiles = useMemo(
|
||||||
() => filterFiles(files, resourceQuery),
|
() => filterFiles(files, resourceQuery),
|
||||||
[files, resourceQuery],
|
[files, resourceQuery],
|
||||||
@ -76,6 +93,20 @@ export function MemorySettings() {
|
|||||||
setExpandedProjectId(selectedProjectId)
|
setExpandedProjectId(selectedProjectId)
|
||||||
}, [selectedProjectId])
|
}, [selectedProjectId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectMenu) return
|
||||||
|
const close = () => setProjectMenu(null)
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setProjectMenu(null)
|
||||||
|
}
|
||||||
|
document.addEventListener('click', close)
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', close)
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [projectMenu])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProjectId || selectedFile || isLoadingFiles || isLoadingFile) return
|
if (!selectedProjectId || selectedFile || isLoadingFiles || isLoadingFile) return
|
||||||
if (pendingMemoryPath) return
|
if (pendingMemoryPath) return
|
||||||
@ -138,6 +169,40 @@ export function MemorySettings() {
|
|||||||
void openFile(selectedProjectId, file.path)
|
void openFile(selectedProjectId, file.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const togglePinnedProject = (projectId: string) => {
|
||||||
|
setProjectMenu(null)
|
||||||
|
setPinnedProjectIds((previous) => {
|
||||||
|
const next = new Set(previous)
|
||||||
|
if (next.has(projectId)) {
|
||||||
|
next.delete(projectId)
|
||||||
|
} else {
|
||||||
|
next.add(projectId)
|
||||||
|
}
|
||||||
|
writePinnedMemoryProjects(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openProjectInFinder = async (project: MemoryProject) => {
|
||||||
|
setProjectMenu(null)
|
||||||
|
try {
|
||||||
|
const store = useOpenTargetStore.getState()
|
||||||
|
await store.ensureTargets()
|
||||||
|
const latest = useOpenTargetStore.getState()
|
||||||
|
const target = latest.targets.find((item) => item.id === 'finder')
|
||||||
|
?? latest.targets.find((item) => item.kind === 'file_manager')
|
||||||
|
if (!target) {
|
||||||
|
throw new Error(t('settings.memory.openInFinderUnavailable'))
|
||||||
|
}
|
||||||
|
await latest.openTarget(target.id, resolveMemoryProjectOpenPath(project))
|
||||||
|
} catch (error) {
|
||||||
|
addToast({
|
||||||
|
type: 'error',
|
||||||
|
message: error instanceof Error ? error.message : t('settings.memory.openInFinderFailed'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleFolder = (path: string) => {
|
const toggleFolder = (path: string) => {
|
||||||
setCollapsedFolders((previous) => {
|
setCollapsedFolders((previous) => {
|
||||||
const next = new Set(previous)
|
const next = new Set(previous)
|
||||||
@ -222,7 +287,7 @@ export function MemorySettings() {
|
|||||||
<PanelHeader
|
<PanelHeader
|
||||||
icon={<Database size={15} aria-hidden="true" />}
|
icon={<Database size={15} aria-hidden="true" />}
|
||||||
title={t('settings.memory.resourceManager')}
|
title={t('settings.memory.resourceManager')}
|
||||||
meta={isLoadingProjects ? t('common.loading') : String(projects.length)}
|
meta={isLoadingProjects ? t('common.loading') : undefined}
|
||||||
/>
|
/>
|
||||||
<div className="px-3 py-3">
|
<div className="px-3 py-3">
|
||||||
<SearchField
|
<SearchField
|
||||||
@ -236,32 +301,72 @@ export function MemorySettings() {
|
|||||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||||
{projects.length === 0 && !isLoadingProjects ? (
|
{projects.length === 0 && !isLoadingProjects ? (
|
||||||
<EmptyState icon={<FolderGit2 size={18} />} text={t('settings.memory.emptyProjects')} />
|
<EmptyState icon={<FolderGit2 size={18} />} text={t('settings.memory.emptyProjects')} />
|
||||||
) : filteredProjects.length === 0 ? (
|
) : orderedProjects.length === 0 ? (
|
||||||
<EmptyState icon={<Search size={18} />} text={t('settings.memory.noProjectMatches')} />
|
<EmptyState icon={<Search size={18} />} text={t('settings.memory.noProjectMatches')} />
|
||||||
) : (
|
) : (
|
||||||
<div className="py-1">
|
<div className="space-y-3 py-1">
|
||||||
{filteredProjects.map((project) => {
|
{pinnedProjects.length > 0 ? (
|
||||||
const isExpanded = project.id === expandedProjectId
|
<MemoryProjectSection title={t('settings.memory.pinnedProjects')}>
|
||||||
const isSelected = project.id === selectedProjectId
|
{pinnedProjects.map((project) => {
|
||||||
const visibleFileTree = isSelected ? fileTree : []
|
const isExpanded = project.id === expandedProjectId
|
||||||
return (
|
const isSelected = project.id === selectedProjectId
|
||||||
<ProjectTreeRow
|
const visibleFileTree = isSelected ? fileTree : []
|
||||||
key={project.id}
|
return (
|
||||||
project={project}
|
<ProjectTreeRow
|
||||||
expanded={isExpanded}
|
key={project.id}
|
||||||
active={isSelected}
|
project={project}
|
||||||
loading={isSelected && isLoadingFiles}
|
expanded={isExpanded}
|
||||||
fileTree={visibleFileTree}
|
active={isSelected}
|
||||||
activePath={selectedFile?.path ?? null}
|
pinned
|
||||||
collapsedFolders={collapsedFolders}
|
loading={isSelected && isLoadingFiles}
|
||||||
forceExpanded={forceExpandFiles}
|
fileTree={visibleFileTree}
|
||||||
onToggle={() => handleProjectToggle(project.id)}
|
activePath={selectedFile?.path ?? null}
|
||||||
onToggleFolder={toggleFolder}
|
collapsedFolders={collapsedFolders}
|
||||||
onFileSelect={handleFileOpen}
|
forceExpanded={forceExpandFiles}
|
||||||
emptyText={t('settings.memory.emptyFiles')}
|
onToggle={() => handleProjectToggle(project.id)}
|
||||||
/>
|
onOpenMenu={(event) => {
|
||||||
)
|
event.stopPropagation()
|
||||||
})}
|
setProjectMenu(positionProjectMenu(project.id, event.clientX, event.clientY))
|
||||||
|
}}
|
||||||
|
onToggleFolder={toggleFolder}
|
||||||
|
onFileSelect={handleFileOpen}
|
||||||
|
emptyText={t('settings.memory.emptyFiles')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</MemoryProjectSection>
|
||||||
|
) : null}
|
||||||
|
{regularProjects.length > 0 ? (
|
||||||
|
<MemoryProjectSection title={pinnedProjects.length > 0 ? t('settings.memory.projects') : undefined}>
|
||||||
|
{regularProjects.map((project) => {
|
||||||
|
const isExpanded = project.id === expandedProjectId
|
||||||
|
const isSelected = project.id === selectedProjectId
|
||||||
|
const visibleFileTree = isSelected ? fileTree : []
|
||||||
|
return (
|
||||||
|
<ProjectTreeRow
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
expanded={isExpanded}
|
||||||
|
active={isSelected}
|
||||||
|
pinned={false}
|
||||||
|
loading={isSelected && isLoadingFiles}
|
||||||
|
fileTree={visibleFileTree}
|
||||||
|
activePath={selectedFile?.path ?? null}
|
||||||
|
collapsedFolders={collapsedFolders}
|
||||||
|
forceExpanded={forceExpandFiles}
|
||||||
|
onToggle={() => handleProjectToggle(project.id)}
|
||||||
|
onOpenMenu={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
setProjectMenu(positionProjectMenu(project.id, event.clientX, event.clientY))
|
||||||
|
}}
|
||||||
|
onToggleFolder={toggleFolder}
|
||||||
|
onFileSelect={handleFileOpen}
|
||||||
|
emptyText={t('settings.memory.emptyFiles')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</MemoryProjectSection>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -324,6 +429,17 @@ export function MemorySettings() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{projectMenu ? (
|
||||||
|
<MemoryProjectMenu
|
||||||
|
project={orderedProjects.find((project) => project.id === projectMenu.projectId) ?? null}
|
||||||
|
x={projectMenu.x}
|
||||||
|
y={projectMenu.y}
|
||||||
|
pinned={pinnedProjectIds.has(projectMenu.projectId)}
|
||||||
|
onTogglePin={() => togglePinnedProject(projectMenu.projectId)}
|
||||||
|
onOpenInFinder={(project) => void openProjectInFinder(project)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -399,14 +515,27 @@ function SearchField({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelHeader({ icon, title, meta }: { icon?: ReactNode; title: string; meta: string }) {
|
function PanelHeader({ icon, title, meta }: { icon?: ReactNode; title: string; meta?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-11 items-center justify-between border-b border-[var(--color-border)] px-3">
|
<div className="flex h-11 items-center justify-between border-b border-[var(--color-border)] px-3">
|
||||||
<h3 className="flex min-w-0 items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
|
<h3 className="flex min-w-0 items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
|
||||||
{icon ? <span className="text-[var(--color-text-tertiary)]">{icon}</span> : null}
|
{icon ? <span className="text-[var(--color-text-tertiary)]">{icon}</span> : null}
|
||||||
<span className="truncate">{title}</span>
|
<span className="truncate">{title}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-xs text-[var(--color-text-tertiary)]">{meta}</span>
|
{meta ? <span className="text-xs text-[var(--color-text-tertiary)]">{meta}</span> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MemoryProjectSection({ title, children }: { title?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{title ? (
|
||||||
|
<div className="px-2 pb-1 text-[11px] font-semibold text-[var(--color-text-tertiary)]">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -415,12 +544,14 @@ function ProjectTreeRow({
|
|||||||
project,
|
project,
|
||||||
expanded,
|
expanded,
|
||||||
active,
|
active,
|
||||||
|
pinned,
|
||||||
loading,
|
loading,
|
||||||
fileTree,
|
fileTree,
|
||||||
activePath,
|
activePath,
|
||||||
collapsedFolders,
|
collapsedFolders,
|
||||||
forceExpanded,
|
forceExpanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onOpenMenu,
|
||||||
onToggleFolder,
|
onToggleFolder,
|
||||||
onFileSelect,
|
onFileSelect,
|
||||||
emptyText,
|
emptyText,
|
||||||
@ -428,12 +559,14 @@ function ProjectTreeRow({
|
|||||||
project: MemoryProject
|
project: MemoryProject
|
||||||
expanded: boolean
|
expanded: boolean
|
||||||
active: boolean
|
active: boolean
|
||||||
|
pinned: boolean
|
||||||
loading: boolean
|
loading: boolean
|
||||||
fileTree: MemoryTreeNode[]
|
fileTree: MemoryTreeNode[]
|
||||||
activePath: string | null
|
activePath: string | null
|
||||||
collapsedFolders: Set<string>
|
collapsedFolders: Set<string>
|
||||||
forceExpanded: boolean
|
forceExpanded: boolean
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
|
onOpenMenu: (event: MouseEvent<HTMLButtonElement>) => void
|
||||||
onToggleFolder: (path: string) => void
|
onToggleFolder: (path: string) => void
|
||||||
onFileSelect: (file: MemoryFile) => void
|
onFileSelect: (file: MemoryFile) => void
|
||||||
emptyText: string
|
emptyText: string
|
||||||
@ -442,25 +575,40 @@ function ProjectTreeRow({
|
|||||||
const display = projectDisplayName(project.label)
|
const display = projectDisplayName(project.label)
|
||||||
return (
|
return (
|
||||||
<div className="mb-0.5">
|
<div className="mb-0.5">
|
||||||
<button
|
<div
|
||||||
type="button"
|
data-testid="memory-project-row"
|
||||||
onClick={onToggle}
|
className={`group flex min-h-9 w-full items-center gap-1.5 rounded-md px-2 py-1 text-left transition-colors focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
|
||||||
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
|
active
|
||||||
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
|
? '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)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{expanded ? <ChevronDown size={14} className="shrink-0" aria-hidden="true" /> : <ChevronRight size={14} className="shrink-0" aria-hidden="true" />}
|
<button
|
||||||
<Folder size={15} className="shrink-0 text-[var(--color-brand)]" aria-hidden="true" />
|
type="button"
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{display}</span>
|
onClick={onToggle}
|
||||||
<span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">
|
title={project.label}
|
||||||
{project.exists ? project.fileCount : t('settings.memory.missing')}
|
aria-expanded={expanded}
|
||||||
|
aria-label={t('settings.memory.toggleFolder', { name: display })}
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-1.5 text-left outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
|
||||||
|
>
|
||||||
|
<Folder size={15} className="shrink-0 text-[var(--color-brand)]" aria-hidden="true" />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">{display}</span>
|
||||||
|
{pinned ? <Pin size={13} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" /> : null}
|
||||||
|
{!project.exists ? (
|
||||||
|
<span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">{t('settings.memory.missing')}</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
<span className="ml-0.5 flex shrink-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenMenu}
|
||||||
|
aria-label={t('settings.memory.projectActions', { name: display })}
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-md text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<div className="ml-[18px] border-l border-[var(--color-border)] pl-2">
|
<div className="ml-[18px] border-l border-[var(--color-border)] pl-2">
|
||||||
@ -559,7 +707,6 @@ function MemoryTreeRow({
|
|||||||
{isCollapsed ? <ChevronRight size={14} aria-hidden="true" /> : <ChevronDown size={14} aria-hidden="true" />}
|
{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" />
|
<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="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>
|
</button>
|
||||||
{!isCollapsed ? (
|
{!isCollapsed ? (
|
||||||
<div className="ml-[18px] border-l border-[var(--color-border)] pl-2">
|
<div className="ml-[18px] border-l border-[var(--color-border)] pl-2">
|
||||||
@ -581,6 +728,53 @@ function MemoryTreeRow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MemoryProjectMenu({
|
||||||
|
project,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
pinned,
|
||||||
|
onTogglePin,
|
||||||
|
onOpenInFinder,
|
||||||
|
}: {
|
||||||
|
project: MemoryProject | null
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
pinned: boolean
|
||||||
|
onTogglePin: () => void
|
||||||
|
onOpenInFinder: (project: MemoryProject) => void
|
||||||
|
}) {
|
||||||
|
const t = useTranslation()
|
||||||
|
if (!project) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="fixed z-50 min-w-[220px] overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[var(--shadow-dropdown)]"
|
||||||
|
style={{ left: x, top: y }}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={onTogglePin}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-semibold text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)]"
|
||||||
|
>
|
||||||
|
{pinned ? <PinOff size={17} aria-hidden="true" /> : <Pin size={17} aria-hidden="true" />}
|
||||||
|
<span>{pinned ? t('settings.memory.unpinProject') : t('settings.memory.pinProject')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => onOpenInFinder(project)}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-semibold text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)]"
|
||||||
|
>
|
||||||
|
<FolderOpen size={17} aria-hidden="true" />
|
||||||
|
<span>{t('settings.memory.openInFinder')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function Badge({ children }: { children: string }) {
|
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)]">
|
||||||
@ -630,6 +824,15 @@ function filterFiles(files: MemoryFile[], query: string): MemoryFile[] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function orderMemoryProjects(projects: MemoryProject[], pinnedProjectIds: Set<string>): MemoryProject[] {
|
||||||
|
return [...projects].sort((a, b) => {
|
||||||
|
const aPinned = pinnedProjectIds.has(a.id)
|
||||||
|
const bPinned = pinnedProjectIds.has(b.id)
|
||||||
|
if (aPinned !== bPinned) return aPinned ? -1 : 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function projectDisplayName(label: string): string {
|
function projectDisplayName(label: string): string {
|
||||||
const normalized = label.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '')
|
const normalized = label.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '')
|
||||||
const parts = normalized.split('/').filter(Boolean)
|
const parts = normalized.split('/').filter(Boolean)
|
||||||
@ -637,6 +840,46 @@ function projectDisplayName(label: string): string {
|
|||||||
return parts[0] ?? label
|
return parts[0] ?? label
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readPinnedMemoryProjects(): Set<string> {
|
||||||
|
if (typeof window === 'undefined') return new Set()
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(PINNED_MEMORY_PROJECTS_STORAGE_KEY)
|
||||||
|
if (!raw) return new Set()
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!Array.isArray(parsed)) return new Set()
|
||||||
|
return new Set(parsed.filter((item): item is string => typeof item === 'string' && item.length > 0))
|
||||||
|
} catch {
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePinnedMemoryProjects(projectIds: Set<string>) {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(PINNED_MEMORY_PROJECTS_STORAGE_KEY, JSON.stringify([...projectIds]))
|
||||||
|
} catch {
|
||||||
|
// Ignore storage failures; pinning remains a session-local UI preference.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionProjectMenu(projectId: string, clientX: number, clientY: number) {
|
||||||
|
const viewportWidth = typeof window === 'undefined' ? 1024 : window.innerWidth
|
||||||
|
const viewportHeight = typeof window === 'undefined' ? 768 : window.innerHeight
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
x: Math.max(8, Math.min(clientX, viewportWidth - 236)),
|
||||||
|
y: Math.max(8, Math.min(clientY + 6, viewportHeight - 120)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMemoryProjectOpenPath(project: MemoryProject): string {
|
||||||
|
return isLikelyAbsolutePath(project.label) ? project.label : project.memoryDir
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyAbsolutePath(value: string): boolean {
|
||||||
|
return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
function stripMarkdownFrontmatter(content: string): string {
|
function stripMarkdownFrontmatter(content: string): string {
|
||||||
if (!content.startsWith('---')) return content
|
if (!content.startsWith('---')) return content
|
||||||
const end = content.indexOf('\n---', 3)
|
const end = content.indexOf('\n---', 3)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user