Files
inkling/docs/superpowers/plans/2026-05-10-sync-help.md
altair823 976d53ccfc docs(plan): sync 도움말 4-task TDD 구현 계획
Task 1: SyncHelpModal 신규 (4 anchor 섹션)
Task 2: ConflictModal inline 설명 + onOpenHelp optional prop
Task 3: SyncSection 도움말 버튼 + modal mount + ConflictModal wiring
Task 4: README "원격 백업" → "동기화 (Git, Cut E)" 통째 재작성

각 task TDD (test → impl → typecheck → commit), 단위 +11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:57:43 +09:00

29 KiB
Raw Blame History

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.tsxonOpenHelp prop 추가, 각 옵션 inline 설명 + "자세히 보기" 링크
  • tests/unit/ConflictModal.test.tsx — inline 설명 + 링크 회귀 추가
  • src/renderer/inbox/components/settings/SyncSection.tsx — "도움말" 버튼 + showHelp state + SyncHelpModal mount + ConflictModalonOpenHelp 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 신규 작성:

// @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(<SyncHelpModal onClose={() => {}} />);
    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(<SyncHelpModal onClose={() => {}} />);
    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(<SyncHelpModal onClose={() => {}} initialAnchor="main-conflict" />);
    expect(scrollSpy).toHaveBeenCalled();
  });

  it('X 버튼 클릭 → onClose 호출', () => {
    const onClose = vi.fn();
    render(<SyncHelpModal onClose={onClose} />);
    fireEvent.click(screen.getByRole('button', { name: /닫기/ }));
    expect(onClose).toHaveBeenCalled();
  });

  it('overlay 클릭 → onClose 호출', () => {
    const onClose = vi.fn();
    const { container } = render(<SyncHelpModal onClose={onClose} />);
    const overlay = container.firstChild as HTMLElement;
    fireEvent.click(overlay);
    expect(onClose).toHaveBeenCalled();
  });

  it('modal body 클릭 → onClose 호출 X (stopPropagation)', () => {
    const onClose = vi.fn();
    render(<SyncHelpModal onClose={onClose} />);
    fireEvent.click(screen.getByRole('heading', { name: /충돌 해결/ }));
    expect(onClose).not.toHaveBeenCalled();
  });

  it('주요 시나리오 키워드 본문 포함 (회귀)', () => {
    render(<SyncHelpModal onClose={() => {}} />);
    // 메인 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
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 신규:

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<HTMLDivElement>(null);

  useEffect(() => {
    if (!initialAnchor) return;
    const el = bodyRef.current?.querySelector(`#${initialAnchor}`);
    if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
  }, [initialAnchor]);

  return (
    <div style={overlayStyle} onClick={onClose}>
      <div ref={bodyRef} style={modalStyle} onClick={(e) => e.stopPropagation()}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
          <h3 style={{ margin: 0, fontSize: 16 }}>동기화 도움말</h3>
          <button onClick={onClose} aria-label="닫기" style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#888' }}>×</button>
        </div>

        <section id="main-conflict" style={sectionStyle}>
          <h4 style={h4Style}>1. 충돌 해결 (메인 시나리오)</h4>
          <p style={pStyle}>같은 노트를  기기에서 동시에 수정하면 충돌이 발생한다. "충돌 해결…" 버튼이 활성화되면 ConflictModal  열려 path  결정 (  사용 / 원격 사용)  받는다.</p>

          <p style={{ ...pStyle, marginTop: 10, fontWeight: 600 }}>편집/편집  가장 흔한 경우</p>
          <ul style={{ paddingLeft: 18, margin: '4px 0' }}>
            <li style={liStyle}> 기기에서 같은 노트 본문 수정   텍스트가 ConflictModal  좌우로 나란히 표시</li>
            <li style={liStyle}>결정 트리: 어느  변경이  새롭고 완전한지 비교   나은  선택</li>
            <li style={liStyle}>  보존하려면? 현재 'both' 미지원  한쪽 선택  사후 수동 병합 (다른  텍스트 메모  모달 닫고 노트 편집)</li>
          </ul>

          <p style={{ ...pStyle, marginTop: 10, fontWeight: 600 }}>삭제/편집</p>
          <ul style={{ paddingLeft: 18, margin: '4px 0' }}>
            <li style={liStyle}>한쪽에서 trash 처리, 다른 쪽에서 같은 노트 본문 수정</li>
            <li style={liStyle}>"삭제가 의도였다"  원격 사용 (trash  적용)</li>
            <li style={liStyle}>"수정이 더 중요"    사용 (편집  적용 = trash 취소)</li>
          </ul>

          <p style={{ ...pStyle, marginTop: 10, fontWeight: 600 }}>AI 결과 충돌</p>
          <ul style={{ paddingLeft: 18, margin: '4px 0' }}>
            <li style={liStyle}> 기기에서 AI 자동 처리 결과 (태그 / 주제 / 요약)  다름</li>
            <li style={liStyle}>대부분 어느 쪽이든 무관  한쪽 선택  AI 재실행 권장 (가장 최신 모델 결과로 통일)</li>
          </ul>
        </section>

        <section id="auto" style={sectionStyle}>
          <h4 style={h4Style}>2. 자동 처리 (내가  해도 되는 )</h4>
          <ul style={{ paddingLeft: 18, margin: '4px 0' }}>
            <li style={liStyle}><b>fetch + rebase</b>: sync 시작  원격 변경을 가져와  변경 위에 다시 쌓음 (linear history). conflict 없으면 자동 진행</li>
            <li style={liStyle}><b> sync 순서</b>:  원격에는 어느 기기든 먼저 push 가능.  번째 기기는 fetch  자동 rebase</li>
            <li style={liStyle}><b>push 거부 (non-fast-forward)</b>: 다른 기기가 먼저 push 했어도 자동 fetch + rebase + 재시도. 사용자 개입은 rebase conflict 발생 시에만</li>
            <li style={liStyle}><b>자동 sync 주기</b>: 기본 30 (설정에서 변경).  종료  자동 1 추가</li>
          </ul>
        </section>

        <section id="silent" style={sectionStyle}>
          <h4 style={h4Style}>3. 조용히 잘못될  있는 케이스 (silent risk)</h4>
          <ul style={{ paddingLeft: 18, margin: '4px 0' }}>
            <li style={liStyle}><b>시계 어긋남 (NTP)</b>:  기기 시계가 다르면 timestamp 기반 merge  잘못된 결과를   있음. macOS / Windows 모두 기본 NTP 동기화 켜져 있음  수동으로 끄지  </li>
            <li style={liStyle}><b> 기기 동시 수정 회피</b>: 같은 노트를 동시에 수정하면 conflict   자주 발생.  기기에서 작업 중이면 다른 기기에서 같은 노트 수정 자제</li>
            <li style={liStyle}><b>자동 sync 실패 silent</b>: 주기적 sync 실패  토스트  . 마지막 sync 시각 / 결과는 설정 페이지에서 확인   1 점검 권장</li>
          </ul>
        </section>

        <section id="setup" style={sectionStyle}>
          <h4 style={h4Style}>4. Setup / 인증 (troubleshoot)</h4>
          <p style={{ ...pStyle, fontWeight: 600 }}>URL 형식 (  하나)</p>
          <ul style={{ paddingLeft: 18, margin: '4px 0' }}>
            <li style={liStyle}>SSH: <code style={codeStyle}>git@host:user/repo.git</code></li>
            <li style={liStyle}>HTTPS: <code style={codeStyle}>https://host/user/repo.git</code></li>
            <li style={liStyle}>잘못된 형식: <code style={codeStyle}>git@https://...</code> 같은 혼합 형식 ✗</li>
          </ul>

          <p style={{ ...pStyle, fontWeight: 600, marginTop: 10 }}>인증</p>
          <ul style={{ paddingLeft: 18, margin: '4px 0' }}>
            <li style={liStyle}>SSH: 기기에 SSH key 등록 + 원격에 public key 추가</li>
            <li style={liStyle}>HTTPS: OS credential helper (Windows Credential Manager / macOS Keychain)   push  token 입력받아 저장.  push 마다 재입력 X</li>
          </ul>

          <p style={{ ...pStyle, fontWeight: 600, marginTop: 10 }}>"연결 테스트" 실패 </p>
          <ul style={{ paddingLeft: 18, margin: '4px 0' }}>
            <li style={liStyle}>네트워크: 원격 host  브라우저로 접속해 응답 확인</li>
            <li style={liStyle}>인증:  인증 절차 점검</li>
            <li style={liStyle}>URL: 형식 (SSH/HTTPS) + 오타 점검</li>
          </ul>

          <p style={{ ...pStyle, fontWeight: 600, marginTop: 10 }}>재설정</p>
          <ul style={{ paddingLeft: 18, margin: '4px 0' }}>
            <li style={liStyle}>URL 변경  설정  동기화 저장소에서  URL 입력  저장. 내부적으로 <code style={codeStyle}>git remote set-url origin</code> 자동 처리</li>
          </ul>
        </section>
      </div>
    </div>
  );
}
  • Step 4: Run test to verify it passes
npx vitest run tests/unit/SyncHelpModal.test.tsx

Expected: 7/7 PASS

  • Step 5: typecheck
npx tsc --noEmit

Expected: 0 errors

  • Step 6: Commit
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 추가.

기존 코드:

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 케이스 추가:

  it('각 conflict row 에 local/remote inline 설명 표시', async () => {
    render(<ConflictModal onClose={() => {}} 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(<ConflictModal onClose={() => {}} 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(<ConflictModal onClose={() => {}} onResolved={() => {}} />);
    await waitFor(() => screen.getByText(/local A/));
    expect(screen.queryByRole('button', { name: /자세히 보기/ })).toBeNull();
  });
});
  • Step 2: Run test to verify it fails
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 갱신 (onOpenHelpoptional — 없으면 "자세히 보기" 링크 미렌더. caller 가 wiring 하지 않은 환경에서 type-clean):

interface Props {
  onClose: () => void;
  onResolved: () => void;
  onOpenHelp?: (anchor: 'main-conflict' | 'auto' | 'silent' | 'setup') => void;
}

함수 signature:

export function ConflictModal({ onClose, onResolved, onOpenHelp }: Props): React.ReactElement {

각 conflict row 의 button row 위에 inline 설명 + (조건부) "자세히 보기" 링크 삽입. 기존 button row (<div style={{ marginTop: 8, ... }}>) 직전에 추가:

            <div style={{ marginTop: 8, fontSize: 11, color: '#666', lineHeight: 1.5 }}>
              <div><b>  사용</b>:  기기의 변경을 보존하고 원격의 같은 노트 변경을 폐기.</div>
              <div><b>원격 사용</b>: 원격의 변경을 가져오고  변경을 폐기.</div>
              {onOpenHelp && (
                <button
                  onClick={() => onOpenHelp('main-conflict')}
                  style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 11, padding: 0, marginTop: 2, textDecoration: 'underline' }}
                >
                  자세히 보기 
                </button>
              )}
            </div>

