diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index da2d657..9b26959 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -1932,6 +1932,50 @@ describe('AssistantDatabase', () => { database.close() }) + it('updates a derived todo and its source checklist together', async () => { + const database = await createDatabase() + const note = database.createMagicNote({ title: '发布笔记' }) + database.createMagicNoteEntry({ + noteId: note.id, + content: { + version: 1, + ops: [ + { insert: '核对发布材料' }, + { insert: '\n', attributes: { list: 'unchecked' } } + ] + }, + plainText: '核对发布材料' + }) + const todo = database.listMagicTodos()[0]! + + const updated = database.updateMagicTodo({ + todoId: todo.id, + completed: true, + expectedRevision: todo.revision + }) + + expect(updated).toMatchObject({ + id: todo.id, + completed: true, + revision: todo.revision + 1 + }) + expect( + database.getMagicNote(note.id).entries[0]!.content.ops + ).toEqual([ + { insert: '核对发布材料' }, + { insert: '\n', attributes: { list: 'checked' } } + ]) + expect(() => + database.updateMagicTodo({ + todoId: todo.id, + completed: false, + expectedRevision: todo.revision + }) + ).toThrow('待办已被更新,请刷新后重试') + + database.close() + }) + it('protects magic note records from stale revisions', async () => { const database = await createDatabase() const note = database.createMagicNote({ title: '并发笔记' }) diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index c1f0733..6cc35f8 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -51,13 +51,16 @@ import { type MagicNoteRichContent, type MagicNoteSearchResult, type MagicNoteSummary, - type MagicTodoItem + type MagicTodoItem, + type MagicTodoUpdateInput } from '../../shared/magic-notes-contracts' import type { ComputerControlAuditEvent } from '../computer-control/audit' import { magicNoteChecklistItems, magicNoteImageBytes, - magicNotePreview + magicNotePlainText, + magicNotePreview, + setMagicNoteChecklistCompletion } from '../magic-notes/rich-content' import { computeNextHeartbeatRun } from './heartbeat-recurrence' @@ -2164,6 +2167,86 @@ export class AssistantDatabase { return toMagicTodo(row) } + updateMagicTodo(input: MagicTodoUpdateInput): MagicTodoItem { + const database = this.requireDatabase() + const now = new Date().toISOString() + database.exec('BEGIN IMMEDIATE') + try { + const existing = database + .prepare( + `SELECT t.note_id, t.entry_id, t.source_index, t.completed, + t.revision AS todo_revision, + e.content_json, e.revision AS entry_revision + FROM magic_todos t + INNER JOIN magic_note_entries e ON e.id = t.entry_id + WHERE t.id = ? AND t.source = 'note'` + ) + .get(input.todoId) as + | { + note_id: string + entry_id: string + source_index: number + completed: number + todo_revision: number + content_json: string + entry_revision: number + } + | undefined + if (!existing) { + throw new Error('待办不存在') + } + if (existing.todo_revision !== input.expectedRevision) { + throw new Error('待办已被更新,请刷新后重试') + } + if (Boolean(existing.completed) === input.completed) { + database.exec('COMMIT') + return this.getMagicTodo(input.todoId) + } + + const content = setMagicNoteChecklistCompletion( + JSON.parse(existing.content_json) as MagicNoteRichContent, + existing.source_index, + input.completed + ) + const result = database + .prepare( + `UPDATE magic_note_entries + SET content_json = ?, plain_text = ?, comments_json = '[]', + analyzed_at = NULL, revision = revision + 1, updated_at = ? + WHERE id = ? AND revision = ?` + ) + .run( + JSON.stringify(content), + magicNotePlainText(content), + now, + existing.entry_id, + existing.entry_revision + ) + if (result.changes !== 1) { + throw new Error('记录已被更新,请刷新后重试') + } + database + .prepare( + `UPDATE magic_notes + SET revision = revision + 1, updated_at = ? + WHERE id = ?` + ) + .run(now, existing.note_id) + this.syncMagicNoteTodos( + database, + existing.note_id, + existing.entry_id, + content, + now + ) + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } + return this.getMagicTodo(input.todoId) + } + saveMagicTodoAnalysis(input: { todoId: string expectedRevision: number diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b12bce5..d22bcd4 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -86,7 +86,8 @@ import { magicNoteEntryDeleteSchema, magicNoteEntryUpdateSchema, magicNoteUpdateSchema, - magicTodoIdSchema + magicTodoIdSchema, + magicTodoUpdateSchema } from '../shared/magic-notes-contracts' import { assistantIdSchema, @@ -3495,6 +3496,16 @@ export function registerIpcHandlers( } ) + ipcMain.handle( + ipcChannels.magicTodosUpdate, + (event, input: unknown) => { + assertTrustedSender(event, window) + return assistantDatabase.updateMagicTodo( + magicTodoUpdateSchema.parse(input) + ) + } + ) + ipcMain.handle( ipcChannels.magicTodosAnalyze, async (event, input: unknown) => { diff --git a/src/preload/index.ts b/src/preload/index.ts index 2a1c393..8fa9776 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -785,6 +785,11 @@ const desktopApi: DesktopApi = { ipcRenderer.invoke( ipcChannels.magicTodosList ) as Promise, + updateTodo: (input) => + ipcRenderer.invoke( + ipcChannels.magicTodosUpdate, + input + ) as Promise, analyzeTodo: (todoId, options) => ipcRenderer.invoke(ipcChannels.magicTodosAnalyze, { todoId, diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 82da644..85c2c1a 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -479,6 +479,9 @@ const api: DesktopApi = { throw new Error('not used') }), listTodos: vi.fn(async () => ({ todos: [] })), + updateTodo: vi.fn(async () => { + throw new Error('not used') + }), analyzeTodo: vi.fn(async () => { throw new Error('not used') }), diff --git a/src/renderer/src/MagicNotesWorkspace.test.tsx b/src/renderer/src/MagicNotesWorkspace.test.tsx index 10fbe64..c5f4375 100644 --- a/src/renderer/src/MagicNotesWorkspace.test.tsx +++ b/src/renderer/src/MagicNotesWorkspace.test.tsx @@ -152,6 +152,7 @@ const listTodos = vi.fn<() => Promise>() const remove = vi.fn() const createEntry = vi.fn() const analyze = vi.fn() +const updateTodo = vi.fn() const analyzeTodo = vi.fn() const analyzeDraft = vi.fn() let analysisEventListener: @@ -206,6 +207,11 @@ beforeEach(() => { ] } createEntry.mockResolvedValue(createdDetail) + updateTodo.mockImplementation(async (input) => ({ + ...noteTodo, + completed: input.completed, + revision: noteTodo.revision + 1 + })) analyze.mockResolvedValue({ ...createdDetail, entries: createdDetail.entries.map((entry) => @@ -258,6 +264,7 @@ beforeEach(() => { remove, createEntry, analyze, + updateTodo, analyzeTodo, analyzeDraft, onAnalysisEvent @@ -319,8 +326,12 @@ describe('MagicNotesWorkspace', () => { ).toHaveAttribute('aria-pressed', 'true') fireEvent.click(screen.getByRole('tab', { name: '待办' })) expect(screen.getByText('准备演示')).toBeInTheDocument() + const selectedTodoButton = screen + .getAllByText('核对发布材料') + .find((element) => element.tagName === 'STRONG') + ?.closest('button') expect( - screen.getByRole('button', { name: /核对发布材料/ }) + selectedTodoButton ).toHaveAttribute('aria-pressed', 'true') expect(onNotify).not.toHaveBeenCalledWith( expect.objectContaining({ @@ -355,7 +366,11 @@ describe('MagicNotesWorkspace', () => { Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy() - expect(screen.getByLabelText('未完成')).toBeInTheDocument() + expect( + screen.getAllByRole('button', { + name: `标记为已完成:${noteTodo.title}` + }) + ).toHaveLength(2) expect( screen.getByRole('button', { name: '打开原笔记修改' }) ).toBeInTheDocument() @@ -571,7 +586,7 @@ describe('MagicNotesWorkspace', () => { ) }) - it('groups note-backed todos in a directory view', async () => { + it('only shows note-backed todos in a directory view', async () => { render( ) @@ -581,12 +596,45 @@ describe('MagicNotesWorkspace', () => { expect( screen.queryByRole('button', { name: '新建待办' }) ).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: '目录视图' })) + expect( + screen.queryByRole('group', { name: '待办列表方式' }) + ).not.toBeInTheDocument() expect(screen.getByText('发布笔记')).toBeInTheDocument() expect(screen.getByText('演示笔记')).toBeInTheDocument() expect(screen.getByText('准备演示')).toBeInTheDocument() }) + it('marks a todo completed from the standalone todo tab', async () => { + render() + + await screen.findByText('记录正文') + fireEvent.click(screen.getByRole('tab', { name: '待办' })) + fireEvent.click( + screen.getAllByRole('button', { + name: `标记为已完成:${noteTodo.title}` + })[0]! + ) + + await waitFor(() => + expect(updateTodo).toHaveBeenCalledWith({ + todoId: noteTodo.id, + completed: true, + expectedRevision: noteTodo.revision + }) + ) + expect( + screen.getByRole('button', { + name: `标记为未完成:${noteTodo.title}` + }) + ).toHaveAttribute('aria-pressed', 'true') + expect(onNotify).toHaveBeenCalledWith( + expect.objectContaining({ + tone: 'success', + message: '待办已完成' + }) + ) + }) + it('reuses the AI comments pane for selected todos', async () => { getApplicationSettings.mockResolvedValue({ checkUpdatesOnStartup: false, diff --git a/src/renderer/src/MagicNotesWorkspace.tsx b/src/renderer/src/MagicNotesWorkspace.tsx index c59c5d0..9f40119 100644 --- a/src/renderer/src/MagicNotesWorkspace.tsx +++ b/src/renderer/src/MagicNotesWorkspace.tsx @@ -54,7 +54,6 @@ export type MagicNotesWorkspaceProps = { type LibraryView = 'notes' | 'todos' type TodoFilter = 'active' | 'completed' | 'all' -type TodoListMode = 'list' | 'directory' type LoadStatus = 'loading' | 'ready' | 'error' type ValidationTarget = | 'create-note' @@ -73,11 +72,6 @@ const todoFilters = [ { value: 'all', label: '全部' } ] as const -const todoListModes = [ - { value: 'list', label: '待办视图' }, - { value: 'directory', label: '目录视图' } -] as const - const commentDirections: ReadonlyArray<{ value: MagicNoteCommentDirection label: string @@ -211,35 +205,50 @@ function AiComment({ } function TodoListItem({ + disabled, onSelect, + onToggle, selected, todo }: { + disabled: boolean onSelect: () => void + onToggle: () => void selected: boolean todo: MagicTodoItem }): React.JSX.Element { return ( - + + + ) } @@ -250,8 +259,6 @@ export function MagicNotesWorkspace({ const [todos, setTodos] = useState([]) const [libraryView, setLibraryView] = useState('notes') const [todoFilter, setTodoFilter] = useState('active') - const [todoListMode, setTodoListMode] = - useState('list') const [commentMode, setCommentMode] = useState('immediate') const [commentDirection, setCommentDirection] = @@ -873,6 +880,30 @@ export function MagicNotesWorkspace({ } } + const updateTodoCompletion = async ( + todo: MagicTodoItem + ): Promise => { + const operation = `update-todo-${todo.id}` + if (!beginBusy(operation)) { + return + } + try { + const completed = !todo.completed + applyTodo( + await window.goodbuddy.magicNotes.updateTodo({ + todoId: todo.id, + completed, + expectedRevision: todo.revision + }) + ) + notifySuccess(completed ? '待办已完成' : '待办已恢复为未完成') + } catch (updateError) { + notifyError(updateError) + } finally { + endBusy(operation) + } + } + const updateTitle = async (): Promise => { if (!detail || titleDraft.trim() === detail.title) { return @@ -1308,12 +1339,6 @@ export function MagicNotesWorkspace({ value={search} /> - )} - ) : todoListMode === 'list' ? ( - visibleTodos.map((todo) => ( - { - setValidation(undefined) - setSelectedTodoId(todo.id) - }} - selected={selectedTodoId === todo.id} - todo={todo} - /> - )) ) : ( todoDirectories.map((directory) => (
{directory.todos.map((todo) => ( { setValidation(undefined) setSelectedTodoId(todo.id) }} + onToggle={() => + void updateTodoCompletion(todo) + } selected={selectedTodoId === todo.id} todo={todo} /> @@ -1780,18 +1797,28 @@ export function MagicNotesWorkspace({ ) : (
- + void updateTodoCompletion(selectedTodo) + } + type="button" > {selectedTodo.completed ? ( ) : ( )} - +

{selectedTodo.title}

来自笔记:{selectedTodo.noteTitle} diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 504fa5a..252c825 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -316,7 +316,6 @@ border-radius: var(--radius-control); background: transparent; color: var(--text-secondary); - cursor: pointer; gap: var(--space-2); grid-template-columns: auto minmax(0, 1fr); text-align: left; @@ -333,18 +332,34 @@ .magic-todo-list-item__check { display: grid; + width: 28px; + height: 28px; padding-top: 1px; + border: 0; + border-radius: var(--radius-control); + background: transparent; color: var(--accent); + cursor: pointer; place-items: center; } -.magic-todo-list-item > span:last-child { - display: grid; - min-width: 0; - gap: var(--space-1); +.magic-todo-list-item__check:hover { + background: var(--accent-subtle); } -.magic-todo-list-item strong { +.magic-todo-list-item__content { + display: grid; + width: 100%; + min-width: 0; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + gap: var(--space-1); + text-align: left; +} + +.magic-todo-list-item__content strong { overflow: hidden; color: var(--text-primary); font-size: var(--font-body); @@ -352,7 +367,7 @@ white-space: nowrap; } -.magic-todo-list-item small { +.magic-todo-list-item__content small { overflow: hidden; color: var(--text-muted); font-size: var(--font-caption); @@ -476,12 +491,19 @@ display: grid; width: 36px; height: 36px; + padding: 0; + border: 0; border-radius: var(--radius-control); background: var(--accent-subtle); color: var(--accent); + cursor: pointer; place-items: center; } +.magic-todo-detail__check:hover { + background: var(--accent-selected); +} + .magic-todo-detail > header > div:nth-child(2) { display: grid; min-width: 0; diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index a8985c0..cd9508d 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -48,6 +48,7 @@ import type { MagicNotesSnapshot, MagicNoteUpdateInput, MagicTodoItem, + MagicTodoUpdateInput, MagicTodosSnapshot } from './magic-notes-contracts' import type { @@ -1223,6 +1224,9 @@ export type DesktopApi = { options: MagicNoteAnalysisOptions ) => Promise listTodos: () => Promise + updateTodo: ( + input: MagicTodoUpdateInput + ) => Promise analyzeTodo: ( todoId: string, options: MagicNoteAnalysisOptions diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 172fea9..b53be1e 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -130,6 +130,7 @@ export const ipcChannels = { magicNotesAnalyzeDraft: 'magic-notes:analyze-draft', magicNotesAnalysisEvent: 'magic-notes:analysis-event', magicTodosList: 'magic-todos:list', + magicTodosUpdate: 'magic-todos:update', magicTodosAnalyze: 'magic-todos:analyze', knowledgeSnapshot: 'knowledge:snapshot', knowledgeCreateLibrary: 'knowledge:library:create', diff --git a/src/shared/magic-notes-contracts.ts b/src/shared/magic-notes-contracts.ts index 21a1d51..835a6dc 100644 --- a/src/shared/magic-notes-contracts.ts +++ b/src/shared/magic-notes-contracts.ts @@ -205,6 +205,17 @@ export const magicTodoIdSchema = z }) .strict() +export const magicTodoUpdateSchema = z + .object({ + todoId: magicNoteIdSchema, + completed: z.boolean(), + expectedRevision: z.number().int().nonnegative() + }) + .strict() +export type MagicTodoUpdateInput = z.infer< + typeof magicTodoUpdateSchema +> + export type MagicNoteCommentKind = | 'narrative' | 'summary'