diff --git a/docs/superpowers/plans/2026-05-10-sync-help.md b/docs/superpowers/plans/2026-05-10-sync-help.md new file mode 100644 index 0000000..9877942 --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-sync-help.md @@ -0,0 +1,720 @@ +# Sync 도움말 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** v0.3.0 Cut E 양방향 sync 의 사용자 도움말을 in-app modal + ConflictModal inline + README 3 표면에 도입. 다기기 dogfood 의 conflict 시나리오에 막힌 순간 결정 트리 / 자동 처리 동작 / silent risk / setup 인증 troubleshoot 4 카테고리를 즉시 찾을 수 있게. + +**Architecture:** 신규 `SyncHelpModal` 컴포넌트 (4 anchor 섹션, ConflictModal 패턴 재사용) + ConflictModal 의 local/remote 옵션 inline 설명 + "자세히 보기" 링크 (`onOpenHelp` callback) + SyncSection 의 "도움말" 버튼이 modal mount/unmount 관리. README 의 stale "원격 백업 (F6-L2)" 섹션은 "동기화 (Git)" 로 통째 재작성. + +**Tech Stack:** React 18 / TypeScript / vitest + @testing-library/react / electron-vite. 기존 ConflictModal 패턴 정합 (overlay + stopPropagation + 인라인 style object). + +**Spec:** `docs/superpowers/specs/2026-05-10-sync-help-design.md` + +--- + +## File Structure + +**신규**: + +- `src/renderer/inbox/components/SyncHelpModal.tsx` — 신규 modal, 4 anchor 섹션 (`#main-conflict`, `#auto`, `#silent`, `#setup`) +- `tests/unit/SyncHelpModal.test.tsx` — 렌더링 + close 회귀 + +**수정**: + +- `src/renderer/inbox/components/ConflictModal.tsx` — `onOpenHelp` prop 추가, 각 옵션 inline 설명 + "자세히 보기" 링크 +- `tests/unit/ConflictModal.test.tsx` — inline 설명 + 링크 회귀 추가 +- `src/renderer/inbox/components/settings/SyncSection.tsx` — "도움말" 버튼 + `showHelp` state + `SyncHelpModal` mount + `ConflictModal` 의 `onOpenHelp` wiring +- `tests/unit/SyncSection.test.tsx` — 도움말 버튼 → modal open 회귀 +- `README.md` line 193-223 — "원격 백업 (F6-L2)" 섹션 통째 재작성 + +--- + +## Task 1: SyncHelpModal 컴포넌트 (4 anchor 섹션 + close 동작) + +**Files:** + +- Create: `src/renderer/inbox/components/SyncHelpModal.tsx` +- Test: `tests/unit/SyncHelpModal.test.tsx` + +- [ ] **Step 1: Write the failing test** + +`tests/unit/SyncHelpModal.test.tsx` 신규 작성: + +```tsx +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import React from 'react'; +import { SyncHelpModal } from '../../src/renderer/inbox/components/SyncHelpModal'; + +describe('SyncHelpModal', () => { + beforeEach(() => { + cleanup(); + }); + + it('4 섹션 헤더 렌더링', () => { + render( {}} />); + expect(screen.getByRole('heading', { name: /충돌 해결/ })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /자동 처리/ })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /조용히 잘못될 수 있는/ })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /Setup/ })).toBeInTheDocument(); + }); + + it('각 섹션이 anchor id 보유', () => { + const { container } = render( {}} />); + expect(container.querySelector('#main-conflict')).not.toBeNull(); + expect(container.querySelector('#auto')).not.toBeNull(); + expect(container.querySelector('#silent')).not.toBeNull(); + expect(container.querySelector('#setup')).not.toBeNull(); + }); + + it('초기 anchor prop 으로 해당 섹션 scrollIntoView 호출', () => { + const scrollSpy = vi.fn(); + Element.prototype.scrollIntoView = scrollSpy; + render( {}} initialAnchor="main-conflict" />); + expect(scrollSpy).toHaveBeenCalled(); + }); + + it('X 버튼 클릭 → onClose 호출', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /닫기/ })); + expect(onClose).toHaveBeenCalled(); + }); + + it('overlay 클릭 → onClose 호출', () => { + const onClose = vi.fn(); + const { container } = render(); + const overlay = container.firstChild as HTMLElement; + fireEvent.click(overlay); + expect(onClose).toHaveBeenCalled(); + }); + + it('modal body 클릭 → onClose 호출 X (stopPropagation)', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole('heading', { name: /충돌 해결/ })); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('주요 시나리오 키워드 본문 포함 (회귀)', () => { + render( {}} />); + // 메인 conflict 3 케이스 + expect(screen.getByText(/편집\/편집/)).toBeInTheDocument(); + expect(screen.getByText(/삭제\/편집/)).toBeInTheDocument(); + expect(screen.getByText(/AI 결과 충돌/)).toBeInTheDocument(); + // setup 의 잘못된 URL 형식 사례 + expect(screen.getByText(/git@https:\/\//)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd C:\Users\rlaxo\inkling +npx vitest run tests/unit/SyncHelpModal.test.tsx +``` + +Expected: FAIL (module not found — `SyncHelpModal` 미존재) + +- [ ] **Step 3: Implement SyncHelpModal** + +`src/renderer/inbox/components/SyncHelpModal.tsx` 신규: + +```tsx +import React, { useEffect, useRef } from 'react'; + +export type SyncHelpAnchor = 'main-conflict' | 'auto' | 'silent' | 'setup'; + +interface Props { + onClose: () => void; + initialAnchor?: SyncHelpAnchor; +} + +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: 110 +}; + +const modalStyle: React.CSSProperties = { + background: '#fff', borderRadius: 8, padding: 20, width: 640, + maxHeight: '80vh', overflow: 'auto', boxShadow: '0 4px 16px rgba(0,0,0,0.2)' +}; + +const sectionStyle: React.CSSProperties = { + marginTop: 18, paddingTop: 12, borderTop: '1px solid #eee' +}; + +const h4Style: React.CSSProperties = { fontSize: 14, margin: '0 0 8px 0' }; +const pStyle: React.CSSProperties = { fontSize: 12, color: '#444', lineHeight: 1.6, margin: '4px 0' }; +const liStyle: React.CSSProperties = { fontSize: 12, color: '#444', lineHeight: 1.6, marginBottom: 4 }; +const codeStyle: React.CSSProperties = { background: '#f4f4f4', padding: '1px 4px', borderRadius: 3, fontSize: 11 }; + +export function SyncHelpModal({ onClose, initialAnchor }: Props): React.ReactElement { + const bodyRef = useRef(null); + + useEffect(() => { + if (!initialAnchor) return; + const el = bodyRef.current?.querySelector(`#${initialAnchor}`); + if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' }); + }, [initialAnchor]); + + return ( +
+
e.stopPropagation()}> +
+

