diff --git a/desktop/src/__tests__/memorySettings.test.tsx b/desktop/src/__tests__/memorySettings.test.tsx index eb544f27..85234494 100644 --- a/desktop/src/__tests__/memorySettings.test.tsx +++ b/desktop/src/__tests__/memorySettings.test.tsx @@ -8,19 +8,38 @@ import { useSessionStore } from '../stores/sessionStore' import { useSettingsStore } from '../stores/settingsStore' import { useUIStore } from '../stores/uiStore' -const { memoryApiMock } = vi.hoisted(() => ({ +const { memoryApiMock, openTargetStoreMock } = vi.hoisted(() => ({ memoryApiMock: { listProjects: vi.fn(), listFiles: vi.fn(), readFile: 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', () => ({ memoryApi: memoryApiMock, })) +vi.mock('../stores/openTargetStore', () => ({ + useOpenTargetStore: Object.assign( + (selector: (state: typeof openTargetStoreMock) => unknown) => selector(openTargetStoreMock), + { getState: () => openTargetStoreMock }, + ), +})) + vi.mock('../components/markdown/MarkdownRenderer', () => ({ MarkdownRenderer: ({ content }: { content: string }) => (
{content}
@@ -30,6 +49,11 @@ vi.mock('../components/markdown/MarkdownRenderer', () => ({ describe('MemorySettings', () => { beforeEach(() => { 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' }) useSessionStore.setState({ 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() + + 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() + + 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 () => { memoryApiMock.listFiles.mockResolvedValue({ files: [ diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index d44bee6b..65419679 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -556,6 +556,7 @@ export const en = { 'settings.memory.refresh': 'Refresh', 'settings.memory.projects': 'Projects', 'settings.memory.files': 'Memory files', + 'settings.memory.pinnedProjects': 'Pinned projects', 'settings.memory.resourceManager': 'Resource manager', 'settings.memory.editor': 'Editor', 'settings.memory.preview': 'Preview', @@ -579,6 +580,12 @@ export const en = { 'settings.memory.noFileMatches': 'No memory files match this search.', 'settings.memory.clearSearch': 'Clear search', '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.title': 'Installed Plugins', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 0ec2d754..0033a8be 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -558,6 +558,7 @@ export const zh: Record = { 'settings.memory.refresh': '刷新', 'settings.memory.projects': '项目', 'settings.memory.files': '记忆文件', + 'settings.memory.pinnedProjects': '置顶项目', 'settings.memory.resourceManager': '资源管理器', 'settings.memory.editor': '编辑', 'settings.memory.preview': '预览', @@ -581,6 +582,12 @@ export const zh: Record = { 'settings.memory.noFileMatches': '没有匹配的记忆文件。', 'settings.memory.clearSearch': '清空搜索', '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.title': '已安装插件', diff --git a/desktop/src/pages/MemorySettings.tsx b/desktop/src/pages/MemorySettings.tsx index c5661f86..17d0295c 100644 --- a/desktop/src/pages/MemorySettings.tsx +++ b/desktop/src/pages/MemorySettings.tsx @@ -1,16 +1,18 @@ import { useEffect, useMemo, useState } from 'react' -import type { ReactNode } from 'react' -import { BookOpenText, ChevronDown, ChevronRight, Database, FileText, Folder, FolderGit2, RefreshCw, RotateCcw, Save, Search, X } from 'lucide-react' +import type { MouseEvent, ReactNode } from '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 { MarkdownRenderer } from '../components/markdown/MarkdownRenderer' import { useTranslation } from '../i18n' import { formatBytes } from '../lib/formatBytes' import { useMemoryStore } from '../stores/memoryStore' +import { useOpenTargetStore } from '../stores/openTargetStore' import { useSessionStore } from '../stores/sessionStore' import { useUIStore } from '../stores/uiStore' import type { MemoryFile, MemoryProject } from '../types/memory' const DEFAULT_MEMORY_PATH = 'MEMORY.md' +const PINNED_MEMORY_PROJECTS_STORAGE_KEY = 'cc-haha-memory-pinned-projects' export function MemorySettings() { const t = useTranslation() @@ -35,11 +37,14 @@ export function MemorySettings() { } = useMemoryStore() const sessions = useSessionStore((s) => s.sessions) const activeSessionId = useSessionStore((s) => s.activeSessionId) + const addToast = useUIStore((s) => s.addToast) const pendingMemoryPath = useUIStore((s) => s.pendingMemoryPath) const setPendingMemoryPath = useUIStore((s) => s.setPendingMemoryPath) const [resourceQuery, setResourceQuery] = useState('') const [expandedProjectId, setExpandedProjectId] = useState(null) const [collapsedFolders, setCollapsedFolders] = useState>(new Set()) + const [pinnedProjectIds, setPinnedProjectIds] = useState>(() => readPinnedMemoryProjects()) + const [projectMenu, setProjectMenu] = useState<{ projectId: string; x: number; y: number } | null>(null) const activeSession = useMemo( () => sessions.find((session) => session.id === activeSessionId), @@ -52,6 +57,18 @@ export function MemorySettings() { () => filterProjects(projects, resourceQuery, selectedProjectId, files), [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( () => filterFiles(files, resourceQuery), [files, resourceQuery], @@ -76,6 +93,20 @@ export function MemorySettings() { setExpandedProjectId(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(() => { if (!selectedProjectId || selectedFile || isLoadingFiles || isLoadingFile) return if (pendingMemoryPath) return @@ -138,6 +169,40 @@ export function MemorySettings() { 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) => { setCollapsedFolders((previous) => { const next = new Set(previous) @@ -222,7 +287,7 @@ export function MemorySettings() {