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 d73575f6..fc26bd40 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 17fa42d2..725a1aab 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() {
}
title={t('settings.memory.resourceManager')}
- meta={isLoadingProjects ? t('common.loading') : String(projects.length)}
+ meta={isLoadingProjects ? t('common.loading') : undefined}
/>
{projects.length === 0 && !isLoadingProjects ? (
} text={t('settings.memory.emptyProjects')} />
- ) : filteredProjects.length === 0 ? (
+ ) : orderedProjects.length === 0 ? (
} text={t('settings.memory.noProjectMatches')} />
) : (
-
- {filteredProjects.map((project) => {
- const isExpanded = project.id === expandedProjectId
- const isSelected = project.id === selectedProjectId
- const visibleFileTree = isSelected ? fileTree : []
- return (
-
handleProjectToggle(project.id)}
- onToggleFolder={toggleFolder}
- onFileSelect={handleFileOpen}
- emptyText={t('settings.memory.emptyFiles')}
- />
- )
- })}
+
+ {pinnedProjects.length > 0 ? (
+
+ {pinnedProjects.map((project) => {
+ const isExpanded = project.id === expandedProjectId
+ const isSelected = project.id === selectedProjectId
+ const visibleFileTree = isSelected ? fileTree : []
+ return (
+ handleProjectToggle(project.id)}
+ onOpenMenu={(event) => {
+ event.stopPropagation()
+ setProjectMenu(positionProjectMenu(project.id, event.clientX, event.clientY))
+ }}
+ onToggleFolder={toggleFolder}
+ onFileSelect={handleFileOpen}
+ emptyText={t('settings.memory.emptyFiles')}
+ />
+ )
+ })}
+
+ ) : null}
+ {regularProjects.length > 0 ? (
+
0 ? t('settings.memory.projects') : undefined}>
+ {regularProjects.map((project) => {
+ const isExpanded = project.id === expandedProjectId
+ const isSelected = project.id === selectedProjectId
+ const visibleFileTree = isSelected ? fileTree : []
+ return (
+ handleProjectToggle(project.id)}
+ onOpenMenu={(event) => {
+ event.stopPropagation()
+ setProjectMenu(positionProjectMenu(project.id, event.clientX, event.clientY))
+ }}
+ onToggleFolder={toggleFolder}
+ onFileSelect={handleFileOpen}
+ emptyText={t('settings.memory.emptyFiles')}
+ />
+ )
+ })}
+
+ ) : null}
)}
@@ -324,6 +429,17 @@ export function MemorySettings() {
)}
+
+ {projectMenu ? (
+ 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}
)
}
@@ -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 (
{icon ? {icon} : null}
{title}
- {meta}
+ {meta ? {meta} : null}
+
+ )
+}
+
+function MemoryProjectSection({ title, children }: { title?: string; children: ReactNode }) {
+ return (
+
+ {title ? (
+
+ {title}
+
+ ) : null}
+ {children}
)
}
@@ -415,12 +544,14 @@ function ProjectTreeRow({
project,
expanded,
active,
+ pinned,
loading,
fileTree,
activePath,
collapsedFolders,
forceExpanded,
onToggle,
+ onOpenMenu,
onToggleFolder,
onFileSelect,
emptyText,
@@ -428,12 +559,14 @@ function ProjectTreeRow({
project: MemoryProject
expanded: boolean
active: boolean
+ pinned: boolean
loading: boolean
fileTree: MemoryTreeNode[]
activePath: string | null
collapsedFolders: Set
forceExpanded: boolean
onToggle: () => void
+ onOpenMenu: (event: MouseEvent) => void
onToggleFolder: (path: string) => void
onFileSelect: (file: MemoryFile) => void
emptyText: string
@@ -442,25 +575,40 @@ function ProjectTreeRow({
const display = projectDisplayName(project.label)
return (
-
+
{expanded ? (
@@ -559,7 +707,6 @@ function MemoryTreeRow({
{isCollapsed ?
:
}
{node.name}
-
{node.fileCount}
{!isCollapsed ? (
@@ -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 (
+
event.stopPropagation()}
+ >
+
+ {pinned ? : }
+ {pinned ? t('settings.memory.unpinProject') : t('settings.memory.pinProject')}
+
+
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)]"
+ >
+
+ {t('settings.memory.openInFinder')}
+
+
+ )
+}
+
function Badge({ children }: { children: string }) {
return (
@@ -630,6 +824,15 @@ function filterFiles(files: MemoryFile[], query: string): MemoryFile[] {
)
}
+function orderMemoryProjects(projects: MemoryProject[], pinnedProjectIds: Set): 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 {
const normalized = label.replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, '')
const parts = normalized.split('/').filter(Boolean)
@@ -637,6 +840,46 @@ function projectDisplayName(label: string): string {
return parts[0] ?? label
}
+function readPinnedMemoryProjects(): Set {
+ 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) {
+ 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 {
if (!content.startsWith('---')) return content
const end = content.indexOf('\n---', 3)