동기화 도움말

+ +
+ +
+

1. 충돌 해결 (메인 시나리오)

+

같은 노트를 두 기기에서 동시에 수정하면 충돌이 발생한다. "충돌 해결…" 버튼이 활성화되면 ConflictModal 이 열려 path 별 결정 (내 것 사용 / 원격 사용) 을 받는다.

+ +

편집/편집 — 가장 흔한 경우

+
    +
  • 두 기기에서 같은 노트 본문 수정 → 양 텍스트가 ConflictModal 에 좌우로 나란히 표시
  • +
  • 결정 트리: 어느 쪽 변경이 더 새롭고 완전한지 비교 → 더 나은 쪽 선택
  • +
  • 둘 다 보존하려면? 현재 'both' 미지원 — 한쪽 선택 후 사후 수동 병합 (다른 쪽 텍스트 메모 → 모달 닫고 노트 편집)
  • +
+ +

삭제/편집

+
    +
  • 한쪽에서 trash 처리, 다른 쪽에서 같은 노트 본문 수정
  • +
  • "삭제가 의도였다" → 원격 사용 (trash 측 적용)
  • +
  • "수정이 더 중요" → 내 것 사용 (편집 측 적용 = trash 취소)
  • +
+ +

AI 결과 충돌

+
    +
  • 양 기기에서 AI 자동 처리 결과 (태그 / 주제 / 요약) 가 다름
  • +
  • 대부분 어느 쪽이든 무관 → 한쪽 선택 후 AI 재실행 권장 (가장 최신 모델 결과로 통일)
  • +
