Initial Commit

This commit is contained in:
2026-09-16 17:22:14 +09:00
commit 858ee9e9da
335 changed files with 123898 additions and 0 deletions
@@ -0,0 +1,35 @@
# Specification Quality Checklist: 스니펫 팔레트 (Snippet Palette)
**Purpose**: 계획(plan) 넘어가기 전 spec 완성도·품질 확인
**Created**: 2026-08-05
**Feature**: [spec.md](../spec.md)
## Content Quality
- [~] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [~] No implementation details leak into specification
## Notes
- **"No implementation details" (~)**: 이 기능은 "기존 아키텍처(React + WebView2 + 기존 브릿지)에 003을 재개발 이식"이 사용자가 못박은 전제라, 아키텍처 참조(SQLite·브릿지·C# 호스트·Raycast 레이아웃)가 의도적으로 들어감. 다만 그런 구현 앵커는 대부분 **Assumptions 섹션**에 몰아뒀고, 기능 요구(FR)·성공기준(SC)은 사용자 관점의 동작·결과로 기술함. spec-kit 순수주의보단 이 프로젝트 현실(이식·재사용 우선)을 택함.
- 나머지 항목 전부 통과. `/speckit-clarify` 건너뛰고 `/speckit-plan`으로 가도 됨(모호점 없음). plan 단계에서 `2_frontend/docs-lib/` 라이브러리 문서 먼저 참조 규칙 적용할 것.
@@ -0,0 +1,85 @@
# Contract: 스니펫 브릿지 메시지
기준 앱은 Tauri이고 환경별 전달은 `specs/004-tauri-shell/contracts/transport-mapping.md`를 따름. 아래 C# 요청·응답 설명은 참고용 legacy 채널 계약임.
## Tauri 단계별 창 크기 (2026-09-11)
`{type:"window.snippetLayout", stage:"search"|"results"|"preview"|"editor"}``invoke("window_snippet_layout", {stage})`.
- 검색 `640×84`, 결과 `640×440`, 프리뷰 `840×520`, 편집 `960×600` 논리 픽셀. 실제 크기와 DPI·작업 영역 보정은 Rust 소유.
- 호스트가 Tauri일 때만 전송. legacy·브라우저는 화면 단계만 바뀌고 실제 창 크기는 제어하지 않음.
- 다른 route 보고 시 원래 배치를 복원. 종료 직전에 복원한 크기를 저장해 축소 상태가 일반 창 크기를 덮어쓰지 않음.
- 허용되지 않은 stage와 크기 조작 실패는 reject. 기존 transport 오류 토스트로 표시.
## Tauri 앱에 붙여넣기 (2026-09-11)
`{type:"paste.code", text}``invoke("paste_code", {text})`. 스니펫 `Ctrl+Enter`와 기존 코드뷰어 붙여넣기 버튼이 같은 경로를 사용함.
- `Enter`는 기존 원문 복사 후 창 숨김, 복사 버튼은 복사만 하고 창 유지. `Ctrl+Enter`는 선택한 코드에만 적용하고 데스크톱·미편집·비조합 상태에서 한 번만 실행.
- 호스트는 키 해제를 기다린 뒤 직전 앱으로 돌아가고, 실제 입력 직전에 대상·포커스·키 상태를 다시 확인. 대기 중 UI는 멈추지 않고 중복 붙여넣기는 실행하지 않음.
- 성공한 입력 전송 뒤에만 창을 숨김. 실패하면 창을 유지하거나 다시 표시하고 기존 bridge 오류 토스트로 안내.
- 클립보드 기록 뒤에 실패하면 원문은 그대로 남김. 클립보드 자체 기록 실패에는 복사됐다고 안내하지 않음.
- 다른 앱의 편집기가 실제로 내용을 받아들였는지까지 성공 응답이 보장하지는 않음. 별도 입력창에서 end-to-end로 검사하고, 관리자 권한 앱 등 Windows 입력 제한은 오류로 구분.
## 기존 (변경 없음)
| 방향 | 메시지 | 처리 |
|---|---|---|
| JS→C# | `{type:"window.hide"}` | 런처 창 숨김 |
| JS→C# | `{type:"paste.code", text}` | 직전 창에 `text` 붙여넣기 + 창 숨김 |
현재 Enter는 선택한 스니펫 원문을 클립보드에 복사한 뒤 창을 숨김. 기존 `paste.code`는 코드뷰어의 붙여넣기 버튼과 스니펫 Ctrl+Enter에서 사용함. Tauri의 오류·창 표시 동작은 위 절을 따름.
## 신규 — C#→JS 푸시(내비게이션)
| 방향 | 메시지 | 처리 |
|---|---|---|
| C#→JS | `{type:"navigate", path}` | 프론트가 react-router로 `path`로 이동 |
- 스니펫 핫키(Ctrl+1) → C# `ShowPalette()``SendToWeb({type:"navigate", path:"/snippet"})`.
- 프론트 `bridgeNavigate.ts`가 수신 → 등록된 navigate 콜백 호출.
## 신규 — 스니펫 데이터(요청 → 응답, reqId 상관)
각 요청은 프론트가 부여한 `reqId`(문자열, 단조 증가) 포함. C#는 같은 `reqId`로 결과를 돌려준다.
### 요청 (JS→C#)
| 메시지 | 의미 |
|---|---|
| `{type:"snippets.list", reqId}` | 전체 스니펫(usage 병합) 반환 |
| `{type:"snippets.create", reqId, snippet:{name,desc,body,category}}` | 생성(중복 name 거부) |
| `{type:"snippets.update", reqId, snippet:{name,desc,body,category}}` | 수정(name=키, 나머지 갱신) |
| `{type:"snippets.delete", reqId, name}` | 삭제 |
| `{type:"snippets.recordUse", reqId, name}` | usage count+1, last_used=now |
### 응답 (C#→JS)
```jsonc
// 성공
{ "type":"snippets.result", "reqId":"<echo>", "ok":true, "data": <op > }
// 실패
{ "type":"snippets.result", "reqId":"<echo>", "ok":false, "error":"<사람이 읽을 메시지>" }
```
| 요청 | 성공 `data` |
|---|---|
| `snippets.list` | `Snippet[]` (usageCount·lastUsed 병합) |
| `snippets.create` / `update` | 저장된 `Snippet` (또는 갱신 후 전체 목록 — 구현 단순한 쪽) |
| `snippets.delete` | `{name}` |
| `snippets.recordUse` | `{name, usageCount, lastUsed}` |
**에러 계약**: 중복 name 생성/빈 필드 → `ok:false` + 한글 메시지. 프론트는 `ok:false``Error`로 reject → react-query `onError` → sonner 토스트(memos 패턴).
## C# 라우팅
`OnWebMessageReceived``type`으로 분기:
- `window.hide` / `paste.code` → 기존.
- `snippets.*``SnippetBridge`(신규)에 위임: JSON에서 `reqId`·`snippet`·`name` 파싱 → `SnippetRepository` 호출 → `SendToWeb``snippets.result` 응답.
- 파싱은 기존 `type`+`text`만 읽던 걸 확장(요청별 필요한 필드 추출).
## 프론트 리스너
- `snippetBridge.ts`: `Map<reqId,{resolve,reject}>`, `chrome.webview.addEventListener("message")`에서 `snippets.result`만 필터해 매칭 reqId resolve/reject. 각 op은 `postMessage`+promise.
- `bridgeNavigate.ts`: 같은 message 이벤트에서 `navigate`만 필터. (리스너 여러 개 가능 — 서로 독립.)
- 순수 브라우저(`isWebView()===false`): 요청은 즉시 빈 결과/비활성(데스크톱 전용, research R7).
@@ -0,0 +1,42 @@
# Contract: 프론트 데이터 인터페이스 (`snippets.api.ts`)
feature의 유일한 데이터 진입점. 화면·hook은 이 인터페이스만 알고, 뒤가 브릿지(SQLite)인지 나중에 백엔드인지는 모른다. (spec FR-022 / plan 편차1: HTTP 클라이언트 미경유)
```ts
import type { Snippet, SnippetInput } from "../types"
export interface SnippetsApi {
list(): Promise<Snippet[]> // usage 병합, 정렬은 프론트가
create(input: SnippetInput): Promise<Snippet> // 중복 name → reject(Error)
update(input: SnippetInput): Promise<Snippet> // name=키
remove(name: string): Promise<void>
recordUse(name: string): Promise<{ name: string; usageCount: number; lastUsed: number }> // 붙여넣기 성공 후, 갱신된 usage 반환(caller는 무시 가능)
}
```
- v1 구현체 `snippetBridge`(브릿지 왕복). 나중에 백엔드 전환 시 이 인터페이스 뒤 구현만 교체.
- 반환 타입은 순수 데이터(브릿지/HTTP 세부 노출 안 함). 실패는 `Error` reject.
## react-query 계약 (`hooks/useSnippets.ts`) — memos 패턴 준용
```ts
export const SNIPPETS_KEY = "snippets"
useSnippets() // useQuery([SNIPPETS_KEY]) → Snippet[]
useCreateSnippet() // mutation → invalidate([SNIPPETS_KEY]) + sonner "스니펫 생성됨"
useUpdateSnippet() // → invalidate + "저장됨"
useDeleteSnippet() // → invalidate + "삭제됨"
useRecordUse() // → invalidate([SNIPPETS_KEY]) (랭킹 갱신). 토스트 없음(조용히)
```
- 에러: 브릿지 실패는 `ApiError`(HTTP 전용)가 아니라 rejected promise의 순수 `Error`임 → `err instanceof Error ? err.message : "<한글 폴백>"` 형태(memos 톤은 같되 타입만 Error).
- `useSnippets`가 준 목록에 `core/search`+`core/ranking`을 화면에서 적용(서버 정렬 아님 — 로컬 계산).
## 붙여넣기 (기존 재사용, 신규 아님)
```ts
import { pasteToApp } from "@/lib/bridge/webviewBridge"
// Enter / Alt+숫자 →
pasteToApp(snippet.body) // 기존 paste.code — C# PasteService가 앞 창에 Ctrl+V + 창 숨김
await recordUse(snippet.name)
```
+91
View File
@@ -0,0 +1,91 @@
# Data Model: 스니펫 팔레트 (Phase 1)
003 스키마를 그대로 따른다. 랭킹·검색은 프론트에서 계산하므로, 저장은 원자적 데이터만 담는다.
## 엔티티
### Snippet
붙여넣을 코드 조각 한 개.
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
| `name` | string | **PK**, 정규화(대문자·공백→`_`), 유일 | 식별 키, 결과 행 제목 |
| `desc` | string | 기본 `""` | 짧은 설명, 결과 부제 · 검색 대상 |
| `body` | string | 비어있지 않음, 원문 보존 | 실제 붙여넣어지는 내용 |
| `category` | string | 기본 `"코드"` | 분류 라벨 |
- 003의 `change_word1/2`**가져오지 않음**(안 쓰는 유물).
- 저장 시 검증: `name` 정규화 후 비어있지 않음 + 유일(중복 거부), `body` 비어있지 않음. (spec FR-016)
- `name`은 편집 불가(키). desc/body/category만 수정. (FR-017)
### Usage
스니펫별 사용 통계. 별도 테이블(003과 동일).
| 필드 | 타입 | 제약 | 설명 |
|---|---|---|---|
| `name` | string | **PK**, Snippet.name 참조 | 어떤 스니펫 |
| `count` | integer | 기본 0 | 붙여넣기 성공 누적 |
| `last_used` | integer | unix 초 | 마지막 붙여넣기 시각 |
- 붙여넣기 성공 시 upsert: `count+1`, `last_used=now`. (FR-011)
- Snippet에 대응 Usage가 없으면 `(0,0)`으로 취급.
## SQLite 스키마 (C# 호스트 소유)
```sql
CREATE TABLE IF NOT EXISTS snippets (
name TEXT PRIMARY KEY,
desc TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT '코드'
);
CREATE TABLE IF NOT EXISTS usage (
name TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0,
last_used INTEGER NOT NULL DEFAULT 0
);
```
파일: `%LOCALAPPDATA%\CodeAssist\snippets.db`. 첫 실행 시 003의 `snippets.db`가 있으면 1회 복사(시드). (research R4·R5)
## 프론트 타입 (`features/snippets/types.ts`)
로컬 엔티티라 `types/api.ts`(서버 DTO 전용) 아님.
```ts
export interface Snippet {
name: string
desc: string
body: string
category: string
usageCount: number // usage.count (없으면 0)
lastUsed: number // usage.last_used unix초 (없으면 0)
}
export interface SnippetInput { // 생성/수정 입력
name: string
desc: string
body: string
category: string
}
```
- 브릿지 응답에서 snippets ⨝ usage 조인 결과를 `Snippet`(usageCount/lastUsed 병합)로 받는다. C#가 조인해 내려주거나, 두 테이블을 각각 내려 프론트에서 병합 — 구현 시 단순한 쪽(C# 조인 권장).
## 파생 규칙 (프론트 순수함수)
### 검색 (`core/search.ts`) — FR-005/006
- 입력: 스니펫 목록, 쿼리 문자열, (선택)category.
- category가 있으면 그 분류로 먼저 제한. `"전체"`면 전체.
- 쿼리를 공백으로 split → 키워드들. 각 키워드(대문자화)가 **전부(AND)** `(name + " " + desc)`의 대문자화 문자열에 포함될 때만 통과. `body`는 검색 안 함. 퍼지 없음.
- 빈 쿼리 → (category 제한된) 전체.
### 랭킹 (`core/ranking.ts`) — FR-007
- 정렬 키: `count desc, lastUsed desc, name asc`.
- 모든 결과 집합(검색·전체 공통)에 적용. 상위 9개에 `1~9` 번호(Alt+숫자 대응). (FR-008)
## 상태 전이
스니펫: (없음) → 생성 → (편집)* → 삭제.
Usage: 붙여넣기 성공마다 count 증가 · last_used 갱신 → 랭킹 상단으로 부상.
+107
View File
@@ -0,0 +1,107 @@
# Implementation Plan: 스니펫 팔레트 (Snippet Palette)
**Branch**: `001-snippet-palette` | **Date**: 2026-08-05 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/001-snippet-palette/spec.md`
## 2026-09-11 실행 범위
현재 구현 계획은 `spec.md`의 단계별 런처 확정을 따름. 아래 .NET 초기 이식 계획은 미완료 저장 기능의 배경이며, 이번 작업은 Tauri 기준으로 진행함.
1. `SnippetPalettePage.tsx`: 빈 검색 결과 숨김, 명시적인 선택만 프리뷰 표시, 검색·분류·재소환 시 선택 해제. 기존 검색·랭킹·복사 함수와 편집기를 재사용.
2. `SnippetRow.tsx``PaletteShell.tsx`: 테두리 없는 행과 선택 강조, 복사 전용 런처에는 대상 앱 배지를 표시하지 않음.
3. `window.snippetLayout`을 기존 transport로 전달. Rust `commands.rs`가 단계별 크기를 선택하고 `shell/window.rs`가 이전 배치 보관·작업 영역 보정·복원을 담당.
4. `report_route`에서 다른 화면 복원. 창이 닫힐 때 복원한 실측값을 window-state 캐시에 명시적으로 저장해 종료 이벤트 순서에 의존하지 않음.
5. 검색 전환·원문 복사·IME·Esc 포커스·삭제 확인 닫힘은 Vitest, 경계 계산은 Rust 검사. 실제 Tauri에서 크기 전환·최대화 복원·축소 상태 종료 후 재시작 확인.
## Summary
003(파이썬 winkit-snippet)의 스니펫 검색·붙여넣기 기능을 이 앱(React SPA + .NET WebView2 런처)에 재개발 이식한다. 전역 핫키 `Ctrl+1`로 팔레트를 소환해, 검색해서 방금 쓰던 창에 스니펫을 붙여넣는다. UI는 Raycast 결. 저장은 C# 호스트가 소유한 로컬 SQLite(003 스키마 그대로), React는 WebView2 브릿지로 접근.
**기술 접근(재사용 우선):** 이 앱은 이미 팔레트에 필요한 걸 대부분 갖고 있다 — 커맨드 팔레트 UI(`/snap``SessionListPage`: 자동포커스 검색 + ↑↓/Enter nav + IME 안전 + 단계 Esc), 앞 창에 붙여넣는 배선(`pasteToApp``PasteService`), 전역 핫키·트레이·단일실행·앞창 캡처. 그래서 신규는 딱 세 곳만 새로 짠다: (1) 두 번째 전역 핫키, (2) C#→JS `navigate` 신호, (3) React↔SQLite 요청-응답 브릿지 + C# `SnippetRepository`. 나머지는 memos(feature 모듈 카논)·snap(팔레트)의 패턴 복제·리스킨.
## Technical Context
**Language/Version**: TypeScript 5 / React 18 (Vite) · C# / .NET (WPF, WebView2)
**Primary Dependencies**: @tanstack/react-query, shadcn/ui, sonner (프론트) · Microsoft.Web.WebView2, **Microsoft.Data.Sqlite(신규 추가)** (C#)
**Storage**: 로컬 SQLite 파일 `snippets.db`(C# 호스트 소유, 003과 동일 스키마). 브라우저 아님.
**Testing**: vitest(프론트: 검색/랭킹 순수함수 + 브릿지 mock) · xUnit(C#: `SnippetRepository` CRUD/usage against temp db) — TDD(RED→GREEN)는 superpowers 뒷단에서.
**Target Platform**: Windows 데스크톱(WebView2 런처). 순수 브라우저(dev)는 브릿지 no-op → 스니펫 기능 비활성(데스크톱 전용).
**Project Type**: 기존 웹앱(2_frontend) + 데스크톱 호스트(3_windowsApp)에 feature 추가.
**Performance Goals**: 핫키→팔레트 표시 체감 즉시(수백 ms). 검색은 수백~수천 스니펫에서 substring 필터로 즉각(퍼지 없음).
**Constraints**: 붙여넣기 대상은 항상 "소환 직전 창"(기존 캡처 로직 재사용). 붙여넣기 순서 계약(포커스→클립보드→포커스→Ctrl+V)은 기존 `PasteService`가 이미 지킴.
**Scale/Scope**: 개인 단일 PC. 스니펫 수백~수천 규모. 화면 1개(팔레트) + 편집 다이얼로그.
## Constitution Check
*GATE: Phase 0 전 통과, Phase 1 후 재확인.*
`.specify/memory/constitution.md`는 빈 템플릿 → 형식 게이트 없음. **실질 캐논은 `CLAUDE.md`**(재사용 우선·feature 모듈 컨벤션·react-query·TDD·docs-lib 선참조). 이에 비춘 점검:
-**재사용 우선**: 팔레트 UI(snap)·붙여넣기(pasteToApp)·핫키/트레이/캡처 전부 기존 것 재사용. 신규는 최소 3곳.
-**feature 모듈 컨벤션**: `features/snippets/{api,hooks,components,pages}` + react-query(KEY 상수·invalidate·sonner 한글 토스트·ApiError 분기)로 memos 패턴 준용.
- ⚠️ **의도된 편차 1 — 데이터 접근이 `@/lib/api/client`(apiGet/apiPost) 경유 아님**: CLAUDE.md 4번은 "axios/HTTP는 apiGet/apiPost만"인데, 스니펫 저장은 **HTTP 백엔드가 아니라 로컬 SQLite(WebView2 브릿지)**다. 그래서 `snippets.api.ts`는 브릿지 백엔드로 구현. react-query로 감싸는 건 유지. → 백엔드가 아니라 편차가 정당(사용자가 로컬 SQLite로 결정). tasks/impl에서 명시.
- ⚠️ **의도된 편차 2 — 타입 위치**: 서버 DTO는 `types/api.ts`가 규칙이나, `Snippet`은 서버 DTO가 아니라 로컬 엔티티 → snap이 `features/snap/contract/types.ts` 두는 것처럼 `features/snippets/types.ts`에 둠. 정당.
Gate: **PASS** (편차 2건은 문서화된 정당 편차, 복잡도 증가 아님).
## Project Structure
### Documentation (this feature)
```text
specs/001-snippet-palette/
├── plan.md # 이 파일
├── research.md # Phase 0 — 미해결점 결정(브릿지 왕복·멀티핫키·SQLite·시드·프리뷰)
├── data-model.md # Phase 1 — Snippet/Usage 엔티티 + DB 스키마 + TS 타입 + 랭킹규칙
├── contracts/ # Phase 1 — 브릿지 메시지 계약 + 프론트 데이터 인터페이스
│ ├── bridge-messages.md
│ └── snippet-data-interface.md
├── quickstart.md # Phase 1 — end-to-end 검증 시나리오
└── checklists/requirements.md
```
### Source Code (repository root)
```text
2_frontend/src/
├── features/snippets/ # ★ 신규 feature (memos 모양 + snap 팔레트 재사용)
│ ├── types.ts # Snippet 로컬 타입(서버 DTO 아님)
│ ├── api/snippets.api.ts # 데이터 인터페이스(브릿지 백엔드). list/create/update/delete/recordUse
│ ├── core/search.ts # 순수 검색(키워드 AND, name+desc, 대문자) — 단위테스트
│ ├── core/ranking.ts # usage 정렬 (-count,-lastUsed,name) — 단위테스트
│ ├── hooks/useSnippets.ts # react-query (SNIPPETS_KEY·invalidate·토스트)
│ ├── components/ # SnippetRow·PreviewPane·CategoryChips·EditDialog (Raycast 리스킨)
│ └── pages/SnippetPalettePage.tsx # snap SessionListPage 복제 + Raycast 룩 + Enter→pasteToApp
├── lib/bridge/
│ ├── webviewBridge.ts # (기존) + C#→JS 리스너 확장
│ ├── snippetBridge.ts # ★ 신규: 요청-응답(reqId) 래퍼 — snippets.api 가 사용
│ └── bridgeNavigate.ts # ★ 신규: C#→JS navigate 수신 → react-router 이동
├── config/routes.ts # + PATHS.SNIPPET
└── routes.tsx # + SnippetPalettePage 라우트(SnapLayout 그룹)
3_windowsApp/
├── CodeAssist.Shell/
│ ├── Storage/SnippetRepository.cs # ★ 신규: 003 repository.py+usage.py 포팅(Microsoft.Data.Sqlite)
│ ├── Storage/SnippetSeed.cs # ★ 신규: 첫 실행 시 003 snippets.db 1회 복사
│ ├── Platform/HotKeyService.cs # 확장: 멀티 핫키(id별 등록/디스패치)
│ └── CodeAssist.Shell.csproj # + Microsoft.Data.Sqlite
├── CodeAssist.App/
│ ├── App.xaml.cs # 확장: Ctrl+1 두 번째 핫키 + WndProc wParam 전달
│ └── Views/WebHostView.xaml.cs # 확장: snippets.* 브릿지 핸들러 + navigate 송신(SendToWeb)
└── CodeAssist.Tests/ # + SnippetRepository 테스트
```
**Structure Decision**: 기존 2폴더 구조(2_frontend feature 모듈 + 3_windowsApp WPF/Shell) 유지. 신규 코드는 위 ★ 표시. 새 프로젝트/레이어 없음.
## Complexity Tracking
> Constitution 위반 없음. 편차 2건은 §Constitution Check에 정당화(로컬 SQLite라 HTTP 클라이언트 미경유 / 로컬 엔티티라 feature-local 타입). 별도 정당화 표 불필요.
+49
View File
@@ -0,0 +1,49 @@
# Quickstart: 스니펫 팔레트 검증
end-to-end로 "돌아간다"를 확인하는 가이드. 구현 코드는 tasks.md/구현 단계에서.
## 전제
- Windows + WebView2 런타임.
- `3_windowsApp` 빌드 가능(.NET SDK), `2_frontend` `npm install` 완료.
- DEBUG 실행은 WebView2 안에서 vite(localhost:15173)를 자동으로 물어 띄움 → 브릿지 살아있음.
## 실행
```pwsh
# 데스크톱 런처(DEBUG) — WebView2 안에 React dev 앱 + C# 호스트
# 3_windowsApp 솔루션 빌드/실행 (CodeAssist.App)
```
## 단위 검증 (호스트 없이)
```pwsh
# 프론트: 검색/랭킹 순수함수 + 브릿지 mock
cd 2_frontend; npm run test # core/search, core/ranking, snippets.api(브릿지 mock)
# C#: SnippetRepository CRUD/usage (temp sqlite)
dotnet test 3_windowsApp/CodeAssist.Tests
```
## end-to-end 시나리오 (수동)
각 시나리오는 spec의 Acceptance를 눈으로 확인.
1. **소환·진입** (US1-1): 메모장에 포커스 → `Ctrl+1` → 팔레트가 가운데 뜨고 `/snippet`으로 진입, 검색 자동포커스, 결과는 usage 순.
2. **검색** (US1-2): 키워드 입력 → 공백 나눈 키워드 전부가 name+desc에 있는 것만 남음(대소문자 무시, body 매칭 안 됨).
3. **프리뷰** (US1-3): `↑`/`↓` → 우측 프리뷰가 선택 스니펫 body를 원문 그대로(공백/줄바꿈/특수문자) 표시.
4. **붙여넣기** (US1-4): `Enter` → 팔레트 사라지고 **메모장(직전 창)에** body가 정확히 삽입, 그 스니펫 usage 상승.
5. **빠른 붙여넣기** (US1-5): `Alt+3` → 3번 행이 선택 이동 없이 즉시 붙여넣어짐.
6. **Esc 단계** (US1-6): 검색어 있을 때 `Esc` → 비움 / 빈 상태에서 `Esc` → 숨김.
7. **생성** (US2-1~3): 코드 복사 후 생성 → body가 클립보드로 채워짐 / 이름·내용 빈 채 저장 거부 / 중복 이름 거부.
8. **편집·삭제** (US2-4~6): `F2`/더블클릭 → 이름 읽기전용, 수정 저장 / 편집창서 삭제+확인 → 사라짐 / 완료 후 목록 새로고침.
9. **분류** (US3): category 칩 선택 → 그 분류만 / `Ctrl+←/→` 칩 이동.
10. **영속** (SC-003): 앱 껐다 켜기 → 스니펫·usage 유지. **첫 실행 시** 003 `snippets.db` 있으면 그 데이터로 시작(시드), 없으면 빈 목록.
11. **대상 정확성** (SC-006): 서로 다른 창에서 각각 `Ctrl+1`→붙여넣기 → 항상 "부르기 직전 창"에 들어감.
## 참조
- 메시지 계약: [contracts/bridge-messages.md](./contracts/bridge-messages.md)
- 데이터 인터페이스: [contracts/snippet-data-interface.md](./contracts/snippet-data-interface.md)
- 데이터 모델/스키마: [data-model.md](./data-model.md)
- 결정 근거: [research.md](./research.md)
+68
View File
@@ -0,0 +1,68 @@
# 완료 보고: 스니펫 팔레트 (001-snippet-palette)
**브랜치**: `feat/snippet-palette` · **작성**: 2026-08-05 · **상태**: 최종 승인 대기
003(파이썬 winkit-snippet)의 스니펫 검색·붙여넣기 기능을 이 앱(React SPA + .NET WebView2)에 재개발 이식. 전역 핫키 `Ctrl+1` → Raycast 결 팔레트 → 검색 → 앞 창에 붙여넣기. 저장은 C# 호스트 소유 로컬 SQLite.
## 계획 vs 결과
| Phase / Story | 계획 | 결과 |
|---|---|---|
| Foundational (T001-T011) | 멀티핫키·Ctrl+1·SendToWeb/navigate·SnippetRepository+스키마·시드·브릿지 왕복·라우트 | ✅ 전부. C# 10→17 테스트, 프론트 브릿지 왕복 테스트 |
| US1 / MVP (T012-T024) | 검색(키워드 AND, name+desc)·usage 랭킹·Enter/Alt 붙여넣기·프리뷰·Raycast 팔레트 | ✅ 전부. 검색/랭킹 순수함수 + api 테스트 |
| US2 / CRUD (T025-T031) | 생성/편집/삭제·EditDialog(클립보드 프리필)·검증(중복·빈값 거부) | ✅ 전부. repo CRUD xUnit + api 테스트 |
| US3 / category (T032-T034) | category 칩 필터 + Ctrl+←/→ | ✅ 전부. (검색 category 필터는 US1때 이미 구현돼 있어 칩 UI만 추가) |
| Polish (T035-T037) | 최종 검증·quickstart·report | T035 ✅ / T036 데스크톱 수동확인(사용자) / T037 이 문서 |
## 신규 vs 재사용 (재사용 우선 원칙)
**신규(핵심 3곳만):**
- C# 2번째 전역 핫키(멀티핫키로 `HotKeyService` 확장) + Ctrl+1
- C#→JS `navigate` 푸시(`SendToWeb`/`PostWebMessageAsJson`)
- React↔SQLite reqId 요청-응답 브릿지 + C# `SnippetRepository`/`SnippetBridge`/`SnippetSeed`
**재사용:**
- 팔레트 UI: snap `SessionListPage`(키보드 nav·IME 안전·Esc 단계) 복제 후 Raycast 리스킨
- 붙여넣기: 기존 `pasteToApp``PasteService`(앞 창 Ctrl+V) 그대로
- 핫키/트레이/단일실행/앞창 캡처, memos feature/react-query 패턴
## 문서화한 정당 편차
1. 데이터가 `@/lib/api/client`(HTTP) 미경유 — 로컬 SQLite라 브릿지 백엔드. react-query는 유지.
2. `Snippet` 타입이 `types/api.ts`(서버 DTO) 아닌 feature-local — 로컬 엔티티(snap 방식과 동일).
## 계획과 달라진 점 / 겪은 문제
- **시드 소스 부재**: 003의 `snippets.db`가 이 머신에 실제로 없음(포터블/미실행) → 첫 실행 시 빈 DB로 시작(설계상 fallback). 003 데이터 이어받기는 그 파일이 생기면 자동 동작.
- **git index 레이스**(Foundational): 프론트·C# implementer를 병렬로 돌렸다가 같은 git index 충돌로 커밋이 섞이고 일부 미커밋됨. working tree(진실원천)로 검증·복구(30159d2). 이후 **implementer 순차 원칙** 확립.
- **클릭 모델 정리**(US2 리뷰): US1이 행 단일클릭=붙여넣기로 만들어 더블클릭 편집이 죽어있었음 → 단일클릭=선택, Enter/Alt=붙여넣기, 더블클릭/F2=편집(003·spec 정합).
- **콜드스타트 navigate 드롭**(Foundational 리뷰): 앱 시작 직후 첫 Ctrl+1이 유실될 수 있어 pending 버퍼+NavigationCompleted flush로 방어.
## 검증
- 프론트: **233/233** 통과, build OK. 스니펫 feature lint **0문제**. (repo 전체 20 lint 문제는 무관 feature의 pre-existing 빚.)
- C#: **17/17** 통과, build 0 경고/에러.
- 런타임 E2E(Ctrl+1→검색→붙여넣기, 시드, 붙여넣기 대상 정확성): **데스크톱 앱 실행 필요 — 헤드리스 불가. 사용자 수동 확인 권장**(quickstart.md 시나리오 1~11).
## 남은 것 / 이월(deferred)
- **[수동확인]** quickstart 시나리오 — 실제 데스크톱에서.
- **[deferred]** 붙여넣기 실패해도 recordUse 됨(기존 `paste.code` fire-and-forget 프로토콜 한계, ack 추가는 별개 작업).
- **[deferred]** list-fetch 에러가 빈상태와 동일 표시(에러 UI 없음).
- **[deferred]** `HotKeyService.Dispose` 단일 `_hwnd` 가정(현재 두 핫키 같은 hwnd라 무해).
- **[deferred]** `SnippetBridge` reqId 빈값 시 promise 영구 대기(정상 caller는 항상 reqId 세팅).
## 최종 리뷰 (whole-branch, opus)
**APPROVE** — 보안(전 쿼리 파라미터화, body는 `<pre>` escape)·정합성(delete가 usage까지·name 정규화 일관)·브릿지 상관(reqId 유일·리스너 멱등)·동시성(WebView2 UI 스레드 단일) 다 클린. Critical/Important 신규 없음.
리뷰서 나온 신규 Minor 4개는 **1회 fix wave로 처리 완료**(재리뷰 ALL ADDRESSED):
- **M1/M2**: `/snippet``SnapLayout``ProtectedRoute` 직속 `h-screen` 풀스크린으로 이동 → snap 헤더 없이 Raycast 풀윈도 룩 복구 + Ctrl+N 이탈 차단. auth 유지(routes.test.tsx 회귀가드).
- **M3**: navigate 버퍼는 pre-ready일 때만 → reload 시 stale 재전송 방지.
- **M4**: 비도메인 예외는 raw 메시지 대신 generic 한글 토스트(SQLite 영어 내부 노출 차단).
**런타임 미검(헤드리스 한계 — 사용자 확인 필요):** 실제 데스크톱에서 Ctrl+1 → 팔레트 풀윈도 중앙 표시(snap 헤더 없음)·검색·앞 창 붙여넣기·Ctrl+N 비이탈. quickstart.md 시나리오로 눈확인 권장.
**최종 검증:** 프론트 **234/234**, C# **17/17**, 빌드 클린. 스니펫 코드 lint 0문제.
**결론:** 문서화된 deferral(위 "남은 것") 제외하고 **merge 준비 완료**.
+82
View File
@@ -0,0 +1,82 @@
# Research: 스니펫 팔레트 (Phase 0)
브레인스토밍·clarify에서 큰 결정은 끝났고(저장=로컬 SQLite, 핫키=Ctrl+1, UI=Raycast, 데이터=003 시드), 여기선 **구현 미해결점**만 결정한다.
---
## R1. React ↔ SQLite 브릿지: 요청→응답 방식
**결정**: 기존 postMessage 채널을 **reqId 상관관계(correlation) 왕복**으로 확장. `AddHostObjectToScript`(COM 호스트 객체) 안 씀.
- JS→C#: `{type:"snippets.list", reqId}` 식으로 요청(각 호출마다 증가하는 reqId).
- C#→JS: `Web.CoreWebView2.PostWebMessageAsJson({type:"snippets.result", reqId, ok, data|error})`로 응답.
- JS: `Map<reqId, {resolve,reject}>` 유지, `chrome.webview.addEventListener("message")`에서 매칭 reqId를 resolve.
**Rationale**:
- 기존 브릿지가 이미 postMessage(단방향)라 **같은 메커니즘 하나로 통일** — 스타일 일관, 학습비용 0.
- COM 마샬링(hostObjects) 회피: 배열/객체 반환 시 JSON 문자열 마샬링이 번거롭고, 기존 코드와 이질적.
- SQLite 호출은 로컬·즉각이라 C#에서 동기로 처리 후 바로 응답 → async 복잡도 불필요(UI 스레드에서 안전, 기존 paste도 UI 스레드).
**Alternatives 기각**:
- `AddHostObjectToScript`: 호출부는 깔끔(`await hostObjects.snippets.list()`)하나 COM 세팅·마샬링·기존 스타일 이탈.
- 별도 로컬 HTTP 서버(C#): 과함(YAGNI). 브릿지로 충분.
## R2. C#→JS 방향(navigate 신호)
**결정**: `CoreWebView2.PostWebMessageAsJson`로 C#→JS 푸시. JS는 `chrome.webview.addEventListener("message", …)`로 수신. 이 채널을 (a) 스니펫 핫키 시 `{type:"navigate", path:"/snippet"}`와 (b) R1의 `snippets.result` 양쪽에 공용.
- `WebHostView``SendToWeb(object msg)` 추가(`PostWebMessageAsJson(JsonSerializer.Serialize(msg))`).
- 프론트 `bridgeNavigate.ts`: navigate 메시지 수신 → 등록된 react-router `navigate` 콜백 호출. React 최상위에서 mount 시 콜백 등록.
**Rationale**: WebView2 기본 제공 기능, 의존성 0. 팔레트는 부팅 시 `/snap`으로 뜨므로(WebHostView `StartPath`), 스니펫은 이 navigate로 진입.
**주의**: 여러 `addEventListener` 등록 가능 → `snippetBridge`(결과용)와 `bridgeNavigate`(내비용)가 각자 리스너를 걸고 `type`으로 필터. 서로 결합 안 함.
## R3. 멀티 핫키(C#)
**결정**: `HotKeyService`를 **id별 다중 등록**으로 확장.
- 현재: 단일 `HotKeyId=0x1000`, 단일 `HotKeyPressed`(Action), `ProcessMessage(int msg)`**wParam(=핫키 id)을 무시**.
- 변경:
- `Register(int id, uint mods, uint vk)` — 등록 id를 인자로, 내부 `HashSet<int>`로 추적, Dispose 시 전부 Unregister.
- `event Action<int>? HotKeyPressed` — 눌린 id 전달.
- `ProcessMessage(int msg, IntPtr wParam)` — wParam.ToInt32()가 핫키 id. `App.WndProc`가 wParam을 넘기도록 수정.
- `App`: id `0x1000`=snap 토글(Ctrl+Alt+Space, 기존), `0x1001`=스니펫(Ctrl+1). 등록 실패해도 죽지 말고 트레이 안내(기존 패턴).
**Ctrl+1 상수**: `MOD_CONTROL(0x2)`, `VK_1(0x31)`. **핫키 조합은 App 상수 한 곳**에 모아 나중에 바꾸기 쉽게(spec Edge: 전역 Ctrl+1 가로채기 감수).
**스니펫 핫키 동작**: 눌리면 `ShowPalette()`(앞창 캡처+표시, 기존) 후 `SendToWeb({type:"navigate", path:"/snippet"})`. 이미 /snippet에서 보이는 상태면 토글로 숨김(선택, 구현 단순하면 적용).
## R4. SQLite 라이브러리·DB 경로
**결정**:
- 라이브러리: **`Microsoft.Data.Sqlite`** (경량·MS 공식) 를 `CodeAssist.Shell.csproj`에 추가. (현재 리포에 SQLite 참조 전무.)
- DB 경로: `%LOCALAPPDATA%\CodeAssist\snippets.db` (쓰기 가능·안정. WebView2 UserDataFolder(temp)와 별개 — temp는 캐시성).
- 스키마: 003 그대로 — `snippets(name PK, desc, body, category)`, `usage(name PK, count, last_used)`. (data-model.md 참조.)
- 저장 방식: 003의 통짜 replace 대신 **행 단위 upsert/delete**로(개별 CRUD API라 그게 자연스러움). 랭킹·검색은 프론트가 하므로 C# repo는 순수 CRUD + usage upsert만.
**Rationale**: MS 공식·의존성 가벼움. `SQLitePCLRaw` 번들 포함이라 네이티브 배포도 무난.
## R5. 첫 실행 시드(003 데이터 1회 복사)
**결정**: `SnippetSeed` — 앱 시작 시 대상 DB(`%LOCALAPPDATA%\CodeAssist\snippets.db`)가 **없을 때만**, 003의 `snippets.db`가 알려진 경로에 있으면 그 파일을 대상 경로로 복사. 있으면(=이미 시드됨) 아무것도 안 함. 003 파일이 없으면 빈 DB로 시작(에러 아님).
- **003 경로 확인은 구현 시점 lookup**: 003(winkit)의 데이터 디렉토리 규칙(`winkit.shell` 데이터 폴더 + `snippets.db`)을 그때 확인해 상수화. 못 찾으면 빈 DB로 폴백(기능 안 막음).
- 파일 복사 한 번이라 "임포트 기능"급 작업 아님(spec FR-024).
## R6. 프리뷰 렌더링
**결정**: 프리뷰·body 표시는 **순수 `<pre>` 고정폭**(공백·줄바꿈 원문 보존). `react-markdown` **안 씀**.
**Rationale**: body는 마크다운이 아니라 **날 코드**다. 마크다운 파싱하면 특수문자(`*`,`#`,`` ` ``)가 깨지고 붙여넣을 원문과 화면이 달라짐. `<pre>`가 요구사항(원문 그대로·고정폭·no-wrap)을 정확히 만족. → docs-lib(react-markdown) 트리거 안 함.
## R7. 순수 브라우저(dev) 폴백
**결정**: 스니펫 기능은 **데스크톱(WebView2) 전용**. 순수 브라우저에서 `isWebView()===false`면 `snippets.api`는 빈 목록/명확한 비활성 상태를 반환(throw 대신 graceful). 붙여넣기·핫키·SQLite 모두 호스트가 필요하므로 순수 브라우저 지원은 비목표.
**Rationale**: DEBUG 개발도 WebView2 안에서 vite를 물어 돌리므로(WebHostView가 localhost:15173을 WebView2로 네비) 브릿지가 살아있음 → 개발 지장 없음. 순수 크롬 탭 지원은 YAGNI.
## 미해결/구현 시점 확인 목록
- 003 `snippets.db` 실제 경로(R5) — winkit 데이터 디렉토리 규칙 확인 후 상수화.
- `Microsoft.Data.Sqlite` 버전 핀 — 구현 시 최신 안정.
+192
View File
@@ -0,0 +1,192 @@
# Feature Specification: 스니펫 팔레트 (Snippet Palette)
**Feature Branch**: `001-snippet-palette`
**Created**: 2026-08-05
**Status**: Draft
**Input**: 003(winkit-snippet, 파이썬 PySide6)의 snippet 기능을 021.code-assistant-v2(React SPA + .NET WebView2 데스크톱 런처)에 재개발해 이식. snap과 별도 route(`/snippet`), 동일 아키텍처. 저장은 로컬 SQLite(003 스키마 그대로, C# 호스트가 소유). 전용 전역 핫키 Ctrl+1로 소환. UI/UX는 Raycast 결.
## 2026-09-11 확정: 단계별 스니펫 런처
이번 사용자 승인으로 아래 초기 초안의 항상 열린 목록·자동 프리뷰·Enter 붙여넣기 설명은 이 절로 대체함. 미완료 CRUD·사용기록 저장은 이번 범위에 포함하지 않음.
- 기준 앱은 Tauri(`specs/004-tauri-shell/`), 소환은 `Ctrl+Shift+7`. 클린 블루와 기존 Enter 원문 복사를 유지함.
- 처음 소환하면 검색창만 표시. 공백만 입력한 경우도 결과·분류·프리뷰를 숨김.
- 검색어 입력 시 목록과 분류·하단 힌트만 표시. 첫 행을 자동 선택하지 않음.
- `↓`는 첫 행, `↑`는 마지막 행부터 선택. 이후 화살표·행 클릭으로 선택한 코드만 오른쪽에 표시.
- 프리뷰에는 코드·문법 색상·옅은 줄 번호만 표시. 이름·설명·분류 중복, 장식 점, 코드 번호·언어 헤더, 박스 테두리·그림자는 제거하고 세로 스크롤은 하나만 둠.
- 복사·앱에 붙여넣기는 하단 액션바에 모음. 복사 버튼은 창을 유지하고, 검색창/선택 행 Enter는 기존처럼 복사 후 닫음. 버튼에 포커스한 Enter는 해당 버튼 동작을 실행함. 챗봇 코드 카드는 기존 모양을 유지함.
- 검색어·분류 변경 시 선택을 해제. `Esc`로 검색을 지우면 검색창에 포커스를 돌려주고, 빈 검색에서 `Esc`는 창을 숨김.
- 선택한 행에서 `Enter`로 원문을 복사. 미선택·IME 조합 중에는 복사하지 않음.
- 선택한 코드의 `Ctrl+Enter`는 기존 앱에 붙여넣기 버튼과 같은 경로를 실행함. 데스크톱에서만 사용하고 미선택·IME 조합·편집 중·자동 반복·추가 modifier 조합은 무시함.
- 붙여넣기는 누른 키를 놓은 뒤 대상 창을 확인하고 실행. 대상 없음·창 전환 실패·키 해제 시간 초과 때는 창과 오류 안내를 유지하며, 이미 복사한 원문은 클립보드에 남김.
- 실제 창의 콘텐츠 크기: 검색 `640×84`, 결과 `640×440`, 프리뷰 `840×520`, 편집 `960×600` 논리 픽셀. DPI와 모니터 작업 영역·바깥 테두리를 반영해 화면 안에 맞춤.
- 챗봇 복귀와 종료 때 원래 창 크기·위치를 복원. 최대화 전 일반 크기도 보존하며, 축소 크기를 다음 시작 크기로 저장하지 않음.
- 재소환은 검색어·분류·선택·편집/삭제 확인을 닫고 검색부터 다시 시작함.
---
## 개요
자주 쓰는 코드 조각(스니펫)을 전역 핫키로 언제든 불러, 몇 글자 검색하고 방금까지 쓰던 창에 바로 붙여넣는 기능. 003(파이썬)에 있던 걸 이 앱(React + WebView2) 구조로 다시 만든다. 핵심 동작은 003 그대로, 화면은 Raycast 커맨드 팔레트 느낌으로.
**디자인 방향(Raycast):** 가운데 떠있는 프레임리스 커맨드바 — 맨 위 큰 검색 입력, 그 아래 결과 리스트(행마다 제목 + 부제 + 우측 액세서리), 우측에 선택 항목 프리뷰, 맨 아래 액션 힌트바(왼쪽 컨텍스트 / 오른쪽 `⏎ 붙여넣기` 등). 키보드 우선, 어두운 톤·둥근 모서리.
---
## Clarifications
### Session 2026-08-05
- Q: v1 시작 시점의 데이터 소스(빈 DB / 003 파일 1회 복사 / 003 파일 직접 공유) → A: 첫 실행 시 003의 `snippets.db`를 앱 데이터 폴더로 1회 복사(시드) 후 독립 운영.
---
## User Scenarios & Testing *(mandatory)*
### User Story 1 - 핫키로 검색해서 앞 창에 붙여넣기 (Priority: P1)
다른 앱(에디터·터미널 등)에서 작업하다 `Ctrl+1`을 누르면 스니펫 팔레트가 화면 가운데 뜬다. 검색어를 몇 글자 치면 결과가 실시간으로 좁혀지고, 자주·최근 쓴 것이 위에 온다. `Enter`로 선택하거나 `Alt+1~9`로 번호 매긴 행을 바로 골라 붙여넣으면, 팔레트가 사라지고 **방금 전까지 쓰던 창에 스니펫 내용이 그대로 들어간다.**
**Why this priority**: 이게 이 기능의 존재 이유. 이거 하나만 돌아도 도구로서 가치가 있음(스니펫은 미리 넣어둔 상태 가정). 나머지는 다 이걸 돕는 것.
**Independent Test**: 스니펫 몇 개가 저장된 상태에서, 아무 텍스트 에디터에 포커스 → `Ctrl+1` → 검색어 입력 → `Enter` → 에디터에 스니펫 body가 원문 그대로 삽입되는지로 단독 검증 가능.
**Acceptance Scenarios**:
1. **Given** 에디터에 커서가 있고 스니펫이 여러 개 저장돼 있음, **When** `Ctrl+1`을 누름, **Then** 화면 가운데에 검색 입력에 포커스된 팔레트가 뜨고 결과가 usage 순으로 보인다.
2. **Given** 팔레트가 떠있음, **When** 검색어를 입력, **Then** 공백으로 나눈 키워드가 **전부** `name`+`desc`(대소문자 무시)에 들어있는 스니펫만 남고, usage 순으로 정렬된다.
3. **Given** 결과가 보임, **When** `↑`/`↓`로 이동, **Then** 선택 행이 바뀌고 우측 프리뷰가 그 스니펫의 body를 보여준다.
4. **Given** 원하는 행이 선택됨, **When** `Enter`, **Then** 팔레트가 사라지고 직전 활성 창에 그 스니펫 body가 붙여넣어지며, 그 스니펫의 usage(횟수+최근시각)가 올라간다.
5. **Given** 결과 상위 9개가 `1~9` 번호로 표시됨, **When** `Alt+3`, **Then** 3번 행이 선택 이동 없이 즉시 붙여넣어진다.
6. **Given** 검색어가 있음, **When** `Esc`, **Then** 검색어가 먼저 비워지고, 이미 비어있으면 팔레트가 숨는다.
---
### User Story 2 - 스니펫 만들고 고치고 지우기 (Priority: P2)
팔레트에서 새 스니펫을 만들거나(주로 방금 복사한 코드를 등록), 기존 걸 고치거나 지운다. 새로 만들 때 body는 현재 클립보드 내용으로 미리 채워진다.
**Why this priority**: 스니펫이 있어야 P1이 쓸모 있음. 다만 초기엔 수동/시드로 채워도 되니 P1보단 뒤.
**Independent Test**: 팔레트에서 생성 버튼/키 → 이름·내용 입력 → 저장 → 검색에 새 스니펫이 뜨는지. 편집·삭제도 같은 식으로 단독 검증.
**Acceptance Scenarios**:
1. **Given** 팔레트가 떠있고 클립보드에 코드가 있음, **When** 생성 액션, **Then** 편집 다이얼로그가 열리고 body가 클립보드 내용으로 채워지며 category 기본값은 "코드".
2. **Given** 생성 다이얼로그, **When** 이름 비움 또는 body 비움으로 저장 시도, **Then** 저장이 거부되고 뭐가 문제인지 알려준다.
3. **Given** 생성 다이얼로그, **When** 이미 있는 이름으로 저장 시도, **Then** 중복이라 거부된다.
4. **Given** 결과에서 한 행 선택, **When** `F2` 또는 더블클릭, **Then** 편집 다이얼로그가 열리고 이름은 읽기전용(=키), 나머지는 수정 가능.
5. **Given** 편집 다이얼로그, **When** 삭제 액션 + 확인, **Then** 그 스니펫이 지워지고 목록에서 사라진다.
6. **Given** 저장/수정/삭제 완료, **When** 다이얼로그 닫힘, **Then** 팔레트 목록이 현재 검색어 기준으로 새로고침된다.
---
### User Story 3 - category 칩으로 좁혀보기 (Priority: P3)
결과가 많을 때 category(전체/코드/…) 칩으로 한 분류만 추려본다.
**Why this priority**: 편의 기능. 검색만으로도 대부분 커버됨.
**Independent Test**: 서로 다른 category의 스니펫이 있을 때, 특정 칩 선택 → 그 category 것만 남는지.
**Acceptance Scenarios**:
1. **Given** 여러 category의 스니펫, **When** "코드" 칩 선택, **Then** category가 "코드"인 것만 검색 대상이 된다.
2. **Given** 칩바에 포커스 없이 팔레트가 떠있음, **When** `Ctrl+←`/`Ctrl+→`, **Then** 선택 칩이 좌우로 바뀐다.
3. **Given** "전체" 칩, **When** 선택, **Then** 모든 category가 검색 대상이 된다.
---
### Edge Cases
- **붙여넣기 실패**(직전 창 포커스 복원 실패): 스니펫 내용을 클립보드에 남겨 사용자가 수동으로 붙일 수 있게 한다(기존 붙여넣기 배선의 fallback 준용).
- **검색 결과 0개**: 빈 상태 안내를 보여준다(에러 아님).
- **`Alt+숫자`가 결과 개수보다 큼**: 무시.
- **IME 조합 중 `Enter`**: 조합 확정만 되고 붙여넣기는 일어나지 않는다.
- **전역 `Ctrl+1` 충돌**: `Ctrl+1`은 전역이라 다른 앱의 `Ctrl+1`을 가로챈다. 감수하는 선택. 단 핫키는 한 곳(config 상수)에서 바꿀 수 있어야 한다.
- **로컬 저장 불가/용량 초과**: 저장 실패를 사용자에게 알리고, 최소한 이번 세션 동작은 막지 않는다.
- **스니펫 0개 상태에서 소환**: 빈 목록 + "스니펫을 만들어보라"는 안내를 보여준다.
- **첫 실행 시 003의 `snippets.db`가 없음**: 시드를 건너뛰고 빈 DB로 시작(에러 아님).
---
## Requirements *(mandatory)*
### Functional Requirements
**소환 · 표시**
- **FR-001**: 전역 핫키 `Ctrl+1`로 스니펫 팔레트를 소환할 수 있어야 한다. 이 핫키는 snap 소환(`Ctrl+Alt+Space`)과 별개다.
- **FR-002**: 소환 시, 팔레트가 뜨기 **직전에 활성이던 창**을 붙여넣기 대상으로 기억해야 한다.
- **FR-003**: 팔레트는 화면 가운데 떠서 검색 입력에 자동 포커스되며, snap과 **다른 경로**(`/snippet`)로 열린다.
- **FR-004**: 팔레트는 Raycast식 레이아웃을 따른다 — 상단 검색 입력, 아래 결과 리스트(행: 제목=name, 부제=desc, 우측 액세서리=번호/category 등), 우측 프리뷰 패널, 하단 액션 힌트바.
**검색 · 랭킹**
- **FR-005**: 검색어를 공백으로 나눈 키워드가 **전부(AND)** 스니펫의 `name`+`desc` 합친 문자열(대소문자 무시)에 포함될 때만 결과에 포함한다. `body`는 검색 대상이 아니다. 퍼지/오타 보정 없음.
- **FR-006**: 검색어가 비어있으면 (선택된 category의) 전체 스니펫을 보여준다.
- **FR-007**: 모든 결과는 usage 순 — 사용횟수 내림차순, 같으면 최근 사용시각 내림차순, 그것도 같으면 name 오름차순 — 으로 정렬한다.
- **FR-008**: 상위 9개 결과에 `1~9` 번호를 매겨 표시한다.
**붙여넣기 · usage**
- **FR-009**: `Enter`는 현재 선택 행을, `Alt+1~9`는 해당 번호 행을 (선택 이동 없이) 즉시 붙여넣는다.
- **FR-010**: 붙여넣기는 FR-002에서 기억한 대상 창에 스니펫 `body`**원문 그대로(공백·줄바꿈 보존)** 삽입한다. (기존 WebView2 붙여넣기 배선 재사용.)
- **FR-011**: 붙여넣기 성공 시 해당 스니펫의 사용횟수 +1, 최근 사용시각을 현재로 갱신하고, 팔레트를 숨긴다.
**키 조작**
- **FR-012**: `↑`/`↓`로 선택 이동, 이동 시 우측 프리뷰가 갱신된다.
- **FR-013**: `Esc`는 단계 처리 — 검색어가 있으면 비우고, 없으면 팔레트를 숨긴다.
- **FR-014**: 프리뷰는 선택 스니펫의 `body`를 읽기전용·고정폭으로, 원문 그대로 보여준다.
**관리(CRUD)**
- **FR-015**: 새 스니펫을 만들 수 있어야 하며, 생성 시 body 초기값은 현재 클립보드 내용, category 기본값은 "코드"다.
- **FR-016**: 저장은 name과 body가 모두 비어있지 않을 때만 허용한다. name은 정규화(대문자화, 공백→`_`)되어 **유일**해야 하며 중복은 거부한다.
- **FR-017**: 기존 스니펫을 편집(`F2`/더블클릭)할 수 있고, 이때 name은 읽기전용(=식별 키)이다.
- **FR-018**: 스니펫을 삭제(편집 다이얼로그 안, 확인 후)할 수 있다.
- **FR-019**: 생성/편집/삭제 후 팔레트 목록은 현재 검색어 기준으로 새로고침된다.
**분류**
- **FR-020**: category 칩("전체" + 존재하는 category들)으로 검색 대상을 한 분류로 좁힐 수 있다. `Ctrl+←`/`Ctrl+→`로 칩 이동.
**저장**
- **FR-021**: 스니펫과 usage 정보는 로컬에 영구 저장되어 앱을 재시작해도 유지된다.
- **FR-022**: 저장 접근은 하나의 데이터 인터페이스 뒤에 숨겨, 저장 방식을 바꿔도 화면·검색·랭킹 로직이 영향받지 않아야 한다.
- **FR-023**: 저장 스키마는 003의 것을 그대로 따른다 — 스니펫 테이블(name PK, desc, body, category)과 별도 usage 테이블(name PK, count, last_used).
- **FR-024**: 앱 데이터 폴더에 DB가 아직 없고 003의 `snippets.db`가 존재하면, 첫 실행 시 그 파일을 앱 데이터 폴더로 1회 복사해 시드한다. 없으면 빈 DB로 시작한다. 이후엔 두 파일이 독립이다.
### Key Entities
- **Snippet**: 붙여넣을 코드 조각 한 개.
- `name` — 식별 키(정규화·유일), 결과 행의 제목
- `desc` — 짧은 설명, 결과 행 부제 · 검색 대상
- `body` — 실제 붙여넣어지는 내용(원문 그대로 보존)
- `category` — 분류 라벨(기본 "코드")
- `usageCount` — 붙여넣기 성공 누적 횟수
- `lastUsed` — 마지막 붙여넣기 시각
- (003의 `change_word1/2`는 안 쓰는 유물이라 가져오지 않음)
---
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 핫키를 누른 뒤 원하는 스니펫을 붙여넣기까지 몇 초 안(핫키 → 검색어 몇 글자 → `Enter`/`Alt+숫자`)에 끝난다.
- **SC-002**: 자주 쓰는 스니펫이 검색·기본 목록 상위에 노출되어, 검색어 없이도 상위 몇 개 안에서 고를 수 있다.
- **SC-003**: 앱을 껐다 켜도 저장된 스니펷과 usage(횟수·최근시각)가 100% 유지된다.
- **SC-004**: 붙여넣어진 내용이 스니펫 body와 글자·공백·줄바꿈까지 완전히 일치한다.
- **SC-005**: 핫키 소환에서 팔레트가 보이기까지 체감상 즉시(수백 ms) 뜬다.
- **SC-006**: 붙여넣기 대상이 항상 "팔레트를 부르기 직전 창"이다(엉뚱한 창에 들어가지 않는다).
---
## Assumptions
- **저장은 v1에서 로컬 SQLite(단일 PC)**이며, C# 데스크톱 호스트가 DB 파일(`snippets.db`, 003과 동일 스키마)을 소유한다. 기기 간 동기화는 안 함. 데이터 인터페이스(FR-022)를 통해 나중에 백엔드로 교체할 여지만 남긴다.
- **첫 실행 시 003의 `snippets.db`를 앱 데이터 폴더로 1회 복사(시드)** 후 두 앱은 독립. 이후 003을 고쳐도 이 앱엔 영향 없음(그 반대도). 이건 파일 복사 한 번이라 별도 "임포트 기능"과 다름 — v1 범위 안.
- **붙여넣기·핫키·앞창 캡처는 기존 데스크톱 런처 배선을 재사용**한다(앞 창에 코드 붙여넣는 기능은 이미 있음). 신규 배선은 (1) 스니펫 전용 핫키 등록, (2) 소환 시 팔레트를 스니펫 경로로 이동시키는 신호, (3) React↔SQLite를 잇는 브릿지 데이터 메서드(요청→응답: 목록/생성/수정/삭제/사용기록)와 C# `SnippetRepository`(003 포팅)다.
- **팔레트 UI는 snap의 기존 커맨드 팔레트(키보드 네비게이션 등) 구조를 재사용**하되, 겉모습을 Raycast 결로 다시 입힌다.
- **003의 기존 `snippets.db` 데이터 임포트는 v1 범위 밖**(나중에 별도 임포트 수단으로).
- **백엔드 `/snippets` 동기화는 v1 범위 밖.**
- **전역 `Ctrl+1` 사용은 의도된 선택**이며, 다른 앱의 동일 단축키를 가로채는 점은 감수한다(핫키는 설정 한 곳에서 교체 가능).
+193
View File
@@ -0,0 +1,193 @@
---
description: "Task list for 스니펫 팔레트 (Snippet Palette)"
---
# Tasks: 스니펫 팔레트 (Snippet Palette)
**Input**: `specs/001-snippet-palette/` (plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md)
**Tests**: 포함(이 프로젝트는 superpowers TDD RED→GREEN + `npm run test`/`dotnet test` 통과가 완료 기준 — CLAUDE.md §4).
**Organization**: user story별 phase. US1(P1)만 해도 MVP(스니펫 있으면 검색→붙여넣기 됨).
## Format: `[ID] [P?] [Story] Description (파일경로)`
- **[P]**: 다른 파일·의존 없음 → 병렬 가능
- **[US#]**: 해당 user story
- 핸드오프: 여기서부터 superpowers 뒷단(`executing-plans`/`subagent-driven-development` + `test-driven-development`). 단계(phase) 하나 끝날 때마다 `specs/001-snippet-palette/stage-N.md` 기록.
---
## Phase 1: Setup (공용 기반)
- [ ] T001 `Microsoft.Data.Sqlite` 패키지 추가 (`3_windowsApp/CodeAssist.Shell/CodeAssist.Shell.csproj`)
- [ ] T002 [P] `PATHS.SNIPPET = "/snippet"` 추가 (`2_frontend/src/config/routes.ts`)
- [ ] T003 [P] feature 폴더 뼈대 + 타입 생성: `Snippet`/`SnippetInput` (`2_frontend/src/features/snippets/types.ts`, data-model.md 준용)
---
## Phase 2: Foundational (모든 story 선행 — 반드시 먼저)
**목표 체크포인트**: `Ctrl+1` → 팔레트가 `/snippet`(빈 화면)로 뜨고, `snippets.api.list()`가 시드된 데이터를 왕복으로 돌려준다.
- [ ] T004 `HotKeyService` 멀티 핫키로 확장 — `Register(int id, mods, vk)`·`HashSet<int>` 추적·`event Action<int> HotKeyPressed`·`ProcessMessage(int msg, IntPtr wParam)` (`3_windowsApp/CodeAssist.Shell/Platform/HotKeyService.cs`) + `App.WndProc`가 wParam 전달하도록 수정 (`3_windowsApp/CodeAssist.App/App.xaml.cs`)
- [ ] T005 `Ctrl+1`(id `0x1001`, `MOD_CONTROL|VK_1`) 등록 + 눌리면 `ShowPalette()` 후 navigate 신호 송신. 핫키 조합 상수는 한 곳에 모음 (`3_windowsApp/CodeAssist.App/App.xaml.cs`) — depends T004
- [ ] T006 `WebHostView``SendToWeb(object)`(=`PostWebMessageAsJson`) 추가 + `OnWebMessageReceived``snippets.*``SnippetBridge`로 위임 + `navigate` 푸시 경로 (`3_windowsApp/CodeAssist.App/Views/WebHostView.xaml.cs`) — depends T004/T005 (navigate 대상)
- [ ] T007 [P] `SnippetRepository` 뼈대 + 스키마 초기화(`CREATE TABLE snippets/usage`) + DB 경로 `%LOCALAPPDATA%\CodeAssist\snippets.db` (`3_windowsApp/CodeAssist.Shell/Storage/SnippetRepository.cs`, data-model.md 스키마)
- [ ] T008 [P] `SnippetSeed` — 첫 실행 시 대상 DB 없고 003 `snippets.db` 있으면 1회 복사, 없으면 빈 DB. App 시작 시 repo 사용 전 호출 (`3_windowsApp/CodeAssist.Shell/Storage/SnippetSeed.cs`) — 003 경로는 구현 시 확인(research R5)
- [ ] T009 [P] 프론트 브릿지 왕복 인프라 — `reqId` Map + `chrome.webview` 메시지 리스너(`snippets.result` 매칭 resolve/reject), 순수 브라우저면 비활성 (`2_frontend/src/lib/bridge/snippetBridge.ts`, contracts/bridge-messages.md)
- [ ] T010 [P] `bridgeNavigate.ts``navigate` 메시지 수신 → 등록된 react-router navigate 콜백 호출. 앱 최상위에서 mount 시 콜백 등록 (`2_frontend/src/lib/bridge/bridgeNavigate.ts` + 등록 지점)
- [ ] T011 `/snippet` 라우트 + 임시 placeholder 페이지를 `SnapLayout` 그룹에 추가 (`2_frontend/src/routes.tsx`) — depends T002
**Checkpoint**: Ctrl+1 → /snippet 빈 화면 뜨고 list 왕복 확인.
---
## Phase 3: User Story 1 - 핫키로 검색→앞 창 붙여넣기 (P1) 🎯 MVP
**Goal**: 스니펫 검색·프리뷰·usage랭킹 + Enter/Alt로 앞 창에 붙여넣기.
**Independent Test**: 시드된 스니펫 상태에서 에디터 포커스 → Ctrl+1 → 검색 → Enter → 에디터에 body 원문 삽입 + usage 상승.
### Tests (RED 먼저)
- [ ] T012 [P] [US1] `core/search` 단위테스트 — 키워드 AND, name+desc 대문자, body 제외, 빈쿼리 (`2_frontend/src/features/snippets/core/search.test.ts`)
- [ ] T013 [P] [US1] `core/ranking` 단위테스트 — `(-count,-lastUsed,name)` 정렬 (`2_frontend/src/features/snippets/core/ranking.test.ts`)
- [ ] T014 [P] [US1] `SnippetRepository.List/RecordUse` xUnit (temp sqlite, usage 조인·upsert) (`3_windowsApp/CodeAssist.Tests/SnippetRepositoryTests.cs`)
- [ ] T015 [P] [US1] `snippets.api` list/recordUse — 브릿지 mock 테스트 (`2_frontend/src/features/snippets/api/snippets.api.test.ts`)
### Impl
- [ ] T016 [P] [US1] `core/search.ts` (검색 순수함수, category 파라미터 포함) (`2_frontend/src/features/snippets/core/search.ts`)
- [ ] T017 [P] [US1] `core/ranking.ts` (usage 정렬) (`2_frontend/src/features/snippets/core/ranking.ts`)
- [ ] T018 [US1] `SnippetRepository.List`(snippets⨝usage) + `RecordUse`(count+1,last_used=now) (`3_windowsApp/CodeAssist.Shell/Storage/SnippetRepository.cs`)
- [ ] T019 [US1] `SnippetBridge` 핸들러 `snippets.list`·`snippets.recordUse` → repo 호출 후 `snippets.result` 응답 (`3_windowsApp/CodeAssist.App/Views/WebHostView.xaml.cs` 또는 신규 SnippetBridge.cs)
- [ ] T020 [US1] `snippets.api.ts`(list, recordUse) + `useSnippets`/`useRecordUse` (SNIPPETS_KEY·invalidate, memos 패턴) (`2_frontend/src/features/snippets/api/snippets.api.ts`, `hooks/useSnippets.ts`)
- [ ] T021 [US1] `SnippetPalettePage` — snap `SessionListPage` 복제 → 검색입력 + 결과리스트 + 우측 프리뷰, Raycast 룩 리스킨 (`2_frontend/src/features/snippets/pages/SnippetPalettePage.tsx`)
- [ ] T022 [US1] 키 조작 — ↑↓ nav(프리뷰 갱신), Enter→`pasteToApp(body)`+`recordUse`+`hideWindow`, Alt+1~9 즉시 붙여넣기, Esc 단계(비움→숨김), IME 안전 (`SnippetPalettePage.tsx`)
- [ ] T023 [P] [US1] `SnippetRow`(제목/부제/우측 번호 액세서리) + `PreviewPane`(`<pre>` 고정폭·원문보존, react-markdown 안 씀) Raycast 결 (`2_frontend/src/features/snippets/components/`)
- [ ] T024 [US1] 빈 상태 안내 + 상위 9개 `1~9` 번호 표시 (`SnippetPalettePage.tsx`)
**Checkpoint**: US1 단독으로 완전 동작(MVP). **STOP & VALIDATE** — quickstart 1~6,10,11.
---
## Phase 4: User Story 2 - 생성/편집/삭제 (P2)
**Goal**: 팔레트에서 스니펫 CRUD. 생성 시 body는 클립보드로 프리필.
**Independent Test**: 생성→검색에 뜸 / 빈 이름·내용 거부 / 중복 이름 거부 / F2 편집(이름 읽기전용) / 편집창 삭제.
### Tests (RED 먼저)
- [ ] T025 [P] [US2] `SnippetRepository.Create/Update/Delete` xUnit — 중복 거부·정규화·검증 (`3_windowsApp/CodeAssist.Tests/SnippetRepositoryTests.cs`)
- [ ] T026 [P] [US2] `snippets.api` create/update/remove — 브릿지 mock(성공/`ok:false` reject) (`2_frontend/src/features/snippets/api/snippets.api.test.ts`)
### Impl
- [ ] T027 [US2] `SnippetRepository.Create/Update/Delete` — name 정규화·유일성·body 비어있지않음(위반 시 에러) (`3_windowsApp/CodeAssist.Shell/Storage/SnippetRepository.cs`)
- [ ] T028 [US2] `SnippetBridge` 핸들러 create/update/delete (`ok:false`+한글 에러 계약) (`WebHostView.xaml.cs`/SnippetBridge.cs)
- [ ] T029 [US2] `snippets.api` create/update/remove + `useCreateSnippet`/`useUpdateSnippet`/`useDeleteSnippet`(invalidate + sonner 한글 토스트, ApiError 형태) (`api/snippets.api.ts`, `hooks/useSnippets.ts`)
- [ ] T030 [US2] `EditDialog` — 생성/편집 공용, 편집 시 name 읽기전용, 생성 시 body=클립보드(`navigator.clipboard.readText()`, 실패 시 빈값), 검증 (`2_frontend/src/features/snippets/components/EditDialog.tsx`)
- [ ] T031 [US2] 팔레트 배선 — 생성 액션(Ctrl+N/버튼), F2·더블클릭 편집, 다이얼로그 삭제+확인, 완료 후 목록 새로고침 (`SnippetPalettePage.tsx`)
**Checkpoint**: US1+US2 동작. quickstart 7~8.
---
## Phase 5: User Story 3 - category 칩 필터 (P3)
**Goal**: category 칩으로 분류별 필터.
**Independent Test**: 다른 category 스니펫 있을 때 칩 선택 → 그 분류만.
### Tests (RED 먼저)
- [x] T032 [P] [US3] `core/search` category 필터 케이스 추가 (`core/search.test.ts`)
### Impl
- [x] T033 [US3] category 필터 확정(`core/search.ts` 파라미터 활용) + 목록에서 존재하는 category 도출 (`core/search.ts`, `SnippetPalettePage.tsx`)
- [x] T034 [US3] `CategoryChips` — "전체" + categories, `Ctrl+←/→` 칩 이동, 페이지 배선 (`2_frontend/src/features/snippets/components/CategoryChips.tsx`, `SnippetPalettePage.tsx`)
**Checkpoint**: 전 story 동작. quickstart 9.
---
## Phase 6: Polish & 검증
- [ ] T035 `npm run format``npm run lint` + `npm run test` + `npm run build` 통과 + `dotnet test 3_windowsApp/CodeAssist.Tests` 통과
- [ ] T036 quickstart.md 수동 시나리오 1~11 전체 확인(붙여넣기 대상 정확성·영속·시드)
- [ ] T037 [P] `specs/001-snippet-palette/report.md`(계획 vs 결과) 작성 → 최종 승인용
---
## Dependencies & Execution Order
- **Phase 1 Setup** → **Phase 2 Foundational**(전 story 차단) → **US1 → US2 → US3****Polish**.
- Foundational 안: T004→T005→T006 순(같은 C# App/WebHostView 계열). T007/T008/T009/T010은 서로 [P]. T011은 T002 이후.
- US 간: US1은 read/paste, US2는 write(같은 repo/api/page 파일 확장이라 US1 뒤가 안전 — 병렬 시 파일 충돌 주의), US3은 category(대부분 독립).
- Story 내: 테스트(RED) → 순수함수/모델 → repo/bridge → api/hooks → 페이지/컴포넌트.
## Parallel Opportunities
- Setup: T002·T003 [P].
- Foundational: T007·T008·T009·T010 [P](C# 저장소 vs 프론트 브릿지 서로 다른 파일).
- US1 테스트: T012·T013·T014·T015 [P]. US1 순수함수: T016·T017 [P]. 컴포넌트 T023 [P].
- **주의**: `SnippetRepository.cs`·`snippets.api.ts`·`SnippetPalettePage.tsx`는 여러 태스크가 손대므로 같은 story 안에선 [P] 안 붙임(파일 충돌).
## Implementation Strategy
1. Setup + Foundational → "Ctrl+1로 /snippet 뜨고 데이터 왕복" 체크포인트.
2. **US1(MVP)** → 독립 검증(붙여넣기 되면 도구로서 완성) → 데모.
3. US2(CRUD) → US3(category) 순 증분.
4. 각 단계 끝 `stage-N.md`, 완료 전 `superpowers:verification-before-completion` + `/code-review`, 최종 `report.md` → 승인 → merge(`/cmp`).
## Notes
- [P] = 다른 파일·무의존. [US#] = 추적용.
- 정당 편차(plan §Constitution): 데이터는 `@/lib/api/client` 아닌 브릿지, `Snippet` 타입은 feature-local.
- 붙여넣기·핫키·앞창캡처·팔레트 nav는 **기존 재사용** — 새로 만들지 말 것.
## 2026-09-11: 단계별 런처와 실제 창 크기
`spec.md`의 2026-09-11 사용자 승인 범위만 완료함. 위 초기 .NET 이식·CRUD·사용기록의 미완료 항목을 완료로 바꾸지 않음.
- [x] 검색창만 시작하고 검색 후 결과를 표시. 미선택 Enter는 아무것도 복사하지 않음.
- [x] 화살표·클릭으로 직접 선택할 때만 프리뷰를 열고 검색·분류 변경 시 선택 해제.
- [x] 결과 행을 간결하게 바꾸고 하단 키 안내 추가. 기존 코드뷰어·분할 크기 조절 유지.
- [x] Tauri `window_snippet_layout`으로 검색 640×84 / 결과 640×440 / 프리뷰·편집 960×600 적용. DPI·작업 영역·창 테두리 보정.
- [x] 챗봇 복귀·최대화 복원·종료 저장에서 원래 배치 보존. CloseRequested 캐시 순서 문제를 실제로 재현하고 명시적 저장으로 수정.
- [x] Esc 포커스 복원과 재소환 시 삭제 확인창 닫힘을 실패하는 회귀 검사로 재현 후 수정.
- [x] `npm run format`, lint(오류 0·기존 경고 9), 프론트 46파일·259검사, build 통과. Rust fmt·6검사 통과·로컬 DB 전용 1검사 기본 제외, clippy `-D warnings` 통과.
- [x] 실제 저장된 스니펫 SELECT 검색 7개, 키보드 선택·코드 프리뷰·빈 결과·편집·재소환을 Tauri에서 확인. 데이터 저장·삭제는 실행하지 않음.
- [x] 실제 Windows 크기 전환·화면 우하단 확장·최대화 전 일반 배치 복원·축소 종료 후 960×680 재시작 확인.
## 2026-09-11: 코드 프리뷰 군더더기 제거
- [x] 프리뷰 이름·설명·분류, 장식 점과 코드 헤더, 박스 테두리·그림자를 제거. 문법 강조와 줄 번호 유지.
- [x] `CodeBlock`의 plain 표시를 재사용해 프리뷰 안쪽 스크롤을 하나로 합침. 챗봇 카드의 기본 모양은 유지.
- [x] 기존 복사·붙여넣기 버튼을 `CodeActions`로 공유하고 스니펫 하단에 배치. 버튼 Enter를 전역 즉시 복사가 가로채지 않도록 수정.
- [x] 실제 Tauri 프리뷰의 헤더·버튼 0개, 세로 스크롤 1개, 하단 액션 2개와 문법 강조를 확인. 클릭·키보드 복사 원문과 창 유지 회귀 검사 통과.
- [x] format·lint(오류 0, 기존 경고 9)·46파일 260검사·build 통과.
### 별도 관찰
- [ ] 최소화된 앱을 검사 도중 종료한 뒤 `/snap`이 640×84로 시작하는 현상을 관찰함. 최소화 종료 경로의 배치 저장은 별도로 재현·확인할 것. 이번 프리뷰 UI 변경에는 Rust 수정을 포함하지 않음.
## 2026-09-11: 프리뷰 전체 창 축소
- [x] 사용자 추가 요청에 따라 코드 프리뷰만 960×600에서 840×520으로 축소. 검색 640×84·결과 640×440·편집 960×600은 유지.
- [x] 실제 Tauri에서 840×520 화면, 하단 액션 표시, 세로 스크롤 1개를 확인. F2 편집 960×600 → 닫기 후 프리뷰 840×520 복귀 확인.
- [x] Rust fmt 검사와 6개 검사 통과. 프론트는 직전 UI 변경 검증 46파일·260개 검사 및 build 통과 상태를 유지함.
## 2026-09-11: Ctrl+Enter 앱에 붙여넣기
- [x] 기존 `pasteToApp`을 Ctrl+Enter에서도 사용. 일반 Enter·복사 버튼 동작은 유지하고, 미선택·IME·편집·브라우저·반복키·추가 modifier는 차단.
- [x] 네이티브 붙여넣기를 비동기 작업으로 실행. 키 해제는 최대 2초 대기하고, 중복 요청·대상 HWND/PID/thread 변경·포커스 이탈·재입력은 취소.
- [x] `SetForegroundWindow` 직후 정상 활성화를 실패로 오판하는 경합을 실제로 재현. 80ms 구간에서 첫 활성화 전 팔레트·NULL만 기다리고, 대상 활성화 이후 이탈은 취소하도록 수정.
- [x] `SendInput` 전송 개수를 확인하고 성공 때만 창 숨김. 실패하면 창과 오류 안내 유지, 이미 복사된 원문은 클립보드에 보존.
- [x] 저장하지 않는 별도 Windows 입력창에서 실제 Ctrl+Enter 누름·반복·해제 → 1,420자 원문 1회 입력·창 숨김 확인. 기존 붙여넣기 버튼도 같은 원문 입력 확인.
- [x] 실제 네이티브 입력으로 한글·CRLF·탭·앞뒤 공백 보존 확인. 대상 없음·닫힌 대상·중복 요청·2초 키 대기 초과·대기 중 포커스 이탈에서 입력 취소·오류·클립보드 보존 확인.
- [x] 프론트 format·lint(오류 0, 기존 경고 9)·46파일 264검사·build 통과. Rust fmt·6검사(로컬 DB 검사 1개 기본 제외)·clippy `-D warnings` 통과.
- 검증 범위: 별도 WinForms 입력창을 사용했고 사용자 문서·원본 스니펫 DB를 바꾸지 않음. VS Code·SAP·관리자 권한 앱 각각의 실제 수신 검사는 별도이며, 키 전송 성공이 모든 앱의 붙여넣기 수신을 보장하지는 않음.