mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +08:00
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:
parent
0a8f247781
commit
d11748d61a
@ -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 />)
|
render(<MemorySettings />)
|
||||||
|
|
||||||
expect(await screen.findByText('Project Memory')).toBeInTheDocument()
|
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.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()
|
||||||
|
|
||||||
|
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')
|
const editor = await screen.findByLabelText('Editor')
|
||||||
expect(editor).toHaveValue('# Project Memory\n')
|
expect(editor).toHaveValue('# Project Memory\n')
|
||||||
|
|
||||||
@ -143,7 +148,7 @@ describe('MemorySettings', () => {
|
|||||||
target: { value: '# Project Memory\n\n- Prefer small diffs.\n' },
|
target: { value: '# Project Memory\n\n- Prefer small diffs.\n' },
|
||||||
})
|
})
|
||||||
expect(screen.getByText('Unsaved')).toBeInTheDocument()
|
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 }))
|
fireEvent.click(screen.getByRole('button', { name: /save/i }))
|
||||||
|
|
||||||
@ -154,6 +159,101 @@ describe('MemorySettings', () => {
|
|||||||
content: '# Project Memory\n\n- Prefer small diffs.\n',
|
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 () => {
|
it('filters the unified resource tree by project path', async () => {
|
||||||
@ -257,7 +357,8 @@ describe('MemorySettings', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-demo', 'notes/manual.md')
|
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 () => {
|
it('opens linked memory markdown files from the rendered preview', async () => {
|
||||||
@ -302,7 +403,8 @@ describe('MemorySettings', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-demo', 'notes/manual.md')
|
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 () => {
|
it('keeps frontmatter editable but removes it from the rendered preview', async () => {
|
||||||
@ -317,10 +419,13 @@ describe('MemorySettings', () => {
|
|||||||
|
|
||||||
render(<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')
|
const editor = await screen.findByLabelText('Editor')
|
||||||
expect(editor).toHaveValue('---\ntype: project\n---\n\n# Project Memory\n')
|
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 () => {
|
it('opens the exact memory file requested from chat', async () => {
|
||||||
@ -373,8 +478,8 @@ describe('MemorySettings', () => {
|
|||||||
|
|
||||||
render(<MemorySettings />)
|
render(<MemorySettings />)
|
||||||
|
|
||||||
const editor = await screen.findByLabelText('Editor')
|
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Preferences')
|
||||||
expect(editor).toHaveValue('# Preferences\n')
|
expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument()
|
||||||
expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-other', 'preferences.md')
|
expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-other', 'preferences.md')
|
||||||
expect(useMemoryStore.getState().selectedProjectId).toBe('-workspace-other')
|
expect(useMemoryStore.getState().selectedProjectId).toBe('-workspace-other')
|
||||||
expect(useUIStore.getState().pendingMemoryPath).toBeNull()
|
expect(useUIStore.getState().pendingMemoryPath).toBeNull()
|
||||||
|
|||||||
@ -704,6 +704,7 @@ export const en = {
|
|||||||
'settings.memory.files': 'Memory files',
|
'settings.memory.files': 'Memory files',
|
||||||
'settings.memory.resourceManager': 'Resource manager',
|
'settings.memory.resourceManager': 'Resource manager',
|
||||||
'settings.memory.editor': 'Editor',
|
'settings.memory.editor': 'Editor',
|
||||||
|
'settings.memory.edit': 'Edit',
|
||||||
'settings.memory.preview': 'Preview',
|
'settings.memory.preview': 'Preview',
|
||||||
'settings.memory.rendered': 'Rendered',
|
'settings.memory.rendered': 'Rendered',
|
||||||
'settings.memory.emptyProjects': 'No memory projects found.',
|
'settings.memory.emptyProjects': 'No memory projects found.',
|
||||||
@ -718,6 +719,7 @@ export const en = {
|
|||||||
'settings.memory.unsaved': 'Unsaved',
|
'settings.memory.unsaved': 'Unsaved',
|
||||||
'settings.memory.saved': 'Saved',
|
'settings.memory.saved': 'Saved',
|
||||||
'settings.memory.revert': 'Revert',
|
'settings.memory.revert': 'Revert',
|
||||||
|
'settings.memory.discardUnsavedConfirm': 'Discard unsaved memory changes?',
|
||||||
'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.resourceSearchPlaceholder': 'Search projects or memory files...',
|
||||||
|
|||||||
@ -706,6 +706,7 @@ export const jp: Record<TranslationKey, string> = {
|
|||||||
'settings.memory.files': 'メモリファイル',
|
'settings.memory.files': 'メモリファイル',
|
||||||
'settings.memory.resourceManager': 'リソースマネージャー',
|
'settings.memory.resourceManager': 'リソースマネージャー',
|
||||||
'settings.memory.editor': 'エディター',
|
'settings.memory.editor': 'エディター',
|
||||||
|
'settings.memory.edit': '編集',
|
||||||
'settings.memory.preview': 'プレビュー',
|
'settings.memory.preview': 'プレビュー',
|
||||||
'settings.memory.rendered': 'レンダリング後',
|
'settings.memory.rendered': 'レンダリング後',
|
||||||
'settings.memory.emptyProjects': 'メモリのあるプロジェクトが見つかりません。',
|
'settings.memory.emptyProjects': 'メモリのあるプロジェクトが見つかりません。',
|
||||||
@ -720,6 +721,7 @@ export const jp: Record<TranslationKey, string> = {
|
|||||||
'settings.memory.unsaved': '未保存',
|
'settings.memory.unsaved': '未保存',
|
||||||
'settings.memory.saved': '保存済み',
|
'settings.memory.saved': '保存済み',
|
||||||
'settings.memory.revert': '元に戻す',
|
'settings.memory.revert': '元に戻す',
|
||||||
|
'settings.memory.discardUnsavedConfirm': '未保存のメモリ変更を破棄しますか?',
|
||||||
'settings.memory.projectSearchPlaceholder': 'パスでプロジェクトを検索...',
|
'settings.memory.projectSearchPlaceholder': 'パスでプロジェクトを検索...',
|
||||||
'settings.memory.fileSearchPlaceholder': 'メモリファイルを検索...',
|
'settings.memory.fileSearchPlaceholder': 'メモリファイルを検索...',
|
||||||
'settings.memory.resourceSearchPlaceholder': 'プロジェクトまたはメモリファイルを検索...',
|
'settings.memory.resourceSearchPlaceholder': 'プロジェクトまたはメモリファイルを検索...',
|
||||||
|
|||||||
@ -706,6 +706,7 @@ export const kr: Record<TranslationKey, string> = {
|
|||||||
'settings.memory.files': '메모리 파일',
|
'settings.memory.files': '메모리 파일',
|
||||||
'settings.memory.resourceManager': '리소스 관리자',
|
'settings.memory.resourceManager': '리소스 관리자',
|
||||||
'settings.memory.editor': '편집기',
|
'settings.memory.editor': '편집기',
|
||||||
|
'settings.memory.edit': '편집',
|
||||||
'settings.memory.preview': '미리 보기',
|
'settings.memory.preview': '미리 보기',
|
||||||
'settings.memory.rendered': '렌더링됨',
|
'settings.memory.rendered': '렌더링됨',
|
||||||
'settings.memory.emptyProjects': '메모리가 있는 프로젝트를 찾을 수 없습니다.',
|
'settings.memory.emptyProjects': '메모리가 있는 프로젝트를 찾을 수 없습니다.',
|
||||||
@ -720,6 +721,7 @@ export const kr: Record<TranslationKey, string> = {
|
|||||||
'settings.memory.unsaved': '저장 안 됨',
|
'settings.memory.unsaved': '저장 안 됨',
|
||||||
'settings.memory.saved': '저장됨',
|
'settings.memory.saved': '저장됨',
|
||||||
'settings.memory.revert': '되돌리기',
|
'settings.memory.revert': '되돌리기',
|
||||||
|
'settings.memory.discardUnsavedConfirm': '저장하지 않은 메모리 변경을 버릴까요?',
|
||||||
'settings.memory.projectSearchPlaceholder': '경로로 프로젝트 검색...',
|
'settings.memory.projectSearchPlaceholder': '경로로 프로젝트 검색...',
|
||||||
'settings.memory.fileSearchPlaceholder': '메모리 파일 검색...',
|
'settings.memory.fileSearchPlaceholder': '메모리 파일 검색...',
|
||||||
'settings.memory.resourceSearchPlaceholder': '프로젝트 또는 메모리 파일 검색...',
|
'settings.memory.resourceSearchPlaceholder': '프로젝트 또는 메모리 파일 검색...',
|
||||||
|
|||||||
@ -706,6 +706,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.memory.files': '記憶檔案',
|
'settings.memory.files': '記憶檔案',
|
||||||
'settings.memory.resourceManager': '資源管理器',
|
'settings.memory.resourceManager': '資源管理器',
|
||||||
'settings.memory.editor': '編輯',
|
'settings.memory.editor': '編輯',
|
||||||
|
'settings.memory.edit': '編輯',
|
||||||
'settings.memory.preview': '預覽',
|
'settings.memory.preview': '預覽',
|
||||||
'settings.memory.rendered': '已渲染',
|
'settings.memory.rendered': '已渲染',
|
||||||
'settings.memory.emptyProjects': '還沒有找到記憶專案。',
|
'settings.memory.emptyProjects': '還沒有找到記憶專案。',
|
||||||
@ -720,6 +721,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.memory.unsaved': '未儲存',
|
'settings.memory.unsaved': '未儲存',
|
||||||
'settings.memory.saved': '已儲存',
|
'settings.memory.saved': '已儲存',
|
||||||
'settings.memory.revert': '還原',
|
'settings.memory.revert': '還原',
|
||||||
|
'settings.memory.discardUnsavedConfirm': '要放棄未儲存的記憶修改嗎?',
|
||||||
'settings.memory.projectSearchPlaceholder': '按路徑搜尋專案...',
|
'settings.memory.projectSearchPlaceholder': '按路徑搜尋專案...',
|
||||||
'settings.memory.fileSearchPlaceholder': '搜尋記憶檔案...',
|
'settings.memory.fileSearchPlaceholder': '搜尋記憶檔案...',
|
||||||
'settings.memory.resourceSearchPlaceholder': '搜尋專案或記憶檔案...',
|
'settings.memory.resourceSearchPlaceholder': '搜尋專案或記憶檔案...',
|
||||||
|
|||||||
@ -706,6 +706,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.memory.files': '记忆文件',
|
'settings.memory.files': '记忆文件',
|
||||||
'settings.memory.resourceManager': '资源管理器',
|
'settings.memory.resourceManager': '资源管理器',
|
||||||
'settings.memory.editor': '编辑',
|
'settings.memory.editor': '编辑',
|
||||||
|
'settings.memory.edit': '编辑',
|
||||||
'settings.memory.preview': '预览',
|
'settings.memory.preview': '预览',
|
||||||
'settings.memory.rendered': '已渲染',
|
'settings.memory.rendered': '已渲染',
|
||||||
'settings.memory.emptyProjects': '还没有找到记忆项目。',
|
'settings.memory.emptyProjects': '还没有找到记忆项目。',
|
||||||
@ -720,6 +721,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.memory.unsaved': '未保存',
|
'settings.memory.unsaved': '未保存',
|
||||||
'settings.memory.saved': '已保存',
|
'settings.memory.saved': '已保存',
|
||||||
'settings.memory.revert': '还原',
|
'settings.memory.revert': '还原',
|
||||||
|
'settings.memory.discardUnsavedConfirm': '要放弃未保存的记忆修改吗?',
|
||||||
'settings.memory.projectSearchPlaceholder': '按路径搜索项目...',
|
'settings.memory.projectSearchPlaceholder': '按路径搜索项目...',
|
||||||
'settings.memory.fileSearchPlaceholder': '搜索记忆文件...',
|
'settings.memory.fileSearchPlaceholder': '搜索记忆文件...',
|
||||||
'settings.memory.resourceSearchPlaceholder': '搜索项目或记忆文件...',
|
'settings.memory.resourceSearchPlaceholder': '搜索项目或记忆文件...',
|
||||||
|
|||||||
@ -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, 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 { 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'
|
||||||
@ -40,6 +40,7 @@ export function MemorySettings() {
|
|||||||
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 [isEditing, setIsEditing] = useState(false)
|
||||||
|
|
||||||
const activeSession = useMemo(
|
const activeSession = useMemo(
|
||||||
() => sessions.find((session) => session.id === activeSessionId),
|
() => sessions.find((session) => session.id === activeSessionId),
|
||||||
@ -61,6 +62,7 @@ export function MemorySettings() {
|
|||||||
[filteredFiles],
|
[filteredFiles],
|
||||||
)
|
)
|
||||||
const previewContent = stripMarkdownFrontmatter(draftContent)
|
const previewContent = stripMarkdownFrontmatter(draftContent)
|
||||||
|
const selectedFilePath = selectedFile?.path ?? null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchProjects(activeCwd)
|
void fetchProjects(activeCwd)
|
||||||
@ -76,6 +78,10 @@ export function MemorySettings() {
|
|||||||
setExpandedProjectId(selectedProjectId)
|
setExpandedProjectId(selectedProjectId)
|
||||||
}, [selectedProjectId])
|
}, [selectedProjectId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsEditing(false)
|
||||||
|
}, [selectedFilePath])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProjectId || selectedFile || isLoadingFiles || isLoadingFile) return
|
if (!selectedProjectId || selectedFile || isLoadingFiles || isLoadingFile) return
|
||||||
if (pendingMemoryPath) return
|
if (pendingMemoryPath) return
|
||||||
@ -115,7 +121,13 @@ export function MemorySettings() {
|
|||||||
setPendingMemoryPath,
|
setPendingMemoryPath,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const canLeaveDirtyEdit = () => {
|
||||||
|
if (!isEditing || !isDirty) return true
|
||||||
|
return window.confirm(t('settings.memory.discardUnsavedConfirm'))
|
||||||
|
}
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
|
if (!canLeaveDirtyEdit()) return
|
||||||
void fetchProjects(activeCwd)
|
void fetchProjects(activeCwd)
|
||||||
if (selectedProjectId) {
|
if (selectedProjectId) {
|
||||||
void fetchFiles(selectedProjectId)
|
void fetchFiles(selectedProjectId)
|
||||||
@ -127,6 +139,7 @@ export function MemorySettings() {
|
|||||||
setExpandedProjectId(null)
|
setExpandedProjectId(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (projectId !== selectedProjectId && !canLeaveDirtyEdit()) return
|
||||||
setExpandedProjectId(projectId)
|
setExpandedProjectId(projectId)
|
||||||
if (projectId !== selectedProjectId) {
|
if (projectId !== selectedProjectId) {
|
||||||
selectProject(projectId)
|
selectProject(projectId)
|
||||||
@ -135,9 +148,33 @@ export function MemorySettings() {
|
|||||||
|
|
||||||
const handleFileOpen = (file: MemoryFile) => {
|
const handleFileOpen = (file: MemoryFile) => {
|
||||||
if (!selectedProjectId || file.path === selectedFile?.path) return
|
if (!selectedProjectId || file.path === selectedFile?.path) return
|
||||||
|
if (!canLeaveDirtyEdit()) return
|
||||||
void openFile(selectedProjectId, file.path)
|
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 => {
|
const handlePreviewLinkClick = (href: string): boolean => {
|
||||||
if (!selectedProjectId || !selectedFile) return false
|
if (!selectedProjectId || !selectedFile) return false
|
||||||
const targetPath = resolveMarkdownMemoryLink(
|
const targetPath = resolveMarkdownMemoryLink(
|
||||||
@ -147,6 +184,7 @@ export function MemorySettings() {
|
|||||||
files,
|
files,
|
||||||
)
|
)
|
||||||
if (!targetPath || targetPath === selectedFile.path) return false
|
if (!targetPath || targetPath === selectedFile.path) return false
|
||||||
|
if (!canLeaveDirtyEdit()) return true
|
||||||
void openFile(selectedProjectId, targetPath)
|
void openFile(selectedProjectId, targetPath)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -165,6 +203,13 @@ export function MemorySettings() {
|
|||||||
|
|
||||||
const forceExpandFiles = Boolean(resourceQuery.trim())
|
const forceExpandFiles = Boolean(resourceQuery.trim())
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
if (selectedFile) {
|
||||||
|
updateDraft(selectedFile.content)
|
||||||
|
}
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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)]">
|
<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)]">
|
<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')}
|
{t('settings.memory.refresh')}
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -306,11 +331,44 @@ export function MemorySettings() {
|
|||||||
</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">
|
isEditing ? (
|
||||||
<div className="min-h-0 border-b border-[var(--color-border)] 2xl:border-b-0 2xl:border-r">
|
<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)]">
|
<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>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span>MARKDOWN</span>
|
<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>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
aria-label={t('settings.memory.editor')}
|
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)]"
|
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>
|
||||||
<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)]">
|
<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>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span>{t('settings.memory.rendered')}</span>
|
<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>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<MarkdownRenderer
|
<MarkdownRenderer
|
||||||
@ -333,7 +403,7 @@ export function MemorySettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="flex min-h-[520px] items-center justify-center p-8">
|
<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')} />
|
<EmptyState icon={<FileText size={20} />} text={isLoadingFile ? t('common.loading') : t('settings.memory.selectFile')} />
|
||||||
|
|||||||
@ -20,7 +20,7 @@ type MemoryStore = {
|
|||||||
fetchFiles: (projectId: string) => Promise<void>
|
fetchFiles: (projectId: string) => Promise<void>
|
||||||
openFile: (projectId: string, path: string) => Promise<void>
|
openFile: (projectId: string, path: string) => Promise<void>
|
||||||
updateDraft: (content: string) => void
|
updateDraft: (content: string) => void
|
||||||
saveFile: () => Promise<void>
|
saveFile: () => Promise<boolean>
|
||||||
createFile: (projectId: string, path: string, content: string) => Promise<void>
|
createFile: (projectId: string, path: string, content: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,7 +100,7 @@ export const useMemoryStore = create<MemoryStore>((set, get) => ({
|
|||||||
|
|
||||||
saveFile: async () => {
|
saveFile: async () => {
|
||||||
const { selectedProjectId, selectedFile, draftContent } = get()
|
const { selectedProjectId, selectedFile, draftContent } = get()
|
||||||
if (!selectedProjectId || !selectedFile) return
|
if (!selectedProjectId || !selectedFile) return false
|
||||||
set({ isSaving: true, error: null })
|
set({ isSaving: true, error: null })
|
||||||
try {
|
try {
|
||||||
const { file } = await memoryApi.saveFile({
|
const { file } = await memoryApi.saveFile({
|
||||||
@ -119,8 +119,10 @@ export const useMemoryStore = create<MemoryStore>((set, get) => ({
|
|||||||
lastSavedAt: file.updatedAt,
|
lastSavedAt: file.updatedAt,
|
||||||
})
|
})
|
||||||
await get().fetchFiles(selectedProjectId)
|
await get().fetchFiles(selectedProjectId)
|
||||||
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ error: (err as Error).message, isSaving: false })
|
set({ error: (err as Error).message, isSaving: false })
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user