+
+ +
+

2. 자동 처리 (내가 안 해도 되는 일)

+
    +
  • fetch + rebase: sync 시작 시 원격 변경을 가져와 내 변경 위에 다시 쌓음 (linear history). conflict 없으면 자동 진행
  • +
  • 첫 sync 순서: 빈 원격에는 어느 기기든 먼저 push 가능. 두 번째 기기는 fetch 후 자동 rebase
  • +
  • push 거부 (non-fast-forward): 다른 기기가 먼저 push 했어도 자동 fetch + rebase + 재시도. 사용자 개입은 rebase conflict 발생 시에만
  • +
  • 자동 sync 주기: 기본 30분 (설정에서 변경). 앱 종료 시 자동 1회 추가
  • +
+
+ +
+

3. 조용히 잘못될 수 있는 케이스 (silent risk)

+
    +
  • 시계 어긋남 (NTP): 양 기기 시계가 다르면 timestamp 기반 merge 가 잘못된 결과를 낼 수 있음. macOS / Windows 모두 기본 NTP 동기화 켜져 있음 — 수동으로 끄지 말 것
  • +
  • 두 기기 동시 수정 회피: 같은 노트를 동시에 수정하면 conflict 가 더 자주 발생. 한 기기에서 작업 중이면 다른 기기에서 같은 노트 수정 자제
  • +
  • 자동 sync 실패 silent: 주기적 sync 실패 시 토스트 안 뜸. 마지막 sync 시각 / 결과는 설정 페이지에서 확인 — 주 1회 점검 권장
  • +
+
+ +
+

4. Setup / 인증 (troubleshoot)

+

URL 형식 (둘 중 하나)

+
    +
  • SSH: git@host:user/repo.git
  • +
  • HTTPS: https://host/user/repo.git
  • +
  • 잘못된 형식: git@https://... 같은 혼합 형식 ✗
  • +
+ +

인증

+
    +
  • SSH: 기기에 SSH key 등록 + 원격에 public key 추가
  • +
  • HTTPS: OS credential helper (Windows Credential Manager / macOS Keychain) 가 첫 push 시 token 입력받아 저장. 매 push 마다 재입력 X
  • +
+ +

"연결 테스트" 실패 시

+
    +
  • 네트워크: 원격 host 에 브라우저로 접속해 응답 확인
  • +
  • 인증: 위 인증 절차 점검
  • +
  • URL: 형식 (SSH/HTTPS) + 오타 점검
  • +
+ +

재설정

+
    +
  • URL 변경 시 설정 → 동기화 저장소에서 새 URL 입력 → 저장. 내부적으로 git remote set-url origin 자동 처리
  • +