전체 row 변경 후 모습:

        {conflicts.map((c) => (
          <div key={c.path} style={rowStyle}>
            <div style={{ fontSize: 12, color: '#888', marginBottom: 6 }}>{c.path}</div>
            <div style={{ display: 'flex', gap: 12 }}>
              <div style={{ flex: 1 }}>
                <div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}> 기기</div>
                <pre style={preStyle()}>{c.localText || '(미리보기 없음)'}</pre>
              </div>
              <div style={{ flex: 1 }}>
                <div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>다른 기기</div>
                <pre style={preStyle()}>{c.remoteText || '(미리보기 없음)'}</pre>
              </div>
            </div>
            <div style={{ marginTop: 8, fontSize: 11, color: '#666', lineHeight: 1.5 }}>
              <div><b>  사용</b>:  기기의 변경을 보존하고 원격의 같은 노트 변경을 폐기.</div>
              <div><b>원격 사용</b>: 원격의 변경을 가져오고  변경을 폐기.</div>
              <button
                onClick={() => onOpenHelp('main-conflict')}
                style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 11, padding: 0, marginTop: 2, textDecoration: 'underline' }}
              >
                자세히 보기 
              </button>
            </div>
            <div style={{ marginTop: 8, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
              <button
                onClick={() => { void onChoose(c.path, 'local'); }}
                disabled={busy === c.path}
                style={chooseBtnStyle('#0a4b80')}
              >
                {busy === c.path ? '처리 중…' : '내 것 사용'}
              </button>
              <button
                onClick={() => { void onChoose(c.path, 'remote'); }}
                disabled={busy === c.path}
                style={chooseBtnStyle('#236b1a')}
              >
                {busy === c.path ? '처리 중…' : '원격 사용'}
              </button>
            </div>
          </div>
        ))}
  • Step 4: Run test to verify it passes
