From 2d8e58e995c4ac2e3d885b028e588d49d06f05cf Mon Sep 17 00:00:00 2001 From: mesalogo Date: Wed, 30 Sep 2026 10:57:25 +0800 Subject: [PATCH] fix: exit note editing after saving changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return text and canvas records to read-only content after a successful save. Automatic analysis no longer reopens the editor; failed saves preserve drafts for retry. Update regression coverage and UI behavior documentation. Validation: 118 MagicNotesWorkspace tests, typecheck and lint passed. Full test suite exceeded the 120-second timeout. Release note: 笔记中的文字和画布记录保存修改成功后自动退出编辑状态,自动评论完成后不再重新打开编辑器;保存失败时保留草稿以便重试。 --- UI-DESIGN.md | 2 +- src/renderer/src/MagicNotesWorkspace.test.tsx | 99 ++++++++++++++++--- src/renderer/src/MagicNotesWorkspace.tsx | 8 +- 3 files changed, 91 insertions(+), 18 deletions(-) diff --git a/UI-DESIGN.md b/UI-DESIGN.md index f49b7cbd..4894140b 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -808,7 +808,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - Agent / MCP 或其他入口成功写入笔记后,已打开的工作台自动更新笔记列表、当前详情、待办及来源记录,不增加刷新按钮。保留仍存在的笔记与待办选择、搜索和筛选,以及未保存的标题、输入区和记录编辑草稿;普通后台刷新不重建编辑器。 - 外部删除当前笔记时,无草稿则返回总览;有草稿则保留编辑区并提示先复制草稿。外部删除正在编辑的记录时,同样保留该编辑器并明确提示记录已删除;保存失败不得清空草稿。 - 当前记录草稿非空或标题尚未保存时,返回总览必须先就地确认;编辑另一条记录等会丢弃正文草稿的操作沿用同一保护。继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。 -- 保存成功后,以该修订的编辑器初始化并完成 flush 后的内容作为修改比较基线;没有新编辑时返回不再提示。编辑器归一化不算用户修改,后续输入、画布变更和保存失败仍保留离开保护;同修订的后台刷新不替换已建立的基线。 +- 文字和画布记录保存修改成功后退出编辑状态,显示已保存正文;后续自动评论成功或失败均不重新打开编辑器。再次修改须点击编辑入口,以该修订的编辑器初始化内容作为修改比较基线。编辑器归一化不算用户修改,后续输入、画布变更和保存失败仍保留离开保护;同修订的后台刷新不替换已建立的基线。 - 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。 - 标题或正文校验、删除确认、同步进度和可就地恢复的错误仍靠近对应编辑器或操作呈现。 diff --git a/src/renderer/src/MagicNotesWorkspace.test.tsx b/src/renderer/src/MagicNotesWorkspace.test.tsx index ef74ab78..2390203d 100644 --- a/src/renderer/src/MagicNotesWorkspace.test.tsx +++ b/src/renderer/src/MagicNotesWorkspace.test.tsx @@ -351,12 +351,13 @@ describe('MagicNotesWorkspace overview navigation', () => { expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() await openNote() newEntry() - fireEvent.click(within(document.getElementById(`magic-note-entry-${createdEntryId}`)!).getByRole('button', { name: '编辑' })) for (let count = 1; count <= 2; count++) { + fireEvent.click(within(document.getElementById(`magic-note-entry-${createdEntryId}`)!).getByRole('button', { name: '编辑' })) fireEvent.click(screen.getByTestId('magic-note-editor')) fireEvent.click(screen.getByRole('button', { name: '保存修改' })) await waitFor(() => expect(updateEntry).toHaveBeenCalledTimes(count)) - await waitFor(() => expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled()) + await waitFor(() => expect(within(document.getElementById(`magic-note-entry-${createdEntryId}`)!).getByRole('button', { name: '编辑' })).toBeEnabled()) + expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument() } expect(createEntry).toHaveBeenCalledOnce() expect(updateEntry).toHaveBeenLastCalledWith({ entryId: createdEntryId, content, expectedRevision: auto ? 4 : 2 }) @@ -515,6 +516,7 @@ describe('MagicNotesWorkspace overview navigation', () => { get.mockResolvedValue({ ...detail, entries: [...detail.entries, anotherEntry] }) act(() => changeListener?.()) await waitFor(() => expect(document.querySelectorAll('.magic-note-record')).toHaveLength(2)) + fireEvent.click(original.getByRole('button', { name: '编辑' })) fireEvent.click(original.getByTestId('magic-note-editor')) fireEvent.click(document.querySelectorAll('.magic-note-record')[1]!) back() @@ -677,6 +679,70 @@ describe('MagicNotesWorkspace overview navigation', () => { }) describe('MagicNotesWorkspace detail behavior', () => { + it.each(['text', 'canvas'] as const)('exits %s editing after saving and stays out when automatic analysis completes', async (kind) => { + getSettings.mockResolvedValue({ ...settings, magicNoteCommentMode: 'after-save-auto' }) + const originalContent = kind === 'canvas' ? canvasContent : detail.entries[0]!.content + const editedContent = kind === 'canvas' ? { ...canvasContent, flow: { ops: [{ insert: 'Changed body\n' }] } } : content + get.mockResolvedValue({ ...detail, entries: [{ ...detail.entries[0]!, content: originalContent }] }) + const saved = { ...detail, revision: 2, entries: [{ ...detail.entries[0]!, content: editedContent, revision: 2 }] } + updateEntry.mockResolvedValue(saved) + canvas.flush.mockResolvedValue(editedContent) + let finish!: (value: MagicNoteDetail) => void + analyze.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve })) + render() + await openNote() + const entry = within(screen.getByRole('article')) + const editLabel = kind === 'canvas' ? '展开编辑画布' : '编辑' + const editorId = kind === 'canvas' ? 'canvas-editor' : 'magic-note-editor' + fireEvent.click(entry.getByRole('button', { name: editLabel })) + fireEvent.click(entry.getByTestId(editorId)) + fireEvent.click(screen.getByRole('button', { name: '保存修改' })) + await waitFor(() => expect(analyze).toHaveBeenCalledWith(entryId, expect.objectContaining({ expectedRevision: 2 }))) + expect(updateEntry).toHaveBeenCalledWith({ entryId, content: editedContent, expectedRevision: 1 }) + expect(entry.queryByTestId(editorId)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument() + expect(entry.getByRole('button', { name: editLabel })).toBeVisible() + await act(async () => finish({ ...saved, revision: 3, entries: [{ ...saved.entries[0]!, revision: 3 }] })) + expect(entry.queryByTestId(editorId)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument() + expect(entry.getByRole('button', { name: editLabel })).toBeEnabled() + fireEvent.click(entry.getByRole('button', { name: editLabel })) + expect(entry.getByTestId(editorId)).toBeVisible() + }) + + it.each(['text', 'canvas'] as const)('retains the %s editor and draft after a failed save and exits after retry', async (kind) => { + getSettings.mockResolvedValue({ ...settings, magicNoteCommentMode: 'after-save-auto' }) + const originalContent = kind === 'canvas' ? canvasContent : detail.entries[0]!.content + const editedContent = kind === 'canvas' ? { ...canvasContent, flow: { ops: [{ insert: 'Unsaved body\n' }] } } : content + get.mockResolvedValue({ ...detail, entries: [{ ...detail.entries[0]!, content: originalContent }] }) + const saved = { ...detail, revision: 2, entries: [{ ...detail.entries[0]!, content: editedContent, revision: 2 }] } + updateEntry.mockRejectedValueOnce(new Error('save failed')).mockResolvedValue(saved) + analyze.mockResolvedValue(saved) + canvas.flush.mockResolvedValue(editedContent) + render() + await openNote() + const entry = within(screen.getByRole('article')) + const editLabel = kind === 'canvas' ? '展开编辑画布' : '编辑' + const editorId = kind === 'canvas' ? 'canvas-editor' : 'magic-note-editor' + fireEvent.click(entry.getByRole('button', { name: editLabel })) + const editor = entry.getByTestId(editorId) + fireEvent.click(editor) + fireEvent.click(screen.getByRole('button', { name: '保存修改' })) + await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'save failed' }))) + expect(entry.getByTestId(editorId)).toBe(editor) + expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled() + if (kind === 'text') expect(entry.getByRole('button', { name: editLabel })).toBeDisabled() + else expect(entry.queryByRole('button', { name: editLabel })).not.toBeInTheDocument() + expect(analyze).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: '保存修改' })) + await waitFor(() => expect(entry.getByRole('button', { name: editLabel })).toBeEnabled()) + expect(entry.queryByTestId(editorId)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument() + expect(updateEntry).toHaveBeenCalledTimes(2) + expect(updateEntry).toHaveBeenNthCalledWith(1, { entryId, content: editedContent, expectedRevision: 1 }) + expect(updateEntry).toHaveBeenNthCalledWith(2, { entryId, content: editedContent, expectedRevision: 1 }) + }) + it('selects the explicitly created ID when an Agent appends before the local create completes', async () => { const agentEntry = { ...detail.entries[0]!, id: 'agent-entry', plainText: 'Agent A' } const localEntry = { ...detail.entries[0]!, id: createdEntryId, content, plainText: 'Local B' } @@ -711,7 +777,7 @@ describe('MagicNotesWorkspace detail behavior', () => { } else await act(async () => { fireEvent.click(screen.getByRole('button', { name: '保存修改' })) }) await act(() => vi.advanceTimersByTimeAsync(6000)) expect(analyzeDraft).not.toHaveBeenCalled() - if (action === 'save') fireEvent.click(screen.getByRole('button', { name: '取消' })) + expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument() newEntry() fireEvent.click(screen.getByTestId('magic-note-editor')) await act(() => vi.advanceTimersByTimeAsync(5000)) @@ -1037,7 +1103,8 @@ describe('MagicNotesWorkspace detail behavior', () => { expect(screen.getByRole('button', { name: '保存记录' })).toBeVisible() } else { expect(updateEntry).toHaveBeenCalledWith({ entryId, content, expectedRevision: 1 }) - expect(screen.getByRole('button', { name: '保存修改' })).toBeVisible() + expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '编辑' })).toBeEnabled() } }) @@ -1191,7 +1258,9 @@ describe('MagicNotesWorkspace canvas integration', () => { await openNote() fireEvent.click(screen.getByRole('button', { name: '展开编辑画布' })) fireEvent.click(screen.getByRole('button', { name: '保存修改' })) - await waitFor(() => expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled()) + await waitFor(() => expect(screen.getByRole('button', { name: '展开编辑画布' })).toBeEnabled()) + expect(screen.queryByTestId('canvas-editor')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: '展开编辑画布' })) const normalized = { ...canvasContent, flow: { version: 1 as const, ops: [{ insert: '\n' }] } } act(() => canvas.ready?.(normalized)) canvas.flush.mockResolvedValue(normalized) @@ -1376,7 +1445,8 @@ describe('MagicNotesWorkspace canvas integration', () => { fireEvent.click(screen.getByRole('button', { name: '保存修改' })) await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ message: '记录已保存,但自动分析失败:capture failed。可稍后重新分析。' }))) expect(updateEntry).toHaveBeenCalledOnce() - expect(screen.getByRole('button', { name: '保存修改' })).toBeVisible() + expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument() + expect(screen.queryByTestId('canvas-editor')).not.toBeInTheDocument() expect(analyze).not.toHaveBeenCalled() }) @@ -1420,7 +1490,8 @@ describe('MagicNotesWorkspace canvas integration', () => { await screen.findByTestId('canvas-editor') fireEvent.click(screen.getByRole('button', { name: '保存修改' })) await waitFor(() => expect(updateEntry).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled()) + await waitFor(() => expect(screen.getByRole('button', { name: '保存记录' })).toBeEnabled()) + expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument() expect(analyze).toHaveBeenCalledTimes(expected ? 1 : 0) expect(canvas.capture).toHaveBeenCalledTimes(expected ? 1 : 0) }) @@ -1438,7 +1509,8 @@ describe('MagicNotesWorkspace canvas integration', () => { await screen.findByTestId('canvas-editor') fireEvent.click(screen.getByRole('button', { name: '保存修改' })) await waitFor(() => expect(updateEntry).toHaveBeenCalledOnce()) - await waitFor(() => expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled()) + await waitFor(() => expect(screen.getByRole('button', { name: '保存记录' })).toBeEnabled()) + expect(screen.queryByRole('button', { name: '保存修改' })).not.toBeInTheDocument() expect(analyze).toHaveBeenCalledTimes(semanticChange ? 1 : 0) }) @@ -1480,9 +1552,10 @@ describe('MagicNotesWorkspace canvas integration', () => { fireEvent.click(screen.getByRole('button', { name: '保存修改' })) await waitFor(() => expect(updateEntry).toHaveBeenCalledOnce()) expect(analyze).not.toHaveBeenCalled() - await waitFor(() => expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled()) + await waitFor(() => expect(screen.getByRole('button', { name: '展开编辑画布' })).toBeEnabled()) + expect(screen.queryByTestId('canvas-editor')).not.toBeInTheDocument() showAi() - await screen.findByTestId('canvas-editor') + fireEvent.click(screen.getByRole('button', { name: '展开编辑画布' })) get.mockResolvedValue({ ...detail, entries: [{ ...entry, comments: [{ ...entry.comments[0]!, inputMode: 'text-fallback' }] }] }) act(() => changeListener?.()) await screen.findByText('仅文字分析(未使用画布图像)') @@ -1491,7 +1564,8 @@ describe('MagicNotesWorkspace canvas integration', () => { canvas.flush.mockResolvedValue(moved) fireEvent.click(screen.getByRole('button', { name: '保存修改' })) await waitFor(() => expect(updateEntry).toHaveBeenCalledTimes(2)) - await waitFor(() => expect(screen.getByRole('button', { name: '保存修改' })).toBeEnabled()) + await waitFor(() => expect(screen.getByRole('button', { name: '展开编辑画布' })).toBeEnabled()) + expect(screen.queryByTestId('canvas-editor')).not.toBeInTheDocument() expect(analyze).not.toHaveBeenCalled() }) @@ -1681,7 +1755,8 @@ describe('MagicNotesWorkspace canvas integration', () => { canvas.flush.mockResolvedValue(moved) fireEvent.click(screen.getByRole('button', { name: '保存修改' })) await waitFor(() => expect(updateEntry).toHaveBeenCalledWith({ entryId, content: moved, expectedRevision: 1 })) - await screen.findByTestId('canvas-editor') + await screen.findByTestId('canvas-content') + expect(screen.queryByTestId('canvas-editor')).not.toBeInTheDocument() expect(analyze).toHaveBeenCalledOnce() expect(canvas.capture).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/MagicNotesWorkspace.tsx b/src/renderer/src/MagicNotesWorkspace.tsx index 3cc14c04..c6682b11 100644 --- a/src/renderer/src/MagicNotesWorkspace.tsx +++ b/src/renderer/src/MagicNotesWorkspace.tsx @@ -1869,9 +1869,9 @@ export function MagicNotesWorkspace({ }) applyDetail(updated) const savedEntry = updated.entries.find((entry) => entry.id === editingEntry.id) - setEditingEntry(savedEntry) - editingBaselineRef.current = savedEntry?.content - editingContentRef.current = savedEntry?.content + setEditingEntry(undefined) + editingBaselineRef.current = undefined + editingContentRef.current = undefined clearDraftAnalysis() notifySuccess(t('notifications.entryUpdated')) if (analysisPreparationError !== undefined) notifyError(t('canvas.savedAnalysisFailed', { error: analysisPreparationError })) @@ -1892,8 +1892,6 @@ export function MagicNotesWorkspace({ ...options, expectedRevision: savedEntry.revision }) applyDetail(analyzed) - const analyzedEntry = analyzed.entries.find((entry) => entry.id === editingEntry.id) - setEditingEntry(analyzedEntry) notifySuccess(t('notifications.aiCommentAdded')) } catch (analysisError) { notifyError(analysisError)