fix(desktop): make memory files preview-first (#533)

Tested: cd desktop && bun run test src/__tests__/memorySettings.test.tsx
Tested: bun run check:desktop
Scope-risk: narrow
Confidence: high
This commit is contained in:
程序员阿江(Relakkes) 2026-06-09 16:38:50 +08:00
parent 0a8f247781
commit d11748d61a
8 changed files with 226 additions and 39 deletions

View File

@ -124,7 +124,7 @@ describe('MemorySettings', () => {
})
})
it('loads project-scoped markdown memory and saves manual edits', async () => {
it('opens memory files in preview mode, edits on demand, and returns to preview after save', async () => {
render(<MemorySettings />)
expect(await screen.findByText('Project Memory')).toBeInTheDocument()
@ -136,6 +136,11 @@ describe('MemorySettings', () => {
expect(screen.queryByPlaceholderText('MEMORY.md or notes/project.md')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /create memory file/i })).not.toBeInTheDocument()
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Project Memory')
expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
const editor = await screen.findByLabelText('Editor')
expect(editor).toHaveValue('# Project Memory\n')
@ -143,7 +148,7 @@ describe('MemorySettings', () => {
target: { value: '# Project Memory\n\n- Prefer small diffs.\n' },
})
expect(screen.getByText('Unsaved')).toBeInTheDocument()
expect(screen.getByTestId('markdown-preview')).toHaveTextContent('Prefer small diffs')
expect(screen.queryByTestId('markdown-preview')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /save/i }))
@ -154,6 +159,101 @@ describe('MemorySettings', () => {
content: '# Project Memory\n\n- Prefer small diffs.\n',
})
})
expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument()
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Prefer small diffs')
})
it('cancels edit mode by discarding the unsaved draft', async () => {
render(<MemorySettings />)
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Project Memory')
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
const editor = await screen.findByLabelText('Editor')
fireEvent.change(editor, {
target: { value: '# Changed Memory\n' },
})
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(memoryApiMock.saveFile).not.toHaveBeenCalled()
expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument()
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Project Memory')
expect(screen.queryByText('Changed Memory')).not.toBeInTheDocument()
})
it('keeps unsaved edits when file switching is not confirmed', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
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,
},
],
})
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 />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }))
const editor = await screen.findByLabelText('Editor')
fireEvent.change(editor, {
target: { value: '# Unsaved Memory\n' },
})
fireEvent.click(screen.getByText('Manual'))
expect(confirmSpy).toHaveBeenCalled()
expect(memoryApiMock.readFile).not.toHaveBeenCalledWith('-workspace-demo', 'notes/manual.md')
expect(screen.getByLabelText('Editor')).toHaveValue('# Unsaved Memory\n')
confirmSpy.mockRestore()
})
it('saves with the platform shortcut while editing and returns to preview mode', async () => {
render(<MemorySettings />)
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }))
const editor = await screen.findByLabelText('Editor')
fireEvent.change(editor, {
target: { value: '# Project Memory\n\n- Shortcut save.\n' },
})
fireEvent.keyDown(document, { key: 's', metaKey: true })
await waitFor(() => {
expect(memoryApiMock.saveFile).toHaveBeenCalledWith({
projectId: '-workspace-demo',
path: 'MEMORY.md',
content: '# Project Memory\n\n- Shortcut save.\n',
})
})
expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument()
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Shortcut save')
})
it('filters the unified resource tree by project path', async () => {
@ -257,7 +357,8 @@ describe('MemorySettings', () => {
await waitFor(() => {
expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-demo', 'notes/manual.md')
})
expect(await screen.findByLabelText('Editor')).toHaveValue('# Manual\n')
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Manual')
expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument()
})
it('opens linked memory markdown files from the rendered preview', async () => {
@ -302,7 +403,8 @@ describe('MemorySettings', () => {
await waitFor(() => {
expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-demo', 'notes/manual.md')
})
expect(await screen.findByLabelText('Editor')).toHaveValue('# Manual\n')
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Manual')
expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument()
})
it('keeps frontmatter editable but removes it from the rendered preview', async () => {
@ -317,10 +419,13 @@ describe('MemorySettings', () => {
render(<MemorySettings />)
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Project Memory')
expect(screen.getByTestId('markdown-preview')).not.toHaveTextContent('type: project')
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
const editor = await screen.findByLabelText('Editor')
expect(editor).toHaveValue('---\ntype: project\n---\n\n# Project Memory\n')
expect(screen.getByTestId('markdown-preview')).toHaveTextContent('Project Memory')
expect(screen.getByTestId('markdown-preview')).not.toHaveTextContent('type: project')
})
it('opens the exact memory file requested from chat', async () => {
@ -373,8 +478,8 @@ describe('MemorySettings', () => {
render(<MemorySettings />)
const editor = await screen.findByLabelText('Editor')
expect(editor).toHaveValue('# Preferences\n')
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Preferences')
expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument()
expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-other', 'preferences.md')
expect(useMemoryStore.getState().selectedProjectId).toBe('-workspace-other')
expect(useUIStore.getState().pendingMemoryPath).toBeNull()

View File

@ -704,6 +704,7 @@ export const en = {
'settings.memory.files': 'Memory files',
'settings.memory.resourceManager': 'Resource manager',
'settings.memory.editor': 'Editor',
'settings.memory.edit': 'Edit',
'settings.memory.preview': 'Preview',
'settings.memory.rendered': 'Rendered',
'settings.memory.emptyProjects': 'No memory projects found.',
@ -718,6 +719,7 @@ export const en = {
'settings.memory.unsaved': 'Unsaved',
'settings.memory.saved': 'Saved',
'settings.memory.revert': 'Revert',
'settings.memory.discardUnsavedConfirm': 'Discard unsaved memory changes?',
'settings.memory.projectSearchPlaceholder': 'Search projects by path...',
'settings.memory.fileSearchPlaceholder': 'Search memory files...',
'settings.memory.resourceSearchPlaceholder': 'Search projects or memory files...',

View File

@ -706,6 +706,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.memory.files': 'メモリファイル',
'settings.memory.resourceManager': 'リソースマネージャー',
'settings.memory.editor': 'エディター',
'settings.memory.edit': '編集',
'settings.memory.preview': 'プレビュー',
'settings.memory.rendered': 'レンダリング後',
'settings.memory.emptyProjects': 'メモリのあるプロジェクトが見つかりません。',
@ -720,6 +721,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.memory.unsaved': '未保存',
'settings.memory.saved': '保存済み',
'settings.memory.revert': '元に戻す',
'settings.memory.discardUnsavedConfirm': '未保存のメモリ変更を破棄しますか?',
'settings.memory.projectSearchPlaceholder': 'パスでプロジェクトを検索...',
'settings.memory.fileSearchPlaceholder': 'メモリファイルを検索...',
'settings.memory.resourceSearchPlaceholder': 'プロジェクトまたはメモリファイルを検索...',

View File

@ -706,6 +706,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.memory.files': '메모리 파일',
'settings.memory.resourceManager': '리소스 관리자',
'settings.memory.editor': '편집기',
'settings.memory.edit': '편집',
'settings.memory.preview': '미리 보기',
'settings.memory.rendered': '렌더링됨',
'settings.memory.emptyProjects': '메모리가 있는 프로젝트를 찾을 수 없습니다.',
@ -720,6 +721,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.memory.unsaved': '저장 안 됨',
'settings.memory.saved': '저장됨',
'settings.memory.revert': '되돌리기',
'settings.memory.discardUnsavedConfirm': '저장하지 않은 메모리 변경을 버릴까요?',
'settings.memory.projectSearchPlaceholder': '경로로 프로젝트 검색...',
'settings.memory.fileSearchPlaceholder': '메모리 파일 검색...',
'settings.memory.resourceSearchPlaceholder': '프로젝트 또는 메모리 파일 검색...',

View File

@ -706,6 +706,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.memory.files': '記憶檔案',
'settings.memory.resourceManager': '資源管理器',
'settings.memory.editor': '編輯',
'settings.memory.edit': '編輯',
'settings.memory.preview': '預覽',
'settings.memory.rendered': '已渲染',
'settings.memory.emptyProjects': '還沒有找到記憶專案。',
@ -720,6 +721,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.memory.unsaved': '未儲存',
'settings.memory.saved': '已儲存',
'settings.memory.revert': '還原',
'settings.memory.discardUnsavedConfirm': '要放棄未儲存的記憶修改嗎?',
'settings.memory.projectSearchPlaceholder': '按路徑搜尋專案...',
'settings.memory.fileSearchPlaceholder': '搜尋記憶檔案...',
'settings.memory.resourceSearchPlaceholder': '搜尋專案或記憶檔案...',

View File

@ -706,6 +706,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.memory.files': '记忆文件',
'settings.memory.resourceManager': '资源管理器',
'settings.memory.editor': '编辑',
'settings.memory.edit': '编辑',
'settings.memory.preview': '预览',
'settings.memory.rendered': '已渲染',
'settings.memory.emptyProjects': '还没有找到记忆项目。',
@ -720,6 +721,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.memory.unsaved': '未保存',
'settings.memory.saved': '已保存',
'settings.memory.revert': '还原',
'settings.memory.discardUnsavedConfirm': '要放弃未保存的记忆修改吗?',
'settings.memory.projectSearchPlaceholder': '按路径搜索项目...',
'settings.memory.fileSearchPlaceholder': '搜索记忆文件...',
'settings.memory.resourceSearchPlaceholder': '搜索项目或记忆文件...',

View File

@ -1,6 +1,6 @@
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 { BookOpenText, ChevronDown, ChevronRight, Database, FileText, Folder, FolderGit2, PencilLine, RefreshCw, RotateCcw, Save, Search, X } from 'lucide-react'
import { Button } from '../components/shared/Button'
import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
import { useTranslation } from '../i18n'
@ -40,6 +40,7 @@ export function MemorySettings() {
const [resourceQuery, setResourceQuery] = useState('')
const [expandedProjectId, setExpandedProjectId] = useState<string | null>(null)
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set())
const [isEditing, setIsEditing] = useState(false)
const activeSession = useMemo(
() => sessions.find((session) => session.id === activeSessionId),
@ -61,6 +62,7 @@ export function MemorySettings() {
[filteredFiles],
)
const previewContent = stripMarkdownFrontmatter(draftContent)
const selectedFilePath = selectedFile?.path ?? null
useEffect(() => {
void fetchProjects(activeCwd)
@ -76,6 +78,10 @@ export function MemorySettings() {
setExpandedProjectId(selectedProjectId)
}, [selectedProjectId])
useEffect(() => {
setIsEditing(false)
}, [selectedFilePath])
useEffect(() => {
if (!selectedProjectId || selectedFile || isLoadingFiles || isLoadingFile) return
if (pendingMemoryPath) return
@ -115,7 +121,13 @@ export function MemorySettings() {
setPendingMemoryPath,
])
const canLeaveDirtyEdit = () => {
if (!isEditing || !isDirty) return true
return window.confirm(t('settings.memory.discardUnsavedConfirm'))
}
const handleRefresh = () => {
if (!canLeaveDirtyEdit()) return
void fetchProjects(activeCwd)
if (selectedProjectId) {
void fetchFiles(selectedProjectId)
@ -127,6 +139,7 @@ export function MemorySettings() {
setExpandedProjectId(null)
return
}
if (projectId !== selectedProjectId && !canLeaveDirtyEdit()) return
setExpandedProjectId(projectId)
if (projectId !== selectedProjectId) {
selectProject(projectId)
@ -135,9 +148,33 @@ export function MemorySettings() {
const handleFileOpen = (file: MemoryFile) => {
if (!selectedProjectId || file.path === selectedFile?.path) return
if (!canLeaveDirtyEdit()) return
void openFile(selectedProjectId, file.path)
}
const handleSave = async () => {
if (!selectedFile) return
if (!isDirty) {
setIsEditing(false)
return
}
const saved = await saveFile()
if (saved) {
setIsEditing(false)
}
}
useEffect(() => {
if (!isEditing || !selectedFile) return
const handleKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 's') return
event.preventDefault()
void handleSave()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
})
const handlePreviewLinkClick = (href: string): boolean => {
if (!selectedProjectId || !selectedFile) return false
const targetPath = resolveMarkdownMemoryLink(
@ -147,6 +184,7 @@ export function MemorySettings() {
files,
)
if (!targetPath || targetPath === selectedFile.path) return false
if (!canLeaveDirtyEdit()) return true
void openFile(selectedProjectId, targetPath)
return true
}
@ -165,6 +203,13 @@ export function MemorySettings() {
const forceExpandFiles = Boolean(resourceQuery.trim())
const handleCancelEdit = () => {
if (selectedFile) {
updateDraft(selectedFile.content)
}
setIsEditing(false)
}
return (
<div className="flex h-full min-h-[640px] flex-col overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<header className="grid min-h-[58px] border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] lg:grid-cols-[280px_minmax(0,1fr)]">
@ -199,26 +244,6 @@ export function MemorySettings() {
>
{t('settings.memory.refresh')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!selectedFile || !isDirty || isSaving}
onClick={() => selectedFile && updateDraft(selectedFile.content)}
icon={<RotateCcw size={14} aria-hidden="true" />}
>
{t('settings.memory.revert')}
</Button>
<Button
type="button"
size="sm"
disabled={!selectedFile || !isDirty}
loading={isSaving}
onClick={() => void saveFile()}
icon={<Save size={14} aria-hidden="true" />}
>
{t('common.save')}
</Button>
</div>
</div>
</header>
@ -306,11 +331,44 @@ export function MemorySettings() {
</div>
{selectedFile ? (
<div className="grid min-h-[560px] grid-rows-[minmax(300px,1fr)_minmax(260px,0.95fr)] 2xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] 2xl:grid-rows-1">
<div className="min-h-0 border-b border-[var(--color-border)] 2xl:border-b-0 2xl:border-r">
isEditing ? (
<div className="min-h-[560px]">
<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>MARKDOWN</span>
<div className="flex min-w-0 items-center gap-2">
<span>{t('settings.memory.editor')}</span>
<span>MARKDOWN</span>
</div>
<div className="flex shrink-0 items-center gap-2 normal-case">
<Button
type="button"
variant="ghost"
size="sm"
disabled={isSaving}
onClick={handleCancelEdit}
>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!isDirty || isSaving}
onClick={() => selectedFile && updateDraft(selectedFile.content)}
icon={<RotateCcw size={14} aria-hidden="true" />}
>
{t('settings.memory.revert')}
</Button>
<Button
type="button"
size="sm"
disabled={isSaving}
loading={isSaving}
onClick={() => void handleSave()}
icon={<Save size={14} aria-hidden="true" />}
>
{t('common.save')}
</Button>
</div>
</div>
<textarea
aria-label={t('settings.memory.editor')}
@ -320,10 +378,22 @@ export function MemorySettings() {
className="h-[calc(100%-40px)] w-full resize-none overflow-auto bg-transparent p-5 font-mono text-[13px] leading-6 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
</div>
<div className="min-h-0 overflow-y-auto">
) : (
<div className="min-h-[560px] overflow-y-auto">
<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.rendered')}</span>
<div className="flex min-w-0 items-center gap-2">
<span>{t('settings.memory.preview')}</span>
<span>{t('settings.memory.rendered')}</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
aria-label={t('settings.memory.edit')}
title={t('settings.memory.edit')}
onClick={() => setIsEditing(true)}
icon={<PencilLine size={14} aria-hidden="true" />}
/>
</div>
<div className="p-6">
<MarkdownRenderer
@ -333,7 +403,7 @@ export function MemorySettings() {
/>
</div>
</div>
</div>
)
) : (
<div className="flex min-h-[520px] items-center justify-center p-8">
<EmptyState icon={<FileText size={20} />} text={isLoadingFile ? t('common.loading') : t('settings.memory.selectFile')} />

View File

@ -20,7 +20,7 @@ type MemoryStore = {
fetchFiles: (projectId: string) => Promise<void>
openFile: (projectId: string, path: string) => Promise<void>
updateDraft: (content: string) => void
saveFile: () => Promise<void>
saveFile: () => Promise<boolean>
createFile: (projectId: string, path: string, content: string) => Promise<void>
}
@ -100,7 +100,7 @@ export const useMemoryStore = create<MemoryStore>((set, get) => ({
saveFile: async () => {
const { selectedProjectId, selectedFile, draftContent } = get()
if (!selectedProjectId || !selectedFile) return
if (!selectedProjectId || !selectedFile) return false
set({ isSaving: true, error: null })
try {
const { file } = await memoryApi.saveFile({
@ -119,8 +119,10 @@ export const useMemoryStore = create<MemoryStore>((set, get) => ({
lastSavedAt: file.updatedAt,
})
await get().fetchFiles(selectedProjectId)
return true
} catch (err) {
set({ error: (err as Error).message, isSaving: false })
return false
}
},