From 5e55cd3469a8fea7a5b99e563f20bde9e757e5e8 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 10 May 2026 23:22:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(sync-help):=20SyncHelpModal=204=20anchor?= =?UTF-8?q?=20=EC=84=B9=EC=85=98=20(=EB=A9=94=EC=9D=B8=20conflict=20/=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20/=20silent=20/=20setup)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inbox/components/SyncHelpModal.tsx | 121 ++++++++++++++++++ tests/unit/SyncHelpModal.test.tsx | 65 ++++++++++ 2 files changed, 186 insertions(+) create mode 100644 src/renderer/inbox/components/SyncHelpModal.tsx create mode 100644 tests/unit/SyncHelpModal.test.tsx diff --git a/src/renderer/inbox/components/SyncHelpModal.tsx b/src/renderer/inbox/components/SyncHelpModal.tsx new file mode 100644 index 0000000..1d88dc8 --- /dev/null +++ b/src/renderer/inbox/components/SyncHelpModal.tsx @@ -0,0 +1,121 @@ +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 자동 처리
  • +
+
+
+
+ ); +} diff --git a/tests/unit/SyncHelpModal.test.tsx b/tests/unit/SyncHelpModal.test.tsx new file mode 100644 index 0000000..535605b --- /dev/null +++ b/tests/unit/SyncHelpModal.test.tsx @@ -0,0 +1,65 @@ +// @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( {}} />); + expect(screen.getByText(/편집\/편집/)).toBeInTheDocument(); + expect(screen.getByText(/삭제\/편집/)).toBeInTheDocument(); + expect(screen.getByText(/AI 결과 충돌/)).toBeInTheDocument(); + expect(screen.getByText(/git@https:\/\//)).toBeInTheDocument(); + }); +});