From 81fbacb21eded477f84a365743ed6d24dfe663a6 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 9 May 2026 20:51:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(v0210):=20RevisionHistoryModal=20=E2=80=94?= =?UTF-8?q?=20=EC=9D=B4=EB=A0=A5=20=EB=AA=A9=EB=A1=9D=20+=20=ED=9A=8C?= =?UTF-8?q?=EC=88=98=20confirm=20+=20chain=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/renderer/inbox/components/NoteCard.tsx | 12 +++ .../inbox/components/RevisionHistoryModal.tsx | 95 +++++++++++++++++++ tests/unit/RevisionHistoryModal.test.tsx | 64 +++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 src/renderer/inbox/components/RevisionHistoryModal.tsx create mode 100644 tests/unit/RevisionHistoryModal.test.tsx diff --git a/src/renderer/inbox/components/NoteCard.tsx b/src/renderer/inbox/components/NoteCard.tsx index 193aa61..763d812 100644 --- a/src/renderer/inbox/components/NoteCard.tsx +++ b/src/renderer/inbox/components/NoteCard.tsx @@ -6,6 +6,7 @@ import { EditableField } from './EditableField.js'; import { IntentBanner } from './IntentBanner.js'; import { pushTagUndo } from './TagUndoToast.js'; import { MoveStatusModal, statusLabelWithParticle } from './MoveStatusModal.js'; +import { RevisionHistoryModal } from './RevisionHistoryModal.js'; interface Props { note: Note; @@ -524,6 +525,17 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore }} /> )} + {showRevisions && ( + setShowRevisions(false)} + onRestored={(newRawText) => { + const updated = { ...local, rawText: newRawText, updatedAt: new Date().toISOString() }; + setLocal(updated); + onUpdated(updated); + }} + /> + )} ); } diff --git a/src/renderer/inbox/components/RevisionHistoryModal.tsx b/src/renderer/inbox/components/RevisionHistoryModal.tsx new file mode 100644 index 0000000..4257aeb --- /dev/null +++ b/src/renderer/inbox/components/RevisionHistoryModal.tsx @@ -0,0 +1,95 @@ +import React, { useEffect, useState } from 'react'; +import type { NoteRevision } from '@shared/types'; +import { inboxApi } from '../api.js'; + +interface Props { + noteId: string; + onClose: () => void; + /** 회수 성공 후 부모 (NoteCard) 가 local rawText 를 갱신하도록 통지. */ + onRestored: (newRawText: string) => void; +} + +const overlayStyle: React.CSSProperties = { + position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', + background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', + justifyContent: 'center', zIndex: 100 +}; + +const modalStyle: React.CSSProperties = { + background: '#fff', borderRadius: 8, padding: 20, width: 520, + maxHeight: '70vh', overflow: 'auto', boxShadow: '0 4px 16px rgba(0,0,0,0.2)' +}; + +const rowStyle: React.CSSProperties = { + border: '1px solid #eee', borderRadius: 6, padding: 10, marginTop: 8 +}; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleString('ko-KR'); +} + +function editedByLabel(by: 'user' | 'capture'): string { + return by === 'capture' ? '캡처' : '사용자'; +} + +export function RevisionHistoryModal({ noteId, onClose, onRestored }: Props): React.ReactElement { + const [revs, setRevs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const r = await inboxApi.listRevisions(noteId); + if (!cancelled) setRevs(r); + } catch (e) { + if (!cancelled) setError((e as Error).message); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [noteId]); + + async function onRestore(rev: NoteRevision) { + if (!window.confirm('이 버전으로 되돌릴까요? 현재 본문도 이력에 보존됩니다.')) return; + const r = await inboxApi.restoreRevision(noteId, rev.revId); + if (!r.ok) { + setError(r.reason ?? '복원 실패'); + return; + } + onRestored(rev.rawText); + onClose(); + } + + return ( +
+
e.stopPropagation()}> +
+

이력 ({revs.length}건)

+ +
+ {loading &&
불러오는 중…
} + {error !== null &&
{error}
} + {!loading && revs.map((rev) => ( +
+
+ {formatDate(rev.editedAt)} · {editedByLabel(rev.editedBy)} + +
+
+              {rev.rawText}
+            
+
+ ))} +
+
+ ); +} diff --git a/tests/unit/RevisionHistoryModal.test.tsx b/tests/unit/RevisionHistoryModal.test.tsx new file mode 100644 index 0000000..0d1215a --- /dev/null +++ b/tests/unit/RevisionHistoryModal.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import React from 'react'; + +const { mockListRevisions, mockRestoreRevision } = vi.hoisted(() => ({ + mockListRevisions: vi.fn(), + mockRestoreRevision: vi.fn() +})); + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + listRevisions: mockListRevisions, + restoreRevision: mockRestoreRevision + } +})); + +import { RevisionHistoryModal } from '../../src/renderer/inbox/components/RevisionHistoryModal'; + +describe('RevisionHistoryModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + mockListRevisions.mockResolvedValue([ + { revId: 3, noteId: 'a', rawText: 'v3', editedAt: '2026-05-11T00:00:00Z', editedBy: 'user' }, + { revId: 2, noteId: 'a', rawText: 'v2', editedAt: '2026-05-10T00:00:00Z', editedBy: 'user' }, + { revId: 1, noteId: 'a', rawText: 'v1', editedAt: '2026-05-01T00:00:00Z', editedBy: 'capture' } + ]); + mockRestoreRevision.mockResolvedValue({ ok: true }); + }); + + it('open 시 listRevisions 호출 + 목록 표시 (capture/user 라벨)', async () => { + render( {}} onRestored={() => {}} />); + await waitFor(() => { + expect(screen.getByText('v3')).toBeInTheDocument(); + expect(screen.getByText('v2')).toBeInTheDocument(); + expect(screen.getByText('v1')).toBeInTheDocument(); + }); + expect(screen.getByText(/캡처/)).toBeInTheDocument(); + expect(screen.getAllByText(/사용자/).length).toBeGreaterThanOrEqual(1); + }); + + it('회수 클릭 → confirm OK → restoreRevision + onRestored 호출 + onClose', async () => { + const onRestored = vi.fn(); + const onClose = vi.fn(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + render(); + await waitFor(() => screen.getByText('v1')); + + const buttons = screen.getAllByRole('button', { name: /회수/ }); + // last button = oldest (v1) + const lastButton = buttons[buttons.length - 1]; + if (lastButton === undefined) throw new Error('no 회수 button'); + fireEvent.click(lastButton); + + await waitFor(() => { + expect(mockRestoreRevision).toHaveBeenCalledWith('a', 1); + }); + expect(onRestored).toHaveBeenCalledWith('v1'); + expect(onClose).toHaveBeenCalled(); + confirmSpy.mockRestore(); + }); +});