13 Commits

Author SHA1 Message Date
th-kim0823
2c6bfebb5b chore(release): v0.3.5 — dogfood UX hotfix 7건
v0.3.4 까지 누적된 dogfood UX 결함 hotfix.
사용자 직접 보고 3건 (inbox 재진입, 회고 탈출, 이동 modal 중복) + 동반 갭 4건
(count stale, 필터 잔류, 초기 로드 불일치, 배너 컨텍스트 누수).
데이터/마이그레이션 변경 없음 (스키마 v8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:47:04 +09:00
altair823
e815289b2a chore: gitignore .claude/worktrees + .claude/settings.local.json
Claude Code 로컬 worktree 와 사용자별 권한 override 파일 무시.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:09:47 +09:00
altair823
b35b644fe8 chore(release): v0.3.4 — sync 도움말 cut
PR #33 머지. SyncHelpModal + ConflictModal inline + README 통합 도움말.
v0.3.0 Cut E dogfood gap fill. 데이터/마이그레이션 변경 없음 (스키마 v8 그대로).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:02:33 +09:00
f2db82b6d6 Merge pull request 'sync 도움말 — SyncHelpModal + ConflictModal inline + README 동기화 섹션 재작성' (#33) from worktree-v034-sync-help into main
Reviewed-on: #33
2026-05-10 14:59:05 +00:00
altair823
9d6f5bfacc refactor(sync-help): ConflictModal — SyncHelpAnchor import 로 literal duplicate 제거
PR #33 회차 1 review 반영. 두 파일 sibling 인데 anchor union 두 곳 정의 →
import type 1줄로 단일 source of truth. 향후 anchor 추가 시 두 파일 동시
수정 누락 risk + TS 가 catch 하지 않던 silent drift risk 제거.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:56:26 +09:00
altair823
d686c661ba chore: package-lock.json version 0.3.1 → 0.3.3 catch-up
v0.3.3 hotfix 시 package.json 만 bump 됐고 lockfile 미동기화.
v0.3.4 진입 시 npm install 로 자동 동기화됨. cleanup commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:52:05 +09:00
altair823
dca1def87c docs: README 동기화 섹션 Cut E 반영 — 양방향 sync + ConflictModal + Silent risk + Troubleshoot 2026-05-10 23:41:56 +09:00
altair823
8cd6382902 fix(sync-help): SyncSection 도움말 버튼 busy 중 disable 해제
리뷰 minor 반영. busy (저장/테스트/sync 진행) 시 정확히 사용자가
도움말이 가장 필요한 시점이라 disable 회수. read-only 컴포넌트라
race risk 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:40:16 +09:00
altair823
a5e1c1de35 feat(sync-help): SyncSection 도움말 버튼 + SyncHelpModal mount + ConflictModal onOpenHelp wiring 2026-05-10 23:36:07 +09:00
altair823
54ef394bb4 feat(sync-help): ConflictModal inline 설명 + 자세히 보기 링크 (onOpenHelp prop) 2026-05-10 23:29:29 +09:00
altair823
5e55cd3469 feat(sync-help): SyncHelpModal 4 anchor 섹션 (메인 conflict / 자동 / silent / setup) 2026-05-10 23:25:59 +09:00
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
altair823
e8c6b94d2e docs(spec): sync 도움말 v0.3.4 — SyncHelpModal + ConflictModal inline + README
3 표면 (in-app modal + ConflictModal inline + README) 통합 도움말.
4 시나리오 카테고리: 메인 conflict / 자동 처리 / silent risk / setup·인증.
ConflictModal local/remote 각 옵션 inline 설명 + "자세히 보기" 링크 →
SyncHelpModal anchor jump.

다기기 dogfood 의 핵심 가치 검증 (sync) 인데 막힌 순간 도움말 부재 →
v0.3.0 Cut E + v0.3.3 hotfix 기반 공식 도움말.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:47:27 +09:00
23 changed files with 1357 additions and 318 deletions

4
.gitignore vendored
View File

@@ -11,3 +11,7 @@ test-results/
# build/ 산출물 — icon.{ico,icns,png} 만 커밋, 중간 산출물은 무시
build/icons/
build/icon-source.png
# Claude Code 로컬 worktree + 사용자별 settings
.claude/worktrees/
.claude/settings.local.json

View File

@@ -3,6 +3,63 @@
본 파일은 Inkling 의 버전별 사용자 영향 변경 사항을 기록한다.
형식은 [Keep a Changelog](https://keepachangelog.com/) 를 느슨하게 따른다.
## [0.3.5] — 2026-05-11
v0.3.4 까지 누적된 dogfood UX 결함 7건 hotfix. 사용자가 막혔던 inbox/회고/이동 3건 + 그 부류의 동반 갭 4건. 데이터/마이그레이션 변경 없음 (스키마 v8 그대로).
### 수정
- **Inbox 탭 진입 실패: 다른 보관함에서 inbox 로 못 돌아옴.** `setView('inbox')` 가 reload 를 호출 안 해서 `notes` state 가 이전 view 의 status 로 stale. `loadByView` 시그니처에 `'inbox' → 'active'` 매핑 추가 + setView 의 reload 분기에 inbox 포함.
- **회고 view 탈출 불가.** `ReviewView` 가 App 의 헤더를 우회 (early return) 해서 사용자가 뒤로 갈 길이 없던 문제. `← 돌아가기` 버튼 추가 — 클릭 시 `setView('inbox')`.
- **이동 dropdown 의 modal 중복 질문.** dropdown 에서 "완료로 이동" 선택해도 `MoveStatusModal` 이 떠서 목적지를 재확인. dropdown 클릭 = `inboxApi.setStatus(id, target, null)` 즉시 호출로 단순화. modal path 자체 제거 (`MoveStatusModal.tsx` + 동반 테스트 삭제, `statusLabel` 헬퍼는 별도 `statusLabel.ts` 로 분리).
- **이동 후 헤더 탭 count stale.** `setStatus` IPC 가 `pushNoteUpdated` emit 을 안 해서 `refreshMeta` 가 트리거되지 않던 잠재 버그. dropdown 이동 path 끝에 `refreshMeta()` 명시 호출 추가.
- **View 전환 시 검색/태그 필터 잔류.** `setView``searchResults`/`searchQuery`/`tagFilter` 를 reset 안 해서 이전 view 의 필터가 완료/보관/휴지통/회고에 잘못 적용. 한 번에 reset.
- **Inbox 첫 로드와 탭 reload 결과 불일치.** `loadInitial``listNotes()` (= `deleted_at IS NULL` = active+completed+archived 혼재) 사용 → 헤더 inbox count (active 만) 와 list 불일치. `listByStatus('active', limit:50)` 로 통일.
- **AI 배너가 completed/archived 탭에서도 노출.** OllamaBanner/PendingBanner/FailedBanner/ExpiryBanner/RecallBanner/RecoveryToast 가 `!showTrash` 만 체크해서 active 무관 컨텍스트에서도 그림. `view === 'inbox'` 분기로 한정.
### 갱신
- **이동 dropdown UX** — 메뉴 열린 상태에서 외부 클릭 / Escape 로 닫힘 (mousedown + keydown listener, useEffect 로 menuOpen=true 일 때만 활성).
### 게이트
- 단위 738 → **734 PASS** (MoveStatusModal 6 case 삭제 + NoteCard 메뉴 case 1 → 3 (직접 이동/외부 클릭/Escape) 로 재구성)
- typecheck 0 errors (src)
- 신규 npm dependency 0
### 업그레이드
v0.3.4 인스톨러 위에 v0.3.5 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음.
## [0.3.4] — 2026-05-11
v0.3.0 Cut E (양방향 sync) dogfood 의 결과로, 사용자가 conflict 시나리오에 막힌 순간 도움받을 곳이 부재한 갭을 메운 cut. 3 표면 (in-app modal + ConflictModal inline + README) 통합 도움말. PR #33 머지.
### 신규
- **`SyncHelpModal` (4 anchor 섹션)** — 설정 → 동기화 저장소 → "도움말" 버튼 또는 ConflictModal 의 "자세히 보기 →" 링크에서 진입. `#main-conflict` (편집/편집·삭제/편집·AI 결과 충돌 결정 트리) / `#auto` (fetch+rebase·첫 sync·push 거부·자동 주기) / `#silent` (NTP·동시 수정·자동 sync 실패 silent) / `#setup` (URL SSH/HTTPS 형식·잘못된 `git@https://` 사례·인증 helper·연결 테스트 실패 troubleshoot·URL 재설정).
### 갱신
- **`ConflictModal` inline 설명** — 각 conflict row 의 "내 것 사용" / "원격 사용" 의미를 1-2 줄 인라인 안내 + (옵션) "자세히 보기 →" 링크 (onOpenHelp callback). 기존 caller backward-compatible (optional prop).
- **`SyncSection` 도움말 버튼** — URL row 마지막에 추가. busy (저장/테스트/sync 진행) 중에도 도움말 reachable (read-only).
- **`README` 동기화 섹션 통째 재작성** — stale "원격 백업 (F6-L2)" (v0.2.1 MVP, 트레이 "지금 동기화" + 수동 `git init` 안내) → "동기화 (Git, F21 Cut E)". 일회 설정 / 일상 사용 / 충돌 해결 (3 케이스) / Silent risk / Troubleshoot. in-app SyncHelpModal 과 동일 정보 산문체.
### 게이트
- 단위 727 → **738 PASS** (+11): SyncHelpModal 7 + ConflictModal 회귀 3 + SyncSection 회귀 1
- typecheck 0 errors
- 신규 npm dependency 0
### 후속 (deferred)
- ESC key handler (현재 SyncHelpModal / ConflictModal 모두 X + overlay 만, 프로젝트 패턴 정합. 도입 시 양쪽 동시).
- 1주 dogfood soak 후 도움말 텍스트 정합성 1차 갱신 (실제 사용자 경험과 어긋난 부분 보강).
### 업그레이드
v0.3.3 인스톨러 위에 v0.3.4 인스톨러를 같은 위치에 실행하면 in-place 업그레이드. 데이터/마이그레이션 변경 없음 (스키마 v8 그대로).
## [0.3.3] — 2026-05-10
v0.3.0 Cut E (양방향 sync) dogfood 첫 시도 중 발견된 sync 설정 ENOENT 버그 hotfix.

View File

@@ -190,37 +190,58 @@ inkling.md 원본 제품 브리프 v1.4
---
## 원격 백업 (선택, F6-L2)
## 동기화 (Git, F21 Cut E)
Inkling 데이터를 사적 git 원격에 백업하려면 한 번만 설정하면 된다. 인코딩된 형식이 아니라 평문 마크다운(F5 export 형식)으로 저장되니, **반드시 비공개 repo** 를 사용한다.
Inkling 데이터를 사적 git 원격으로 양방향 동기화 (Mac ↔ Windows 등). 평문 마크다운(F5 export 형식)으로 저장되니 **반드시 비공개 repo** 를 사용한다.
상세 도움말은 앱 내 설정 → 동기화 저장소 → "도움말" 버튼 (4 섹션 modal) 참조. 본 섹션은 setup + 주요 시나리오 요약.
### 일회 설정
```bash
# 1. 빈 사적 repo 생성 (예: gitea, GitHub private)
1. 빈 사적 repo 생성 (Gitea / GitHub private)
2. 앱 → 설정 → 동기화 저장소 → URL 입력 → "저장"
3. "연결 테스트" 클릭해 인증 / 네트워크 확인
4. 자동 sync 사용 토글 + interval (기본 30분) 확인
# 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 면 무시
URL 형식 (둘 중 하나):
# 3. 자격증명 설정 (Windows Credential Manager 자동 / 또는 token 임베드 URL)
- SSH: `git@host:user/repo.git`
- HTTPS: `https://host/user/repo.git`
# 4. 첫 동기화: 트레이 → "지금 동기화"
```
`git@https://...` 같은 혼합 형식은 거부된다.
처음 sync 시 SyncService 가 `<profileDir>/sync/` 안에 F5 export 트리(notes/, media/, index.jsonl, manifest.json)를 덮어쓰고 `git add -A && git commit && git push -u origin <branch>` 를 자동 수행.
### 일상 사용
### 사용
- 자동 sync: 설정한 interval 마다 + 앱 종료 시 1회
- 수동 sync: 트레이 → "지금 동기화"
- 충돌 발생 시 트레이 토스트 + 설정 페이지의 "충돌 해결…" 버튼 → ConflictModal
- 트레이 → "지금 동기화" 로 수동 트리거
- 앱 종료 시 자동 1회 (sync dir 이 설정된 경우만)
- 변경 없으면 토스트 "변경 사항 없음", 변경 있으면 "동기화 완료"
### 충돌 해결 (ConflictModal)
설정이 안 됐으면 트레이 토스트로 안내. 한 번 설정하면 이후 push 는 OS credential helper 가 자동 처리.
같은 노트를 두 기기에서 동시에 수정하면 path 별 결정 (내 것 / 원격) 을 받는다.
데이터 라이프사이클 측면에서 F6-L1 (로컬 스냅샷, 자동) + F5/F6-L3 (수동 export/import) + F6-L2 (원격 git, 반자동) 3-layer 구조의 마지막 layer.
- **내 것 사용**: 이 기기의 변경을 보존, 원격 변경을 폐기
- **원격 사용**: 원격 변경을 가져오고, 이 기기의 변경을 폐기
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 구조.
---

View File

@@ -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(<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**
```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<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**
```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(<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**
```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 (`<div style={{ marginTop: 8, ... }}>`) 직전에 추가:
```tsx
<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 변경 후 모습:
```tsx
{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**
```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(<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**
```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
<button onClick={() => setShowHelp({ open: true })} disabled={busy !== null} style={btnStyle()}>
</button>
```
`ConflictModal` 호출에 `onOpenHelp` 추가:
```tsx
{showConflict && (
<ConflictModal
onClose={() => setShowConflict(false)}
onResolved={async () => {
setStatus(await inboxApi.getSyncStatus());
}}
onOpenHelp={(anchor) => setShowHelp({ open: true, anchor })}
/>
)}
```
return 의 마지막 (section close 직전) 에 SyncHelpModal mount 추가:
```tsx
{showHelp.open && (
<SyncHelpModal
onClose={() => 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 가 `<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**
```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
...
```

View File

@@ -0,0 +1,138 @@
# Sync 도움말 — Design
날짜: 2026-05-10
대상 버전: v0.3.4 (또는 v0.4.0 통합 시 Cut G 안에 포함)
선행 의존: v0.3.0 Cut E (양방향 sync), v0.3.3 (configure-sync ENOENT hotfix)
## 배경
v0.3.0 Cut E 가 양방향 sync (configure UI + ConflictModal + auto-sync timer) 를 도입했지만, 사용자에게 노출되는 도움말은 다음 세 곳 모두 부족 또는 부재:
- **SettingsPage > 동기화 저장소**: URL 입력 + 저장/연결 테스트 + 자동 sync 토글만 있음. 무엇이 어떻게 동작하는지 안내 0.
- **ConflictModal**: "내 것 사용 (local)" / "원격 사용 (remote)" 버튼만 노출, 각 옵션의 의미·결과 미설명. 사용자는 추측에 의존.
- **README "원격 백업 (F6-L2)" 섹션**: v0.2.1 MVP 시점 기준 (트레이 "지금 동기화" + 수동 `git init`). Cut E 의 Configure UI / ConflictModal / auto-sync timer 미반영 — 사용자가 따라하면 어긋남.
다기기 (Mac + Windows) sync dogfood 는 본인 + 사내 베타 10인의 핵심 가치 검증인데, conflict 시나리오에 막혔을 때 도움말이 없어 사용자가 직접 git 내부 동작을 추측해야 하는 상태.
## 목표
git 기반 sync 의 정상 동작·이상 시나리오·복구 절차를 사용자가 막힌 순간에 바로 찾을 수 있게 만든다.
비목표:
- 'both' choice (v0.3.1+ deferred) 도움말
- 다국어 (앱 한국어 only)
- 스크린샷·GIF (텍스트만으로 충분)
- README 외 docs/sync-guide.md 별도 파일 (in-app 이 메인, README 가 보조 — 별도 파일 발견성 ↓)
## 표면 (3개)
### 1. SyncHelpModal — 신규 컴포넌트
**위치**: `src/renderer/inbox/components/SyncHelpModal.tsx`
**진입점**:
- `SyncSection.tsx`: URL 입력 row 옆에 "도움말" 버튼 추가 → 클릭 시 modal open
- `ConflictModal.tsx`: 각 옵션 설명 옆 "자세히 보기 →" 링크 → SyncHelpModal open + "메인 conflict" 섹션으로 스크롤
**구조**: `ConflictModal` 패턴 재사용 (overlay + 닫기 버튼 + scrollable body). 4 섹션 (단순 anchor jump — 좌측 nav 미도입, modal 무게 ↓):
1. **메인 conflict** — 편집/편집, 삭제/편집, AI 결과 충돌 3 케이스 + 각 결정 트리
2. **자동 처리** — fetch+rebase, 첫 sync 순서, push 순서 ("내가 안 해도 되는 일")
3. **Silent risk** — 시계 어긋남(NTP), 결합 실패 silent, dogfood 주의
4. **Setup/인증** — URL 형식 (SSH vs HTTPS), 인증 helper, 연결 실패 troubleshoot
**Close**: ESC + 우상단 X + overlay 클릭 (ConflictModal 패턴 일치).
### 2. ConflictModal 갱신
**현재**: 각 conflict path 에 대해 "내 것 사용 (local)" / "원격 사용 (remote)" 버튼만.
**변경**: 각 옵션 라벨 아래 1-2 줄 inline 설명 + "자세히 보기 →" 링크.
```text
내 것 사용 (local)
이 기기의 변경을 보존하고 원격의 같은 노트 변경을 폐기.
자세히 보기 →
원격 사용 (remote)
원격 (다른 기기 또는 백업) 의 변경을 가져오고 내 변경을 폐기.
자세히 보기 →
```
"자세히 보기" 클릭 → SyncHelpModal open (메인 conflict 섹션 anchor).
### 3. README "원격 백업 (F6-L2)" 섹션 통째 재작성
**현재 (line 193-223)**: v0.2.1 MVP 기준 stale.
**신규 헤더**: "## 동기화 (Git, F21 Cut E)"
**하위 절**:
- 일회 설정 — Settings > 동기화 저장소 UI 안내 (트레이 "지금 동기화" 안내 제거 — 현재 UI 와 다름)
- URL 형식 명확화: `git@host:user/repo.git` (SSH) 또는 `https://host/owner/repo.git` (HTTPS). v0.3.3 dogfood 에서 발견된 `git@https://` 혼합 오류 사례 명시
- 일상 사용 — auto-sync 주기 / 수동 트리거 / 충돌 시 ConflictModal 안내
- 충돌 해결 — local/remote 결정 트리 (in-app SyncHelpModal 과 같은 내용)
- Silent risk — 시계 어긋남, 동시 수정 회피
- Troubleshoot — push 실패 / 인증 실패 / 첫 sync 순서
## 콘텐츠 분배
| 시나리오 | SyncHelpModal | ConflictModal inline | README |
|---|---|---|---|
| 편집/편집 conflict | 결정 트리 (어떤 변경이 더 최신인지 / 둘 다 보존하려면 사후 수동 병합) | 1줄 + "자세히" 링크 | 상세 + 예시 |
| 삭제/편집 | 케이스 설명 (삭제 측이 'remote' 면 trash 로 이동, 편집 측이 'local' 이면 trash 취소) | (해당 없음 — path 가 같음) | 케이스 설명 |
| AI 결과 충돌 | "재처리 권장" — local/remote 한쪽 선택 후 AI 재실행 권장 | (해당 없음) | 케이스 설명 |
| fetch+rebase 자동 | "내가 안 해도 되는 일" 단원 | — | 동일 |
| 첫 sync 순서 | "Mac 먼저 push → Windows pull 후 push" | — | 동일 |
| 시계 어긋남 (NTP) | "두 기기 동시 수정 회피", `chrony` / Windows time sync 점검 안내 | — | 동일 |
| Setup/URL 형식 | SSH/HTTPS 예시, `git@https://` 같은 혼합 형식 거부 사례 | — | 동일 + 인증 helper 안내 |
| 인증 실패 | "OS credential helper 점검", token URL embed 우회 옵션 | — | 동일 |
## 변경 파일
**신규**:
- `src/renderer/inbox/components/SyncHelpModal.tsx`
- `tests/unit/SyncHelpModal.test.tsx`
**수정**:
- `src/renderer/inbox/components/settings/SyncSection.tsx` — "도움말" 버튼 추가 (URL row 옆)
- `src/renderer/inbox/components/ConflictModal.tsx` — 각 옵션 inline 설명 + "자세히" 링크
- `tests/unit/ConflictModal.test.tsx` — inline 설명 / 링크 클릭 시 SyncHelpModal open 회귀
- `tests/unit/SyncSection.test.tsx` — 도움말 버튼 클릭 → SyncHelpModal open 회귀
- `README.md` — "원격 백업 (F6-L2)" 섹션 line 193-223 통째 재작성
## 게이트
- `SyncHelpModal.test.tsx` 신규 — 4 섹션 렌더링, close (ESC/X/overlay), anchor jump
- `ConflictModal.test.tsx` 회귀 — inline 설명 표시, "자세히" 링크 → SyncHelpModal open
- `SyncSection.test.tsx` 회귀 — 도움말 버튼 → SyncHelpModal open
- typecheck 0
- 단위 +6~8 (SyncHelpModal 4 + ConflictModal 회귀 1 + SyncSection 회귀 1)
- e2e 미수행 (UI-only, 기존 capture/onboarding flow 무관)
## Risk
- **콘텐츠 정확성**: AI 결과 충돌 / 시계 어긋남 같은 시나리오는 dogfood 미경험 (v0.3.3 까지 1 dogfood 발견). 도움말이 실제 사용자 경험과 어긋날 risk → 1주 dogfood soak 후 도움말 텍스트 1차 갱신 필수
- **README 와 in-app 의 중복 maintain**: 두 곳에 같은 내용. 정합성 깨질 risk → 우선순위는 in-app (사용자가 보는 위치). README 는 보조
- **'both' choice 부재 안내**: v0.3.1+ deferred 인데 사용자가 "왜 둘 다 보존이 없냐" 질문 가능 → 도움말에 "현재 미지원, 사후 수동 병합" 명시
- **콘텐츠 길이**: SyncHelpModal 4 섹션이 길어지면 modal 자체가 무거워짐 → 각 섹션 200 자 이내 + README 가 상세. modal 은 "막힌 순간 결정 트리" 우선
## 비포함 / Deferred
- 'both' choice 도움말 (Cut E 정책 deferred)
- 다국어 (앱 한국어 only)
- 스크린샷 / GIF
- 별도 docs/sync-guide.md (README + in-app 으로 충분)
- ConflictModal 의 diff 시각 개선 (별개 task, 본 도움말 cut 의 scope 외)
- 도움말 검색 기능 (4 섹션, 짧음)
## How to apply
- v0.3.4 patch 또는 Cut G 안에 통합. 단독 cut 으로 갈 경우 v0.3.4 — 데이터/마이그레이션 변경 0
- dogfood 1주 soak 후 도움말 텍스트 정합성 1차 갱신 (실제 사용자 경험과 어긋난 부분 보강)
- ConflictModal 의 inline 설명은 "자세히 보기" 링크 한 번이 SyncHelpModal 메인 conflict 섹션 anchor 로 점프 — anchor id 명명: `#main-conflict`, `#auto`, `#silent`, `#setup`

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "inkling",
"version": "0.3.1",
"version": "0.3.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "inkling",
"version": "0.3.1",
"version": "0.3.5",
"dependencies": {
"better-sqlite3": "12.9.0",
"electron-log": "5.2.0",

View File

@@ -1,6 +1,6 @@
{
"name": "inkling",
"version": "0.3.3",
"version": "0.3.5",
"private": true,
"description": "Inkling — local-first 한 줄 보관 도구",
"author": "altair823 <dlsrks0734@gmail.com>",

View File

@@ -153,15 +153,21 @@ export function App(): React.ReactElement {
<main className="main">
{!showTrash && (
<>
<OllamaBanner onOpenSettings={() => setShowSettings(true)} />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
<FailedBanner />
<ExpiryBanner />
<RecallBanner />
{/* AI/만료/회상 배너는 active 노트 컨텍스트 — inbox 탭에서만 노출.
completed/archived 에서는 무관 컨텐츠라 숨김. */}
{view === 'inbox' && (
<>
<OllamaBanner onOpenSettings={() => setShowSettings(true)} />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
<FailedBanner />
<ExpiryBanner />
<RecallBanner />
</>
)}
{tagFilter !== null && (
<div style={{
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',

View File

@@ -1,10 +1,12 @@
import React, { useEffect, useState } from 'react';
import type { SyncConflict } from '@shared/types';
import { inboxApi } from '../api.js';
import type { SyncHelpAnchor } from './SyncHelpModal.js';
interface Props {
onClose: () => void;
onResolved: () => void;
onOpenHelp?: (anchor: SyncHelpAnchor) => void;
}
const overlayStyle: React.CSSProperties = {
@@ -22,7 +24,7 @@ const rowStyle: React.CSSProperties = {
border: '1px solid #eee', borderRadius: 6, padding: 10, marginTop: 8
};
export function ConflictModal({ onClose, onResolved }: Props): React.ReactElement {
export function ConflictModal({ onClose, onResolved, onOpenHelp }: Props): React.ReactElement {
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -74,6 +76,18 @@ export function ConflictModal({ onClose, onResolved }: Props): React.ReactElemen
<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>
{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>
<div style={{ marginTop: 8, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={() => { void onChoose(c.path, 'local'); }}

View File

@@ -1,150 +0,0 @@
import React, { useState } from 'react';
import { inboxApi } from '../api.js';
import type { NoteStatus } from '@shared/types';
interface Props {
noteId: string;
rawText: string;
summary: string;
onClose: () => void;
onMoved: (status: NoteStatus, reason: string | null) => void;
}
/**
* v0.2.9 Cut B Task 7 — 메모 이동 Modal.
*
* 사유 입력 + 3 status 버튼 (완료/보관/휴지통) + AI 자동 분류.
*/
export function MoveStatusModal({
noteId,
onClose,
onMoved
}: Props): React.ReactElement {
const [reason, setReason] = useState('');
const [recommendation, setRecommendation] = useState<{
status: NoteStatus;
rationale: string;
} | null>(null);
const [classifying, setClassifying] = useState(false);
async function move(status: NoteStatus): Promise<void> {
const trimmedReason = reason.trim() === '' ? null : reason.trim();
await inboxApi.setStatus(noteId, status, trimmedReason);
onMoved(status, trimmedReason);
}
async function classify(): Promise<void> {
setClassifying(true);
setRecommendation(null);
try {
const r = await inboxApi.classifyStatus(noteId, reason);
setRecommendation({ status: r.recommended, rationale: r.rationale });
} finally {
setClassifying(false);
}
}
return (
<div
role="dialog"
aria-label="이동"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100
}}
>
<div
style={{
background: '#fff',
padding: 16,
borderRadius: 8,
minWidth: 400,
maxWidth: 520
}}
>
<h2 style={{ fontSize: 16, margin: '0 0 12px' }}> </h2>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="이동 사유 (선택사항)"
rows={2}
style={{ width: '100%', padding: 6, fontSize: 13, boxSizing: 'border-box' }}
/>
<div
style={{
display: 'flex',
gap: 8,
marginTop: 8,
flexWrap: 'wrap',
alignItems: 'center'
}}
>
<button onClick={() => void classify()} disabled={classifying}>
{classifying ? '분류 중...' : 'AI 자동 분류'}
</button>
<button onClick={() => void move('completed')}></button>
<button onClick={() => void move('archived')}></button>
<button onClick={() => void move('trashed')}></button>
<button onClick={onClose} style={{ marginLeft: 'auto' }}>
</button>
</div>
{recommendation !== null && (
<div
style={{
marginTop: 12,
padding: 8,
background: '#f0f8ff',
borderRadius: 4,
fontSize: 12
}}
>
<div>
AI : <strong>{statusLabel(recommendation.status)}</strong>
</div>
<div style={{ marginTop: 4 }}>: {recommendation.rationale}</div>
<div style={{ marginTop: 8 }}>
<button onClick={() => void move(recommendation.status)}>
({statusLabel(recommendation.status)})
</button>
</div>
</div>
)}
</div>
</div>
);
}
export function statusLabel(s: NoteStatus): string {
switch (s) {
case 'active':
return '활성';
case 'completed':
return '완료';
case 'archived':
return '보관';
case 'trashed':
return '휴지통';
}
}
/**
* status 의 한글 라벨에 적절한 조사를 붙여 반환. 받침 있으면 "으로", 없으면 "로".
* 예: '완료로' / '보관으로' / '휴지통으로' / '활성으로'.
*/
export function statusLabelWithParticle(s: NoteStatus): string {
const label = statusLabel(s);
const last = label.charCodeAt(label.length - 1);
// 한글 syllable block 외 → "로" default
if (last < 0xAC00 || last > 0xD7A3) return `${label}`;
const jongseong = (last - 0xAC00) % 28;
return jongseong === 0 ? `${label}` : `${label}으로`;
}

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import type { Note, NoteStatus } from '@shared/types';
import { KST_OFFSET_MS } from '@shared/util/kstDate.js';
import { inboxApi } from '../api.js';
@@ -6,7 +6,7 @@ import { useInbox } from '../store.js';
import { EditableField } from './EditableField.js';
import { IntentBanner } from './IntentBanner.js';
import { pushTagUndo } from './TagUndoToast.js';
import { MoveStatusModal, statusLabelWithParticle } from './MoveStatusModal.js';
import { statusLabelWithParticle } from './statusLabel.js';
import { RevisionHistoryModal } from './RevisionHistoryModal.js';
interface Props {
@@ -116,9 +116,9 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
const [local, setLocal] = useState(note);
const isAiDisabled = local.aiStatus === 'disabled';
const fallbackTitle = local.rawText.split('\n')[0]?.slice(0, 60) || '(빈 메모)';
// v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown + MoveStatusModal target.
const [moveTarget, setMoveTarget] = useState<NoteStatus | null>(null);
// v0.2.9 Cut B Task 6 — 이동 메뉴 dropdown. dropdown 항목 클릭 = 해당 status 로 즉시 이동.
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const [editingRaw, setEditingRaw] = useState(false);
const [draftRaw, setDraftRaw] = useState('');
const [showRevisions, setShowRevisions] = useState(false);
@@ -129,6 +129,25 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
React.useEffect(() => { setLocal(note); }, [note]);
// 이동 dropdown 외부 클릭 / Escape 로 닫기. menuOpen=true 일 때만 listener 활성.
useEffect(() => {
if (!menuOpen) return;
function onMouseDown(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setMenuOpen(false);
}
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('keydown', onKey);
};
}, [menuOpen]);
const formatted = new Date(note.createdAt).toLocaleString('ko-KR');
async function saveTitle(next: string) {
@@ -426,7 +445,7 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
>
{/* v0.2.9 Cut B Task 6 — 모든 view 공통 "이동 ▾" dropdown.
현재 status 와 다른 3개 목적지만 표시. */}
<div style={{ position: 'relative' }}>
<div ref={menuRef} style={{ position: 'relative' }}>
<button
onClick={() => setMenuOpen((o) => !o)}
aria-label="이동"
@@ -461,9 +480,16 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
{possibleTargets.map((t) => (
<button
key={t}
onClick={() => {
setMoveTarget(t);
onClick={async () => {
setMenuOpen(false);
await inboxApi.setStatus(local.id, t, null);
const updated = { ...local, status: t, moveReason: null };
setLocal(updated);
onUpdated(updated);
if (t !== local.status) onDeleted?.();
// setStatus IPC 는 pushNoteUpdated emit 안 함 → 헤더 탭 counts 가 stale.
// refreshMeta 로 server-authoritative counts 재로드.
void useInbox.getState().refreshMeta();
}}
style={{
display: 'block',
@@ -509,22 +535,6 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore
</div>
</div>
{moveTarget !== null && (
<MoveStatusModal
noteId={local.id}
rawText={local.rawText}
summary={local.aiSummary ?? ''}
onClose={() => setMoveTarget(null)}
onMoved={(newStatus, reason) => {
const updated = { ...local, status: newStatus, moveReason: reason };
setLocal(updated);
onUpdated(updated);
// inbox/trash mode 의 list 가 status 별로 필터되므로 onDeleted (제거) 도 호출.
if (newStatus !== local.status) onDeleted?.();
setMoveTarget(null);
}}
/>
)}
{showRevisions && (
<RevisionHistoryModal
noteId={local.id}

View File

@@ -14,13 +14,37 @@ const periodLabel: Record<Props['period'], string> = {
export function ReviewView({ period }: Props): React.ReactElement {
const reviewData = useInbox((s) => s.reviewData);
const setView = useInbox((s) => s.setView);
const backButton = (
<button
onClick={() => setView('inbox')}
style={{
background: 'transparent',
border: 'none',
fontSize: 14,
cursor: 'pointer',
color: '#0a4b80',
padding: 0
}}
>
</button>
);
if (!reviewData) {
return <div style={{ padding: 16, fontSize: 13, color: '#666' }}> </div>;
return (
<div style={{ padding: 16 }}>
<div style={{ marginBottom: 12 }}>{backButton}</div>
<div style={{ fontSize: 13, color: '#666' }}> </div>
</div>
);
}
const max = reviewData.tagCounts[0]?.count ?? 1;
return (
<div style={{ padding: 16 }}>
<h2 style={{ fontSize: 18, margin: 0 }}>{periodLabel[period]} </h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
{backButton}
<h2 style={{ fontSize: 18, margin: 0 }}>{periodLabel[period]} </h2>
</div>
<div style={{ marginTop: 8, fontSize: 13, color: '#444' }}>
{reviewData.totalCount}
</div>

View File

@@ -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<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>
);
}

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { inboxApi } from '../../api.js';
import type { SyncStatusSnapshot } from '@shared/types';
import { ConflictModal } from '../ConflictModal.js';
import { SyncHelpModal, type SyncHelpAnchor } from '../SyncHelpModal.js';
export function SyncSection(): React.ReactElement {
const [url, setUrl] = useState('');
@@ -12,6 +13,7 @@ export function SyncSection(): React.ReactElement {
const [busy, setBusy] = useState<'save' | 'test' | 'sync' | null>(null);
const [feedback, setFeedback] = useState<string | null>(null);
const [showConflict, setShowConflict] = useState(false);
const [showHelp, setShowHelp] = useState<{ open: boolean; anchor?: SyncHelpAnchor }>({ open: false });
useEffect(() => {
void (async () => {
@@ -78,6 +80,9 @@ export function SyncSection(): React.ReactElement {
<button onClick={() => { void onTestConnection(); }} disabled={busy !== null || url.trim() === ''} style={btnStyle()}>
{busy === 'test' ? '확인 중…' : '연결 테스트'}
</button>
<button onClick={() => setShowHelp({ open: true })} style={btnStyle()}>
</button>
</div>
{feedback !== null && (
@@ -129,10 +134,18 @@ export function SyncSection(): React.ReactElement {
onResolved={async () => {
setStatus(await inboxApi.getSyncStatus());
}}
onOpenHelp={(anchor) => setShowHelp({ open: true, anchor })}
/>
)}
</>
)}
{showHelp.open && (
<SyncHelpModal
onClose={() => setShowHelp({ open: false })}
initialAnchor={showHelp.anchor}
/>
)}
</section>
);
}

View File

@@ -0,0 +1,26 @@
import type { NoteStatus } from '@shared/types';
export function statusLabel(s: NoteStatus): string {
switch (s) {
case 'active':
return '활성';
case 'completed':
return '완료';
case 'archived':
return '보관';
case 'trashed':
return '휴지통';
}
}
/**
* status 의 한글 라벨에 적절한 조사를 붙여 반환. 받침 있으면 "으로", 없으면 "로".
* 예: '완료로' / '보관으로' / '휴지통으로' / '활성으로'.
*/
export function statusLabelWithParticle(s: NoteStatus): string {
const label = statusLabel(s);
const last = label.charCodeAt(label.length - 1);
if (last < 0xAC00 || last > 0xD7A3) return `${label}`;
const jongseong = (last - 0xAC00) % 28;
return jongseong === 0 ? `${label}` : `${label}으로`;
}

View File

@@ -52,7 +52,7 @@ interface InboxState {
setTagFilter: (tag: string | null) => void;
setShowSettings: (open: boolean) => void;
setView: (view: InboxView) => void;
loadByView: (view: 'completed' | 'archived' | 'trash') => Promise<void>;
loadByView: (view: 'inbox' | 'completed' | 'archived' | 'trash') => Promise<void>;
toggleShowTrash: () => Promise<void>;
loadTrash: () => Promise<void>;
restoreNote: (id: string) => Promise<void>;
@@ -104,7 +104,9 @@ export const useInbox = create<InboxState>((set, get) => ({
async loadInitial() {
set({ loading: true });
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
inboxApi.listNotes({ limit: 50 }),
// inbox 탭은 status='active' 만 표시 — loadByView('inbox') 와 동일 path 로 일관성 확보.
// listNotes 는 deleted_at IS NULL 만 필터 (= active+completed+archived 혼재) 이라 부정확.
inboxApi.listByStatus('active', { limit: 50 }),
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
@@ -182,13 +184,18 @@ export const useInbox = create<InboxState>((set, get) => ({
else get().setView('inbox');
},
setView(view) {
// view 전환 시 검색/태그 필터 reset — 이전 view 의 필터가 새 view 에 잘못 적용되는 것 방지.
set({
view,
showTrash: view === 'trash',
showSettings: view === 'settings'
showSettings: view === 'settings',
searchResults: null,
searchQuery: '',
tagFilter: null
});
// settings/inbox 외 status view 면 해당 status fetch.
if (view === 'completed' || view === 'archived' || view === 'trash') {
// status view 면 해당 status fetch. inbox 도 포함 — 다른 탭에서 돌아올 때 notes 가
// 이전 status 로 stale 한 상태이므로 재로드 필요.
if (view === 'inbox' || view === 'completed' || view === 'archived' || view === 'trash') {
void get().loadByView(view);
}
// v0.2.11 Cut D — review-* view 진입 시 aggregate 로드.
@@ -197,7 +204,8 @@ export const useInbox = create<InboxState>((set, get) => ({
if (view === 'review-monthly') void get().loadReview('monthly');
},
async loadByView(view) {
const status = view === 'trash' ? 'trashed' : view;
const status =
view === 'trash' ? 'trashed' : view === 'inbox' ? 'active' : view;
const notes = await inboxApi.listByStatus(status, { limit: 200 });
if (view === 'trash') {
set({ trashNotes: notes, trashCount: notes.length });

View File

@@ -58,4 +58,26 @@ describe('ConflictModal', () => {
expect(onClose).toHaveBeenCalled();
});
});
it('각 conflict row 에 local/remote inline 설명 표시', async () => {
render(<ConflictModal onClose={() => {}} onResolved={() => {}} onOpenHelp={() => {}} />);
await waitFor(() => screen.getByText(/local A/));
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();
});
});

View File

@@ -1,98 +0,0 @@
// @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';
const { mockSetStatus, mockClassify } = vi.hoisted(() => ({
mockSetStatus: vi.fn(async () => ({ ok: true as const })),
mockClassify: vi.fn(async () => ({
recommended: 'completed' as const,
rationale: '결재 끝'
}))
}));
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
setStatus: mockSetStatus,
classifyStatus: mockClassify
}
}));
import { MoveStatusModal } from '../../src/renderer/inbox/components/MoveStatusModal';
describe('MoveStatusModal', () => {
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
it('renders reason textarea + 4 buttons + AI classify button', () => {
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
onClose={vi.fn()}
onMoved={vi.fn()}
/>
);
expect(screen.getByRole('textbox')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '완료' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '보관' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '휴지통' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /AI 자동 분류/ })).toBeInTheDocument();
});
it('clicking 완료 calls setStatus with reason', async () => {
const onMoved = vi.fn();
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
onClose={vi.fn()}
onMoved={onMoved}
/>
);
fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } });
fireEvent.click(screen.getByRole('button', { name: '완료' }));
await waitFor(() => {
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', '결재 끝');
expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝');
});
});
it('AI 자동 분류 → recommendation 표시 + 확정 → setStatus', async () => {
const onMoved = vi.fn();
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
onClose={vi.fn()}
onMoved={onMoved}
/>
);
fireEvent.change(screen.getByRole('textbox'), { target: { value: '결재 끝' } });
fireEvent.click(screen.getByRole('button', { name: /AI 자동 분류/ }));
await screen.findByText(/AI 추천/);
expect(screen.getByText(/이유: 결재 끝/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /확정/ }));
await waitFor(() => expect(onMoved).toHaveBeenCalledWith('completed', '결재 끝'));
});
it('빈 사유 → null reason 전달', async () => {
const onMoved = vi.fn();
render(
<MoveStatusModal
noteId="n1"
rawText="t"
summary=""
onClose={vi.fn()}
onMoved={onMoved}
/>
);
fireEvent.click(screen.getByRole('button', { name: '보관' }));
await waitFor(() => expect(mockSetStatus).toHaveBeenCalledWith('n1', 'archived', null));
});
});

View File

@@ -32,10 +32,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
}
}));
const mockRefreshMeta = vi.fn();
vi.mock('../../src/renderer/inbox/store.js', () => ({
useInbox: Object.assign(
() => ({}),
{ getState: () => ({ setTagFilter: vi.fn() }) }
{ getState: () => ({ setTagFilter: vi.fn(), refreshMeta: mockRefreshMeta }) }
)
}));
@@ -143,19 +144,45 @@ describe('NoteCard — 이동 메뉴 (v0.2.9 Cut B Task 6)', () => {
expect(screen.queryByRole('button', { name: '활성으로 이동' })).toBeNull();
});
it('메뉴 항목 클릭 → MoveStatusModal 열림 + 확정 시 setStatus 호출', async () => {
it('메뉴 항목 클릭 → 시 setStatus 호출 (modal 없음)', async () => {
const onUpdated = vi.fn();
render(<NoteCard note={baseNote} onUpdated={onUpdated} mode="inbox" />);
const onDeleted = vi.fn();
render(
<NoteCard
note={baseNote}
onUpdated={onUpdated}
onDeleted={onDeleted}
mode="inbox"
/>
);
fireEvent.click(screen.getByRole('button', { name: '이동' }));
fireEvent.click(screen.getByRole('button', { name: '완료로 이동' }));
// Modal 의 dialog role 등장
expect(screen.getByRole('dialog', { name: '이동' })).toBeInTheDocument();
// Modal 내부의 "완료" 버튼 클릭 → setStatus
fireEvent.click(screen.getByRole('button', { name: '완료' }));
await waitFor(() => {
expect(mockSetStatus).toHaveBeenCalledWith('n1', 'completed', null);
expect(onUpdated).toHaveBeenCalled();
// status 변경 → 현재 view (inbox) 에서 제거되어야 함.
expect(onDeleted).toHaveBeenCalled();
// 헤더 탭 count 동기화.
expect(mockRefreshMeta).toHaveBeenCalled();
});
// modal 미존재 검증.
expect(screen.queryByRole('dialog', { name: '이동' })).toBeNull();
});
it('이동 메뉴 외부 클릭 시 dropdown 닫힘', () => {
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
fireEvent.click(screen.getByRole('button', { name: '이동' }));
expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument();
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('button', { name: '완료로 이동' })).toBeNull();
});
it('이동 메뉴 열린 상태에서 Escape → dropdown 닫힘', () => {
render(<NoteCard note={baseNote} onUpdated={() => {}} mode="inbox" />);
fireEvent.click(screen.getByRole('button', { name: '이동' }));
expect(screen.getByRole('button', { name: '완료로 이동' })).toBeInTheDocument();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('button', { name: '완료로 이동' })).toBeNull();
});
});

View File

@@ -8,6 +8,8 @@ import { render, screen, fireEvent, cleanup } from '@testing-library/react';
// 빈 객체 대신 필요한 메서드를 stub 한다.
vi.mock('../../src/renderer/inbox/api.js', () => ({
inboxApi: {
// setShowSettings(false) → setView('inbox') → loadByView('inbox') 가 listByStatus 호출.
listByStatus: vi.fn(async () => []),
loadOllamaSettings: vi.fn(async () => null),
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
ollamaRecheck: vi.fn(async () => ({ ok: true })),

View File

@@ -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(<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={() => {}} />);
expect(screen.getByText(/편집\/편집/)).toBeInTheDocument();
expect(screen.getByText(/삭제\/편집/)).toBeInTheDocument();
expect(screen.getByText(/AI 결과 충돌/)).toBeInTheDocument();
expect(screen.getByText(/git@https:\/\//)).toBeInTheDocument();
});
});

View File

@@ -72,4 +72,12 @@ describe('SyncSection', () => {
expect(mockSetAuto).toHaveBeenCalledWith(false);
});
});
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();
});
});

View File

@@ -3,6 +3,7 @@ import type { Note } from '@shared/types';
const mockApi = {
listNotes: vi.fn(async () => [] as Note[]),
listByStatus: vi.fn(async () => [] as Note[]),
listTrash: vi.fn(async () => [] as Note[]),
getTrashCount: vi.fn(async () => 0),
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),