npx vitest run tests/unit/ConflictModal.test.tsx

Expected: 6/6 PASS (기존 3 + 신규 3)

  • Step 5: typecheck
npx tsc --noEmit

Expected: 0 errors (onOpenHelp 가 optional 이라 기존 SyncSection.tsx caller 그대로 type-clean. Task 3 에서 wiring).

  • Step 6: Commit
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 에 추가 (기존 케이스 유지):

  it('도움말 버튼 클릭 → SyncHelpModal open', async () => {
    render(<SyncSection />);
    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
npx vitest run tests/unit/SyncSection.test.tsx

Expected: FAIL — "도움말" 버튼 없음

  • Step 3: Update SyncSection

src/renderer/inbox/components/settings/SyncSection.tsx:

상단 import 에 추가:

import { SyncHelpModal, type SyncHelpAnchor } from '../SyncHelpModal.js';

state 추가 (showConflict 옆):

  const [showHelp, setShowHelp] = useState<{ open: boolean; anchor?: SyncHelpAnchor }>({ open: false });

URL row 의 버튼 영역에 "도움말" 버튼 추가 (연결 테스트 버튼 옆):

        <button onClick={() => setShowHelp({ open: true })} disabled={busy !== null} style={btnStyle()}>
          도움말
        </button>

ConflictModal 호출에 onOpenHelp 추가:

          {showConflict && (
            <ConflictModal
              onClose={() => setShowConflict(false)}
              onResolved={async () => {
                setStatus(await inboxApi.getSyncStatus());
              }}
              onOpenHelp={(anchor) => setShowHelp({ open: true, anchor })}
            />
          )}

return 의 마지막 (section close 직전) 에 SyncHelpModal mount 추가:

      {showHelp.open && (
        <SyncHelpModal
          onClose={() => setShowHelp({ open: false })}
          initialAnchor={showHelp.anchor}
        />
      )}
  • Step 4: Run test to verify it passes
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.tsxtests/unit/SettingsPage.test.tsx 는 SyncSection 을 mount 하지만 showConflict=false default 라 ConflictModal 직접 렌더 X. SyncHelpModal 도 default closed. mock 갱신 불필요.

npx vitest run tests/unit/App.test.tsx tests/unit/SettingsPage.test.tsx

Expected: PASS (mock 갱신 없이도 통과)

  • Step 6: 전체 typecheck + 단위
npx tsc --noEmit && npx vitest run

Expected: 0 errors, 모두 PASS (총 +11 케이스: SyncHelpModal 7 + ConflictModal 회귀 3 + SyncSection 회귀 1, App/SettingsPage 무영향)

  • Step 7: Commit
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: 기존 섹션 확인

sed -n '193,223p' README.md

기대 출력: 옛 v0.2.1 MVP 안내 (cd %APPDATA%\Inkling\...\sync + 수동 git init + 트레이 "지금 동기화"). 본 섹션을 통째 교체.

  • Step 2: 섹션 교체 (Edit tool 사용)

기존 텍스트 (line 193-223 전부):

## 원격 백업 (선택, 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 가 <profileDir>/sync/ 안에 F5 export 트리(notes/, media/, index.jsonl, manifest.json)를 덮어쓰고 git add -A && git commit && git push -u origin <branch> 를 자동 수행.

사용

  • 트레이 → "지금 동기화" 로 수동 트리거
  • 앱 종료 시 자동 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
git add README.md
git commit -m "docs: README 동기화 섹션 Cut E 반영 — 양방향 sync + ConflictModal + Silent risk + Troubleshoot"

Final Verification

  • Step 1: 전체 단위 + typecheck
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)
npm run dev

확인:

  • 설정 → 동기화 저장소 → "도움말" 버튼 클릭 → SyncHelpModal 4 섹션 표시 + ESC/X/overlay close

  • 충돌이 있는 상태에서 "충돌 해결…" → ConflictModal → "자세히 보기" 클릭 → SyncHelpModal 이 메인 conflict 섹션 (#main-conflict) 으로 scroll 된 채 open

  • README 변경 사항을 GitHub/Gitea 웹에서 렌더링 정상 (헤더 / 코드 펜스 / 리스트)

  • Step 3: 모든 commit history 확인

git log --oneline -6

Expected:

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
...