+
+
+
+ ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +npx vitest run tests/unit/SyncHelpModal.test.tsx +``` + +Expected: 7/7 PASS + +- [ ] **Step 5: typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: 0 errors + +- [ ] **Step 6: Commit** + +```bash +git add src/renderer/inbox/components/SyncHelpModal.tsx tests/unit/SyncHelpModal.test.tsx +git commit -m "feat(sync-help): SyncHelpModal 4 anchor 섹션 (메인 conflict / 자동 / silent / setup)" +``` + +--- + +## Task 2: ConflictModal 갱신 — inline 설명 + "자세히 보기" 링크 + +**Files:** + +- Modify: `src/renderer/inbox/components/ConflictModal.tsx` +- Modify: `tests/unit/ConflictModal.test.tsx` + +- [ ] **Step 1: Update test** + +`tests/unit/ConflictModal.test.tsx` 의 마지막에 두 케이스 추가 (기존 3 케이스 유지). 또한 mock 시그니처에 `onOpenHelp` 추가. + +기존 코드: + +```tsx +import { ConflictModal } from '../../src/renderer/inbox/components/ConflictModal'; + +describe('ConflictModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + cleanup(); + mockListConflicts.mockResolvedValue([ + { path: 'notes/n1.md', localText: 'local A', remoteText: 'remote A' }, + { path: 'notes/n2.md', localText: 'local B', remoteText: 'remote B' } + ]); + mockResolveConflict.mockResolvedValue({ ok: true }); + }); +``` + +기존 3 it 블록은 그대로 (onOpenHelp optional 이라 미수정 호출 type-clean). + +신규 2 케이스 추가: + +```tsx + it('각 conflict row 에 local/remote inline 설명 표시', async () => { + render( {}} onResolved={() => {}} onOpenHelp={() => {}} />); + await waitFor(() => screen.getByText(/local A/)); + // 두 conflict row → inline 설명 2 회씩 + expect(screen.getAllByText(/이 기기의 변경을 보존/).length).toBeGreaterThanOrEqual(2); + expect(screen.getAllByText(/원격의 변경을 가져오고/).length).toBeGreaterThanOrEqual(2); + }); + + it('"자세히 보기" 클릭 → onOpenHelp("main-conflict") 호출', async () => { + const onOpenHelp = vi.fn(); + render( {}} onResolved={() => {}} onOpenHelp={onOpenHelp} />); + await waitFor(() => screen.getByText(/local A/)); + const links = screen.getAllByRole('button', { name: /자세히 보기/ }); + fireEvent.click(links[0]!); + expect(onOpenHelp).toHaveBeenCalledWith('main-conflict'); + }); + + it('onOpenHelp 미제공 → "자세히 보기" 링크 미렌더', async () => { + render( {}} onResolved={() => {}} />); + await waitFor(() => screen.getByText(/local A/)); + expect(screen.queryByRole('button', { name: /자세히 보기/ })).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npx vitest run tests/unit/ConflictModal.test.tsx +``` + +Expected: FAIL — `onOpenHelp` prop 미존재 / inline 설명 미표시 / "자세히 보기" 버튼 없음 + +- [ ] **Step 3: Update ConflictModal** + +`src/renderer/inbox/components/ConflictModal.tsx`: + +Props interface 갱신 (`onOpenHelp` 는 **optional** — 없으면 "자세히 보기" 링크 미렌더. caller 가 wiring 하지 않은 환경에서 type-clean): + +```tsx +interface Props { + onClose: () => void; + onResolved: () => void; + onOpenHelp?: (anchor: 'main-conflict' | 'auto' | 'silent' | 'setup') => void; +} +``` + +함수 signature: + +```tsx +export function ConflictModal({ onClose, onResolved, onOpenHelp }: Props): React.ReactElement { +``` + +각 conflict row 의 button row 위에 inline 설명 + (조건부) "자세히 보기" 링크 삽입. 기존 button row (`
`) 직전에 추가: + +```tsx +
+
내 것 사용: 이 기기의 변경을 보존하고 원격의 같은 노트 변경을 폐기.
+
원격 사용: 원격의 변경을 가져오고 내 변경을 폐기.
+ {onOpenHelp && ( + + )} +
+``` + +전체 row 변경 후 모습: + +```tsx + {conflicts.map((c) => ( +
+
{c.path}
+
+
+
내 기기
+
{c.localText || '(미리보기 없음)'}
+
+
+
다른 기기
+
{c.remoteText || '(미리보기 없음)'}
+
+
+
+
내 것 사용: 이 기기의 변경을 보존하고 원격의 같은 노트 변경을 폐기.
+
원격 사용: 원격의 변경을 가져오고 내 변경을 폐기.
+ +
+
+ + +
+
+ ))} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +npx vitest run tests/unit/ConflictModal.test.tsx +``` + +Expected: 6/6 PASS (기존 3 + 신규 3) + +- [ ] **Step 5: typecheck** + +```bash +npx tsc --noEmit +``` + +Expected: 0 errors (`onOpenHelp` 가 optional 이라 기존 SyncSection.tsx caller 그대로 type-clean. Task 3 에서 wiring). + +- [ ] **Step 6: Commit** + +```bash +git add src/renderer/inbox/components/ConflictModal.tsx tests/unit/ConflictModal.test.tsx +git commit -m "feat(sync-help): ConflictModal inline 설명 + 자세히 보기 링크 (onOpenHelp prop)" +``` + +--- + +## Task 3: SyncSection wiring — 도움말 버튼 + SyncHelpModal mount + ConflictModal onOpenHelp + +**Files:** + +- Modify: `src/renderer/inbox/components/settings/SyncSection.tsx` +- Modify: `tests/unit/SyncSection.test.tsx` + +- [ ] **Step 1: Update test** + +`tests/unit/SyncSection.test.tsx` 에 추가 (기존 케이스 유지): + +```tsx + it('도움말 버튼 클릭 → SyncHelpModal open', async () => { + render(); + await waitFor(() => screen.getByRole('button', { name: /저장/ })); + fireEvent.click(screen.getByRole('button', { name: /^도움말$/ })); + await waitFor(() => screen.getByRole('heading', { name: /동기화 도움말/ })); + expect(screen.getByRole('heading', { name: /동기화 도움말/ })).toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npx vitest run tests/unit/SyncSection.test.tsx +``` + +Expected: FAIL — "도움말" 버튼 없음 + +- [ ] **Step 3: Update SyncSection** + +`src/renderer/inbox/components/settings/SyncSection.tsx`: + +상단 import 에 추가: + +```tsx +import { SyncHelpModal, type SyncHelpAnchor } from '../SyncHelpModal.js'; +``` + +state 추가 (`showConflict` 옆): + +```tsx + const [showHelp, setShowHelp] = useState<{ open: boolean; anchor?: SyncHelpAnchor }>({ open: false }); +``` + +URL row 의 버튼 영역에 "도움말" 버튼 추가 (연결 테스트 버튼 옆): + +```tsx + +``` + +`ConflictModal` 호출에 `onOpenHelp` 추가: + +```tsx + {showConflict && ( + setShowConflict(false)} + onResolved={async () => { + setStatus(await inboxApi.getSyncStatus()); + }} + onOpenHelp={(anchor) => setShowHelp({ open: true, anchor })} + /> + )} +``` + +return 의 마지막 (section close 직전) 에 SyncHelpModal mount 추가: + +```tsx + {showHelp.open && ( + setShowHelp({ open: false })} + initialAnchor={showHelp.anchor} + /> + )} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +npx vitest run tests/unit/SyncSection.test.tsx tests/unit/ConflictModal.test.tsx tests/unit/SyncHelpModal.test.tsx +``` + +Expected: 모두 PASS + +- [ ] **Step 5: App.test.tsx / SettingsPage.test.tsx 에서 ConflictModal 깊이 호출 X 회귀 확인** + +`tests/unit/App.test.tsx` 와 `tests/unit/SettingsPage.test.tsx` 는 SyncSection 을 mount 하지만 `showConflict=false` default 라 ConflictModal 직접 렌더 X. SyncHelpModal 도 default closed. mock 갱신 불필요. + +```bash +npx vitest run tests/unit/App.test.tsx tests/unit/SettingsPage.test.tsx +``` + +Expected: PASS (mock 갱신 없이도 통과) + +- [ ] **Step 6: 전체 typecheck + 단위** + +```bash +npx tsc --noEmit && npx vitest run +``` + +Expected: 0 errors, 모두 PASS (총 +11 케이스: SyncHelpModal 7 + ConflictModal 회귀 3 + SyncSection 회귀 1, App/SettingsPage 무영향) + +- [ ] **Step 7: Commit** + +```bash +git add src/renderer/inbox/components/settings/SyncSection.tsx tests/unit/SyncSection.test.tsx +git commit -m "feat(sync-help): SyncSection 도움말 버튼 + SyncHelpModal mount + ConflictModal onOpenHelp wiring" +``` + +--- + +## Task 4: README "원격 백업 (F6-L2)" 섹션 통째 재작성 → "동기화 (Git, Cut E)" + +**Files:** + +- Modify: `README.md` line 193-223 + +- [ ] **Step 1: 기존 섹션 확인** + +```bash +sed -n '193,223p' README.md +``` + +기대 출력: 옛 v0.2.1 MVP 안내 (`cd %APPDATA%\Inkling\...\sync` + 수동 `git init` + 트레이 "지금 동기화"). 본 섹션을 통째 교체. + +- [ ] **Step 2: 섹션 교체 (Edit tool 사용)** + +기존 텍스트 (line 193-223 전부): + +```markdown +## 원격 백업 (선택, F6-L2) + +Inkling 데이터를 사적 git 원격에 백업하려면 한 번만 설정하면 된다. 인코딩된 형식이 아니라 평문 마크다운(F5 export 형식)으로 저장되니, **반드시 비공개 repo** 를 사용한다. + +### 일회 설정 + +```bash +# 1. 빈 사적 repo 생성 (예: gitea, GitHub private) + +# 2. 데이터 디렉터리에 git 초기화 + 원격 등록 +cd "%APPDATA%\Inkling\Inkling\profiles\default\sync" # Windows +git init +git remote add origin https://your-host/owner/inkling-data.git +git fetch origin || true # 빈 repo 면 무시 + +# 3. 자격증명 설정 (Windows Credential Manager 자동 / 또는 token 임베드 URL) + +# 4. 첫 동기화: 트레이 → "지금 동기화" +``` + +처음 sync 시 SyncService 가 `/sync/` 안에 F5 export 트리(notes/, media/, index.jsonl, manifest.json)를 덮어쓰고 `git add -A && git commit && git push -u origin ` 를 자동 수행. + +### 사용 + +- 트레이 → "지금 동기화" 로 수동 트리거 +- 앱 종료 시 자동 1회 (sync dir 이 설정된 경우만) +- 변경 없으면 토스트 "변경 사항 없음", 변경 있으면 "동기화 완료" + +설정이 안 됐으면 트레이 토스트로 안내. 한 번 설정하면 이후 push 는 OS credential helper 가 자동 처리. + +데이터 라이프사이클 측면에서 F6-L1 (로컬 스냅샷, 자동) + F5/F6-L3 (수동 export/import) + F6-L2 (원격 git, 반자동) 3-layer 구조의 마지막 layer. +``` + +신규 텍스트 (전체): + +```markdown +## 동기화 (Git, F21 Cut E) + +Inkling 데이터를 사적 git 원격으로 양방향 동기화 (Mac ↔ Windows 등). 평문 마크다운(F5 export 형식)으로 저장되니 **반드시 비공개 repo** 를 사용한다. + +상세 도움말은 앱 내 설정 → 동기화 저장소 → "도움말" 버튼 (4 섹션 modal) 참조. 본 섹션은 setup + 주요 시나리오 요약. + +### 일회 설정 + +1. 빈 사적 repo 생성 (Gitea / GitHub private) +2. 앱 → 설정 → 동기화 저장소 → URL 입력 → "저장" +3. "연결 테스트" 클릭해 인증 / 네트워크 확인 +4. 자동 sync 사용 토글 + interval (기본 30분) 확인 + +URL 형식 (둘 중 하나): + +- SSH: `git@host:user/repo.git` +- HTTPS: `https://host/user/repo.git` + +`git@https://...` 같은 혼합 형식은 거부된다. + +### 일상 사용 + +- 자동 sync: 설정한 interval 마다 + 앱 종료 시 1회 +- 수동 sync: 트레이 → "지금 동기화" +- 충돌 발생 시 트레이 토스트 + 설정 페이지의 "충돌 해결…" 버튼 → ConflictModal + +### 충돌 해결 (ConflictModal) + +같은 노트를 두 기기에서 동시에 수정하면 path 별 결정 (내 것 / 원격) 을 받는다. + +- **내 것 사용**: 이 기기의 변경을 보존, 원격 변경을 폐기 +- **원격 사용**: 원격 변경을 가져오고, 이 기기의 변경을 폐기 + +3 케이스: + +1. 편집/편집 — 양 텍스트 비교 후 더 새롭고 완전한 쪽 선택. 둘 다 보존하려면 한쪽 선택 + 사후 수동 병합 ('both' 미지원) +2. 삭제/편집 — 삭제가 의도였으면 원격 사용 (trash 측), 수정이 더 중요하면 내 것 사용 (편집 측 = trash 취소) +3. AI 결과 충돌 — 한쪽 선택 후 AI 재실행 권장 + +### Silent risk + +- **시계 어긋남 (NTP)**: 양 기기 시계가 다르면 timestamp merge 가 잘못된 결과를 낼 수 있음. NTP 동기화 끄지 말 것 +- **두 기기 동시 수정 회피**: 같은 노트를 동시에 수정하면 conflict 가 잦아짐 +- **자동 sync 실패 silent**: 주기적 sync 실패 시 토스트 안 뜸. 마지막 sync 시각 / 결과는 설정 페이지에서 확인 + +### Troubleshoot + +- **"연결 테스트" 실패** — 네트워크 (브라우저로 호스트 접속) / 인증 (SSH key 또는 token) / URL 오타 점검 +- **인증 실패 (push 안 됨)** — SSH 는 public key 등록 점검, HTTPS 는 OS credential helper (Windows Credential Manager / macOS Keychain) 의 저장 token 점검 +- **URL 변경** — 설정 페이지에서 새 URL 입력 → 저장 (`git remote set-url origin` 자동 처리) + +데이터 라이프사이클: F6-L1 (로컬 스냅샷, 자동) + F5/F6-L3 (수동 export/import) + F21 Cut E (양방향 git sync) 3-layer 구조. +``` + +Edit tool 호출: `old_string` = 기존 텍스트 전체, `new_string` = 신규 텍스트 전체. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: README 동기화 섹션 Cut E 반영 — 양방향 sync + ConflictModal + Silent risk + Troubleshoot" +``` + +--- + +## Final Verification + +- [ ] **Step 1: 전체 단위 + typecheck** + +```bash +cd C:\Users\rlaxo\inkling +npx tsc --noEmit && npx vitest run +``` + +Expected: 0 type errors, 모든 테스트 PASS (직전 base 대비 +11: SyncHelpModal 7 + ConflictModal 회귀 3 + SyncSection 회귀 1) + +- [ ] **Step 2: 수동 smoke (electron dev)** + +```bash +npm run dev +``` + +확인: + +- 설정 → 동기화 저장소 → "도움말" 버튼 클릭 → SyncHelpModal 4 섹션 표시 + ESC/X/overlay close +- 충돌이 있는 상태에서 "충돌 해결…" → ConflictModal → "자세히 보기" 클릭 → SyncHelpModal 이 메인 conflict 섹션 (#main-conflict) 으로 scroll 된 채 open +- README 변경 사항을 GitHub/Gitea 웹에서 렌더링 정상 (헤더 / 코드 펜스 / 리스트) + +- [ ] **Step 3: 모든 commit history 확인** + +```bash +git log --oneline -6 +``` + +Expected: + +```text +xxxxxxx docs: README 동기화 섹션 Cut E 반영 — ... +xxxxxxx feat(sync-help): SyncSection 도움말 버튼 + SyncHelpModal mount + ConflictModal onOpenHelp wiring +xxxxxxx feat(sync-help): ConflictModal inline 설명 + 자세히 보기 링크 (onOpenHelp prop) +xxxxxxx feat(sync-help): SyncHelpModal 4 anchor 섹션 (메인 conflict / 자동 / silent / setup) +xxxxxxx docs(spec): sync 도움말 v0.3.4 — SyncHelpModal + ConflictModal inline + README +... +```