From 87c18a4c2d9dc5c4b9079389c5332a154a6a592b Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 03:56:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(v030):=20SyncSection=20+=20ConflictModal?= =?UTF-8?q?=20=E2=80=94=20Configure=20UI=20+=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/main/ipc/settingsApi.ts | 14 ++ src/preload/index.ts | 2 + .../inbox/components/ConflictModal.tsx | 112 +++++++++++++ .../inbox/components/SettingsPage.tsx | 5 + .../inbox/components/settings/SyncSection.tsx | 150 ++++++++++++++++++ src/shared/types.ts | 5 + tests/unit/App.test.tsx | 8 +- tests/unit/ConflictModal.test.tsx | 59 +++++++ tests/unit/SettingsPage.test.tsx | 11 +- tests/unit/SyncSection.test.tsx | 75 +++++++++ 10 files changed, 438 insertions(+), 3 deletions(-) create mode 100644 src/renderer/inbox/components/ConflictModal.tsx create mode 100644 src/renderer/inbox/components/settings/SyncSection.tsx create mode 100644 tests/unit/ConflictModal.test.tsx create mode 100644 tests/unit/SyncSection.test.tsx diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index b27fcaa..1f30f55 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -107,6 +107,20 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { return { ok: true as const }; }); + ipcMain.handle('settings:set-sync-auto-enabled', async (_e, value: boolean) => { + await deps.settings.setAutoSyncEnabled(value); + return { ok: true as const }; + }); + + ipcMain.handle('settings:set-sync-interval-min', async (_e, value: number) => { + try { + await deps.settings.setSyncIntervalMin(value); + return { ok: true as const }; + } catch (e) { + return { ok: false as const, reason: (e as Error).message }; + } + }); + ipcMain.handle('settings:run-backup', async () => { try { const r = await backup.runDaily(); diff --git a/src/preload/index.ts b/src/preload/index.ts index 3db9c04..2f50a5f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -95,6 +95,8 @@ const api: InklingApi = { resolveConflict: (noteId: string, choice: 'local' | 'remote') => ipcRenderer.invoke('sync:resolve-conflict', noteId, choice), getSyncStatus: () => ipcRenderer.invoke('sync:get-status'), + setSyncAutoEnabled: (value: boolean) => ipcRenderer.invoke('settings:set-sync-auto-enabled', value), + setSyncIntervalMin: (value: number) => ipcRenderer.invoke('settings:set-sync-interval-min', value), } }; diff --git a/src/renderer/inbox/components/ConflictModal.tsx b/src/renderer/inbox/components/ConflictModal.tsx new file mode 100644 index 0000000..76441b9 --- /dev/null +++ b/src/renderer/inbox/components/ConflictModal.tsx @@ -0,0 +1,112 @@ +import React, { useEffect, useState } from 'react'; +import type { SyncConflict } from '@shared/types'; +import { inboxApi } from '../api.js'; + +interface Props { + onClose: () => void; + onResolved: () => 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: 600, + 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 +}; + +export function ConflictModal({ onClose, onResolved }: Props): React.ReactElement { + const [conflicts, setConflicts] = useState([]); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + void (async () => { + const c = await inboxApi.listConflicts(); + if (!cancelled) setConflicts(c); + })(); + return () => { cancelled = true; }; + }, []); + + async function onChoose(noteId: string, choice: 'local' | 'remote') { + setBusy(noteId); + setError(null); + const r = await inboxApi.resolveConflict(noteId, choice); + setBusy(null); + if (!r.ok) { + setError(`해결 실패: ${r.reason}`); + return; + } + const next = conflicts.filter((c) => c.noteId !== noteId); + setConflicts(next); + if (next.length === 0) { + onResolved(); + onClose(); + } + } + + return ( +
+
e.stopPropagation()}> +
+

충돌 ({conflicts.length}건)

+ +
+ {error !== null &&
{error}
} + {conflicts.map((c) => ( +
+
note: {c.noteId}
+
+
+
내 기기
+
{c.localText || '(미리보기 없음)'}
+
+
+
다른 기기
+
{c.remoteText || '(미리보기 없음)'}
+
+
+
+ + +
+
+ ))} +
+
+ ); +} + +function preStyle(): React.CSSProperties { + return { + margin: 0, whiteSpace: 'pre-wrap', fontSize: 11, color: '#444', + background: '#fafafa', padding: 6, borderRadius: 3, maxHeight: 120, overflow: 'auto' + }; +} + +function chooseBtnStyle(color: string): React.CSSProperties { + return { + background: 'none', border: `1px solid ${color}`, color, cursor: 'pointer', + fontSize: 12, padding: '4px 10px', borderRadius: 4 + }; +} diff --git a/src/renderer/inbox/components/SettingsPage.tsx b/src/renderer/inbox/components/SettingsPage.tsx index a4b807a..cf96777 100644 --- a/src/renderer/inbox/components/SettingsPage.tsx +++ b/src/renderer/inbox/components/SettingsPage.tsx @@ -4,6 +4,7 @@ import { AiProviderSection } from './settings/AiProviderSection.js'; import { AutostartSection } from './settings/AutostartSection.js'; import { BackupSection } from './settings/BackupSection.js'; import { InfoSection } from './settings/InfoSection.js'; +import { SyncSection } from './settings/SyncSection.js'; export function SettingsPage(): React.ReactElement { const setShowSettings = useInbox((s) => s.setShowSettings); @@ -40,6 +41,10 @@ export function SettingsPage(): React.ReactElement {

정보

+
+

동기화

+ +
); } diff --git a/src/renderer/inbox/components/settings/SyncSection.tsx b/src/renderer/inbox/components/settings/SyncSection.tsx new file mode 100644 index 0000000..ae0a6b6 --- /dev/null +++ b/src/renderer/inbox/components/settings/SyncSection.tsx @@ -0,0 +1,150 @@ +import React, { useEffect, useState } from 'react'; +import { inboxApi } from '../../api.js'; +import type { SyncStatusSnapshot } from '@shared/types'; +import { ConflictModal } from '../ConflictModal.js'; + +export function SyncSection(): React.ReactElement { + const [url, setUrl] = useState(''); + const [draftUrl, setDraftUrl] = useState(''); + const [autoEnabled, setAutoEnabled] = useState(true); + const [intervalMin, setIntervalMin] = useState(30); + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState<'save' | 'test' | 'sync' | null>(null); + const [feedback, setFeedback] = useState(null); + const [showConflict, setShowConflict] = useState(false); + + useEffect(() => { + void (async () => { + const s = await inboxApi.getSettings(); + const u = s.sync_repo_url ?? ''; + setUrl(u); + setDraftUrl(u); + setAutoEnabled(s.sync_auto_enabled ?? true); + setIntervalMin(s.sync_interval_min ?? 30); + setStatus(await inboxApi.getSyncStatus()); + })(); + }, []); + + async function onSaveUrl() { + setBusy('save'); + setFeedback(null); + const r = await inboxApi.configureSync(draftUrl.trim() === '' ? null : draftUrl.trim()); + setBusy(null); + if (r.ok) { + setUrl(draftUrl.trim()); + setFeedback('저장되었습니다'); + } else { + setFeedback(`저장 실패: ${r.reason}`); + } + } + + async function onTestConnection() { + setBusy('test'); + setFeedback(null); + const r = await inboxApi.testSyncConnection(); + setBusy(null); + setFeedback(r.ok ? '연결 성공' : `연결 실패: ${r.reason}`); + } + + async function onToggleAuto(next: boolean) { + await inboxApi.setSyncAutoEnabled(next); + setAutoEnabled(next); + } + + async function onChangeInterval(value: number) { + if (!Number.isInteger(value) || value < 5) return; + const r = await inboxApi.setSyncIntervalMin(value); + if (r.ok) setIntervalMin(value); + } + + const conflictCount = status?.lastResult?.conflicts?.length ?? 0; + + return ( +
+

동기화 저장소

+ +
+ setDraftUrl(e.target.value)} + style={{ flex: 1, fontSize: 12, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4 }} + /> + + +
+ + {feedback !== null && ( +
{feedback}
+ )} + + {url.trim() !== '' && ( + <> +
+ 마지막 sync: {status?.lastAt ?? '없음'} {status?.lastResult?.ok === false && status?.lastResult?.reason !== 'conflict' && ( + ({status.lastResult.reason}) + )} +
+ + + + + + {conflictCount > 0 && ( +
+ +
+ )} + + {showConflict && ( + setShowConflict(false)} + onResolved={async () => { + setStatus(await inboxApi.getSyncStatus()); + }} + /> + )} + + )} +
+ ); +} + +function btnStyle(): React.CSSProperties { + return { + background: '#0a4b80', + color: '#fff', + border: 'none', + cursor: 'pointer', + fontSize: 12, + padding: '4px 10px', + borderRadius: 4 + }; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 217cc91..ed165a0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -192,6 +192,9 @@ export interface InboxApi { ollama?: { endpoint: string; model: string }; ai_enabled?: boolean; onboarding_completed?: boolean; + sync_repo_url?: string | null; + sync_auto_enabled?: boolean; + sync_interval_min?: number; }>; setAiEnabled(enabled: boolean): Promise<{ ok: true }>; setOnboardingCompleted(completed: boolean): Promise<{ ok: true }>; @@ -211,6 +214,8 @@ export interface InboxApi { listConflicts(): Promise; resolveConflict(noteId: string, choice: 'local' | 'remote'): Promise<{ ok: true } | { ok: false; reason: string }>; getSyncStatus(): Promise; + setSyncAutoEnabled(enabled: boolean): Promise<{ ok: true }>; + setSyncIntervalMin(value: number): Promise<{ ok: true } | { ok: false; reason: string }>; } export interface InklingApi { diff --git a/tests/unit/App.test.tsx b/tests/unit/App.test.tsx index 262d27a..f49a065 100644 --- a/tests/unit/App.test.tsx +++ b/tests/unit/App.test.tsx @@ -56,7 +56,13 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })), // v0.2.9 Cut B Task 16 — AiProviderSection 가 SettingsPage 렌더 시 호출. getDisabledCount: vi.fn(async () => 0), - enqueueDisabled: vi.fn(async () => ({ count: 0 })) + enqueueDisabled: vi.fn(async () => ({ count: 0 })), + // v0.3.0 Cut E — SyncSection 이 SettingsPage 에 마운트되어 호출. + getSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })), + setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })), + setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })), + configureSync: vi.fn(async () => ({ ok: true as const })), + testSyncConnection: vi.fn(async () => ({ ok: true as const })) } })); diff --git a/tests/unit/ConflictModal.test.tsx b/tests/unit/ConflictModal.test.tsx new file mode 100644 index 0000000..3bee267 --- /dev/null +++ b/tests/unit/ConflictModal.test.tsx @@ -0,0 +1,59 @@ +// @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 { mockListConflicts, mockResolveConflict } = vi.hoisted(() => ({ + mockListConflicts: vi.fn(), + mockResolveConflict: vi.fn() +})); + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { listConflicts: mockListConflicts, resolveConflict: mockResolveConflict } +})); + +import { ConflictModal } from '../../src/renderer/inbox/components/ConflictModal'; + +describe('ConflictModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + mockListConflicts.mockResolvedValue([ + { noteId: 'n1', localText: 'local A', remoteText: 'remote A' }, + { noteId: 'n2', localText: 'local B', remoteText: 'remote B' } + ]); + mockResolveConflict.mockResolvedValue({ ok: true }); + }); + + it('open 시 listConflicts 호출 + 양 conflict 표시', async () => { + render( {}} onResolved={() => {}} />); + await waitFor(() => screen.getByText(/local A/)); + expect(screen.getByText(/local A/)).toBeInTheDocument(); + expect(screen.getByText(/remote A/)).toBeInTheDocument(); + expect(screen.getByText(/local B/)).toBeInTheDocument(); + }); + + it('내 것 사용 클릭 → resolveConflict(noteId, "local") 호출', async () => { + render( {}} onResolved={() => {}} />); + await waitFor(() => screen.getByText(/local A/)); + const buttons = screen.getAllByRole('button', { name: /내 것 사용/ }); + fireEvent.click(buttons[0]!); + await waitFor(() => { + expect(mockResolveConflict).toHaveBeenCalledWith('n1', 'local'); + }); + }); + + it('마지막 conflict 해결 → onResolved + onClose 호출', async () => { + mockListConflicts.mockResolvedValueOnce([{ noteId: 'n1', localText: 'a', remoteText: 'b' }]); + const onResolved = vi.fn(); + const onClose = vi.fn(); + render(); + await waitFor(() => screen.getByRole('button', { name: /원격 사용/ })); + fireEvent.click(screen.getByRole('button', { name: /원격 사용/ })); + await waitFor(() => { + expect(onResolved).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/SettingsPage.test.tsx b/tests/unit/SettingsPage.test.tsx index 2366ad1..7405c46 100644 --- a/tests/unit/SettingsPage.test.tsx +++ b/tests/unit/SettingsPage.test.tsx @@ -46,7 +46,13 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({ setAiEnabled: vi.fn(async () => ({ ok: true as const })), setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })), getDisabledCount: vi.fn(async () => 0), - enqueueDisabled: vi.fn(async () => ({ count: 0 })) + enqueueDisabled: vi.fn(async () => ({ count: 0 })), + // v0.3.0 Cut E — SyncSection 이 SettingsPage 에 마운트되어 호출. + getSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })), + setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })), + setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })), + configureSync: vi.fn(async () => ({ ok: true as const })), + testSyncConnection: vi.fn(async () => ({ ok: true as const })) } })); @@ -64,12 +70,13 @@ describe('SettingsPage', () => { expect(screen.getByRole('button', { name: /돌아가기/ })).toBeInTheDocument(); }); - it('renders 4 section headings', () => { + it('renders 5 section headings', () => { render(); expect(screen.getByText('AI 제공자')).toBeInTheDocument(); expect(screen.getByText('자동 실행')).toBeInTheDocument(); expect(screen.getByText('백업 / 복원')).toBeInTheDocument(); expect(screen.getByText('정보')).toBeInTheDocument(); + expect(screen.getByText('동기화')).toBeInTheDocument(); }); it('clicking "← 돌아가기" sets showSettings to false', () => { diff --git a/tests/unit/SyncSection.test.tsx b/tests/unit/SyncSection.test.tsx new file mode 100644 index 0000000..8ecaba1 --- /dev/null +++ b/tests/unit/SyncSection.test.tsx @@ -0,0 +1,75 @@ +// @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 { mockGetSettings, mockConfigureSync, mockTestSyncConnection, mockGetSyncStatus, mockSetAuto, mockSetInterval } = vi.hoisted(() => ({ + mockGetSettings: vi.fn(async () => ({ sync_repo_url: '', sync_auto_enabled: true, sync_interval_min: 30 })), + mockConfigureSync: vi.fn(async () => ({ ok: true as const })), + mockTestSyncConnection: vi.fn(async () => ({ ok: true as const })), + mockGetSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })), + mockSetAuto: vi.fn(async () => ({ ok: true as const })), + mockSetInterval: vi.fn(async () => ({ ok: true as const })) +})); + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + getSettings: mockGetSettings, + configureSync: mockConfigureSync, + testSyncConnection: mockTestSyncConnection, + getSyncStatus: mockGetSyncStatus, + setSyncAutoEnabled: mockSetAuto, + setSyncIntervalMin: mockSetInterval + } +})); + +// ConflictModal is imported by SyncSection — mock it to avoid needing listConflicts +vi.mock('../../src/renderer/inbox/components/ConflictModal.js', () => ({ + ConflictModal: () => null +})); + +import { SyncSection } from '../../src/renderer/inbox/components/settings/SyncSection'; + +describe('SyncSection', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + mockGetSettings.mockResolvedValue({ sync_repo_url: '', sync_auto_enabled: true, sync_interval_min: 30 }); + mockGetSyncStatus.mockResolvedValue({ lastAt: null, lastResult: null, nextAt: null }); + }); + + it('빈 URL — 저장/연결 테스트 버튼 + 자동 sync 옵션 hide', async () => { + render(); + await waitFor(() => screen.getByRole('button', { name: /저장/ })); + expect(screen.queryByText(/자동 sync/)).not.toBeInTheDocument(); + }); + + it('URL 입력 + 저장 → configureSync 호출 + 자동 sync 옵션 표시', async () => { + mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 }); + render(); + await waitFor(() => screen.getByText(/자동 sync/)); + expect(screen.getByText(/자동 sync/)).toBeInTheDocument(); + }); + + it('연결 테스트 클릭 → testSyncConnection 호출 + 결과 표시', async () => { + mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 }); + render(); + await waitFor(() => screen.getByRole('button', { name: /연결 테스트/ })); + fireEvent.click(screen.getByRole('button', { name: /연결 테스트/ })); + await waitFor(() => { + expect(mockTestSyncConnection).toHaveBeenCalled(); + expect(screen.getByText(/연결 성공/)).toBeInTheDocument(); + }); + }); + + it('자동 sync 토글 → setSyncAutoEnabled 호출', async () => { + mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 }); + render(); + await waitFor(() => screen.getByLabelText(/자동 sync/)); + fireEvent.click(screen.getByLabelText(/자동 sync/)); + await waitFor(() => { + expect(mockSetAuto).toHaveBeenCalledWith(false); + }); + }); +});