diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..38938b3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: ci + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + # 타입·린트·테스트에는 electron 바이너리도 husky 훅도 필요 없다. + # 타입 선언은 electron 패키지에 들어 있어 postinstall 없이도 받는다 + - name: 의존성 설치 + run: npm ci --ignore-scripts + + - name: 타입 검사 + run: npm run typecheck + + - name: 린트 + run: npm run lint + + - name: 테스트 + run: npm test diff --git a/README.en.md b/README.en.md index 586c363..0d1d5e6 100644 --- a/README.en.md +++ b/README.en.md @@ -29,7 +29,8 @@ Everything happens on your local file system. The only network request is the up ## Features - **📜 Read conversations** — Your input renders as terminal prompts, responses as typeset markdown (tables, syntax highlighting). Tool calls, thinking blocks, and system messages stay collapsed so the flow of the conversation is never interrupted. -- **🔍 Browse sessions** — Sessions grouped by project, with titles, last activity, and message counts. Search everything with `⌘F`. +- **🔍 Browse sessions** — Sessions grouped by project, with titles, last activity, and message counts. Narrow the list with `⌘F` by title, first message, or folder name. +- **🔦 Search message contents** — The search field up top (`⌘K`) searches what was actually said, across every project. One half-remembered word finds the session and the message, and picking a result jumps straight to it. - **⏩ Resume in terminal** — Runs `claude --resume` in the session's original working directory. - **🔱 Open as fork** — Branches off with `--fork-session`, preserving the original. - **🖥 Terminal of your choice** — Defaults to the OS terminal; pick iTerm2 or any detected terminal in Settings. @@ -77,12 +78,15 @@ npm run build:linux # Linux (AppImage, deb) ## Usage +> `⌘` means `Ctrl` on Windows and Linux. The app shows whichever applies to your platform. + | Action | How | |------|------| | Read a session | Expand a project in the sidebar → click a session | | Expand details | Click a tool call (`▸ Bash …`) or a `Thinking` row | | Resume / Fork | **Resume in terminal** / **Open as fork** buttons in the header | -| Search | `⌘F` — matches session titles and first prompts | +| Narrow the list | `⌘F` — matches session titles, first prompts, and folder names | +| Search contents | `⌘K` — search field up top; `↑↓` to move, `↵` to open | | Settings | `⌘,` or the ⚙ button — language, terminal, text size, updates | | Delete | **Delete** button → confirm → moved to Trash | @@ -90,8 +94,9 @@ npm run build:linux # Linux (AppImage, deb) - Reads: `~/.claude/projects//.jsonl` - Viewing is strictly read-only. Original files are never modified. -- The only write operation is deletion you explicitly request — and even that moves files to the Trash. +- The only write to your original files is deletion you explicitly request — and even that moves files to the Trash. - Settings are stored in the standard app data path (`userData/settings.json`). +- The content-search index lives at `userData/search-index.jsonl`. It holds only your prompts and the assistant's replies — never tool input or output — and never leaves your machine. Delete it and it rebuilds on the next launch. ## Known limitations diff --git a/README.md b/README.md index 5e1b8f0..9692075 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ Claude Code가 로컬에 남긴 대화의 항적을 따라 — 읽고, 이어가 ## 주요 기능 - **📜 대화 열람** — 사용자 입력은 터미널 프롬프트 형태로, 응답은 마크다운(표·코드 하이라이트 포함)으로 조판해 표시합니다. 도구 호출·사고 과정·시스템 메시지는 접힌 상태로 정리되어 대화의 흐름을 방해하지 않습니다. -- **🔍 세션 탐색** — 프로젝트별로 정리된 세션 목록과 `⌘F` 검색. 세션 제목·마지막 활동·메시지 수를 한눈에 봅니다. +- **🔍 세션 탐색** — 프로젝트별로 정리된 세션 목록과 `⌘F` 필터. 세션 제목·첫 메시지·폴더 이름으로 목록을 좁힙니다. +- **🔦 대화 내용 검색** — 상단 검색창(`⌘K`)에서 모든 프로젝트의 대화 본문을 찾습니다. 기억나는 단어 하나로 어느 세션의 어느 메시지였는지 짚어내고, 선택하면 그 메시지로 바로 이동합니다. - **⏩ 터미널에서 이어가기** — 세션의 원래 작업 디렉토리에서 `claude --resume`을 실행해 대화를 이어갑니다. - **🔱 Fork로 열기** — `--fork-session`으로 원본을 보존한 채 새 세션으로 분기합니다. - **🖥 터미널 선택** — 기본값은 OS 기본 터미널. 설정에서 iTerm2 등 감지된 터미널을 선택할 수 있습니다. @@ -76,12 +77,15 @@ npm run build:linux # Linux (AppImage, deb) ## 사용법 +> 단축키의 `⌘` 는 Windows·Linux에서 `Ctrl` 입니다. 앱 화면에도 플랫폼에 맞는 표기가 나옵니다. + | 동작 | 방법 | |------|------| | 세션 열람 | 사이드바에서 프로젝트 펼침 → 세션 클릭 | | 상세 펼치기 | 본문의 도구 호출(`▸ Bash …`)·`사고 과정` 줄 클릭 | | 이어가기 / Fork | 상단 **터미널에서 이어가기** / **Fork로 열기** 버튼 | -| 검색 | `⌘F` — 세션 제목·첫 메시지 대상 | +| 세션 목록 좁히기 | `⌘F` — 세션 제목·첫 메시지·폴더 이름 대상 | +| 대화 내용 검색 | `⌘K` — 상단 검색창. 모든 세션의 대화 본문 대상, `↑↓` 이동 · `↵` 열기 | | 설정 | `⌘,` 또는 사이드바 ⚙ — 언어·터미널·글자 크기·업데이트 | | 삭제 | **삭제** 버튼 → 확인 후 휴지통 이동 | @@ -89,8 +93,9 @@ npm run build:linux # Linux (AppImage, deb) - 읽기 대상: `~/.claude/projects/<프로젝트>/.jsonl` - 대화 열람은 전 과정이 읽기 전용입니다. 원본 파일을 수정하지 않습니다. -- 유일한 쓰기 동작은 사용자가 명시적으로 요청한 삭제이며, 이 또한 파일을 휴지통으로 옮기는 방식으로 동작합니다. +- 원본 파일에 가하는 유일한 쓰기 동작은 사용자가 명시적으로 요청한 삭제이며, 이 또한 파일을 휴지통으로 옮기는 방식으로 동작합니다. - 설정은 OS 표준 앱 데이터 경로(`userData/settings.json`)에 저장됩니다. +- 대화 내용 검색용 색인은 `userData/search-index.jsonl`에 저장됩니다. 사용자 발화와 응답 텍스트만 담고 도구 입출력은 담지 않으며, 기기 밖으로 나가지 않습니다. 파일을 지우면 다음 실행 때 다시 만들어집니다. ## 알려진 제한 diff --git a/package-lock.json b/package-lock.json index 861bd59..2bec28a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wake", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wake", - "version": "0.6.0", + "version": "0.7.0", "hasInstallScript": true, "dependencies": { "@electron-toolkit/preload": "^3.0.2", @@ -39,7 +39,8 @@ "react": "^19.2.1", "react-dom": "^19.2.1", "typescript": "^5.9.3", - "vite": "^7.2.6" + "vite": "^7.2.6", + "vitest": "^4.1.10" } }, "node_modules/@babel/code-frame": { @@ -2652,6 +2653,13 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -2721,6 +2729,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2730,6 +2749,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3137,6 +3163,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -3576,6 +3715,16 @@ "node": ">=12.0.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -3990,6 +4139,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5187,6 +5346,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -5683,6 +5849,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5693,6 +5869,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -8796,6 +8982,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -8976,6 +9176,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -9972,6 +10179,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -10053,6 +10267,13 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -10063,6 +10284,13 @@ "node": ">= 6" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -10430,6 +10658,13 @@ "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -10457,6 +10692,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -11508,6 +11753,96 @@ "@esbuild/win32-x64": "0.28.1" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/webcrypto-core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", @@ -11627,6 +11962,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index c9657b1..9cd7701 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wake", - "version": "0.6.0", + "version": "0.7.0", "description": "Wake — trace the wake of your Claude Code sessions. A local conversation history viewer.", "main": "./out/main/index.js", "author": "followingseas", @@ -11,6 +11,7 @@ "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck": "npm run typecheck:node && npm run typecheck:web", + "test": "vitest run", "start": "electron-vite preview", "dev": "electron-vite dev", "build": "npm run typecheck && electron-vite build", @@ -52,6 +53,7 @@ "react": "^19.2.1", "react-dom": "^19.2.1", "typescript": "^5.9.3", - "vite": "^7.2.6" + "vite": "^7.2.6", + "vitest": "^4.1.10" } } diff --git a/src/main/index.ts b/src/main/index.ts index 9a04859..a135047 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,7 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' import { listProjects, listSessions } from './lib/scanner' import { parseConversation } from './lib/parser' +import { initSearchIndex, searchSessions } from './lib/searchIndex' import { deleteSession, forkSession, resumeSession, revealSession } from './lib/actions' import { loadSettings, saveSettings } from './lib/settings' import { listTerminals } from './lib/terminals' @@ -25,6 +26,12 @@ function registerIpcHandlers(): void { ipcMain.handle('projects:list', () => listProjects()) ipcMain.handle('sessions:list', (_event, projectId: string) => listSessions(projectId)) ipcMain.handle('conversation:load', (_event, filePath: string) => parseConversation(filePath)) + ipcMain.handle('search:query', (_event, query: string) => { + // 프리로드가 문자열만 넘기므로 여기 오면 버그다. 빈 질의로 눌러 "결과 없음"으로 + // 위장하지 않고 렌더러까지 실패를 올려보낸다 + if (typeof query !== 'string') throw new TypeError('search:query expects a string') + return searchSessions(query) + }) ipcMain.handle('session:resume', (_event, sessionId: string, cwd: string | null) => resumeSession(sessionId, cwd) ) @@ -130,6 +137,7 @@ function createWindow(): void { }) initAutoUpdate(mainWindow) + initSearchIndex(mainWindow) mainWindow.on('ready-to-show', () => { mainWindow.show() diff --git a/src/main/lib/searchExtract.test.ts b/src/main/lib/searchExtract.test.ts new file mode 100644 index 0000000..38d627f --- /dev/null +++ b/src/main/lib/searchExtract.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import type { Conversation, ConversationItem } from '../../shared/types' +import { extractSearchMessages } from './searchExtract' + +function conversation(items: ConversationItem[]): Conversation { + return { sessionId: 'session-1', items, sidechainCount: 0, hiddenCount: 0 } +} + +describe('extractSearchMessages', () => { + it('사용자 발화와 어시스턴트 텍스트를 uuid와 함께 뽑는다', () => { + const result = extractSearchMessages( + conversation([ + { + kind: 'user', + uuid: 'u1', + timestamp: null, + text: '검색 기능 만들자', + images: [], + meta: null + }, + { + kind: 'assistant', + uuid: 'a1', + timestamp: null, + blocks: [{ type: 'text', text: '인덱스를 먼저 만들죠' }] + } + ]) + ) + expect(result).toEqual([ + { ref: 'u1', role: 'user', text: '검색 기능 만들자' }, + { ref: 'a1', role: 'assistant', text: '인덱스를 먼저 만들죠' } + ]) + }) + + it('meta가 붙은 사용자 아이템은 본문이 남아 있어도 제외한다', () => { + // meta 아이템에도 text는 실린다(슬래시 커맨드 원문·주입 컨텍스트 등). 그래서 빈 + // text로 검사하면 meta 분기를 text 공백 검사로 바꿔치기해도 테스트가 통과한다 — + // 걸러지는 기준이 meta라는 것을 확인하려면 본문이 있는 meta 아이템이 필요하다. + const result = extractSearchMessages( + conversation([ + { + kind: 'user', + uuid: 'u1', + timestamp: null, + text: '/model 을 opus 로 바꿔줘', + images: [], + meta: { kind: 'command', label: 'model', detail: '출력' } + }, + { + kind: 'user', + uuid: 'u2', + timestamp: null, + text: '이건 진짜 발화', + images: [], + meta: null + } + ]) + ) + expect(result).toEqual([{ ref: 'u2', role: 'user', text: '이건 진짜 발화' }]) + }) + + it('사고 과정과 도구 호출은 제외하고 텍스트 블록만 이어 붙인다', () => { + const result = extractSearchMessages( + conversation([ + { + kind: 'assistant', + uuid: 'a1', + timestamp: null, + blocks: [ + { type: 'thinking', text: '내부 독백' }, + { type: 'text', text: '첫 문단' }, + { + type: 'toolCall', + id: 't1', + name: 'Read', + input: null, + result: '파일 내용', + isError: false + }, + { type: 'text', text: '둘째 문단' } + ] + } + ]) + ) + expect(result).toEqual([{ ref: 'a1', role: 'assistant', text: '첫 문단\n둘째 문단' }]) + }) + + it('공백뿐인 아이템은 버린다', () => { + const result = extractSearchMessages( + conversation([ + { kind: 'user', uuid: 'u1', timestamp: null, text: ' \n ', images: [], meta: null }, + { kind: 'assistant', uuid: 'a1', timestamp: null, blocks: [] } + ]) + ) + expect(result).toEqual([]) + }) + + it('텍스트 블록이 있어도 내용이 공백뿐이면 어시스턴트 아이템을 버린다', () => { + // blocks가 비어 있는 경우와 다른 분기다 — texts는 채워지고 join 뒤 trim에서 비게 + // 된다. 이 아이템이 통과하면 검색 결과에 빈 스니펫 행이 생긴다. + const result = extractSearchMessages( + conversation([ + { + kind: 'assistant', + uuid: 'a1', + timestamp: null, + blocks: [ + { type: 'thinking', text: '내부 독백' }, + { type: 'text', text: ' \n ' } + ] + } + ]) + ) + expect(result).toEqual([]) + }) + + it('일부 텍스트 블록만 공백이면 나머지 블록은 살린다', () => { + // 공백 블록 하나 때문에 아이템 전체를 버리거나, 블록별 trim으로 "정리"하면서 + // 뒤 문단을 잃어버리는 회귀를 잡는다. + const result = extractSearchMessages( + conversation([ + { + kind: 'assistant', + uuid: 'a1', + timestamp: null, + blocks: [ + { type: 'text', text: '첫 문단' }, + { type: 'text', text: ' ' }, + { type: 'text', text: '둘째 문단' } + ] + } + ]) + ) + expect(result).toHaveLength(1) + expect(result[0].text).toContain('첫 문단') + expect(result[0].text).toContain('둘째 문단') + }) +}) diff --git a/src/main/lib/searchExtract.ts b/src/main/lib/searchExtract.ts new file mode 100644 index 0000000..58426f9 --- /dev/null +++ b/src/main/lib/searchExtract.ts @@ -0,0 +1,39 @@ +import type { Conversation } from '../../shared/types' + +/** 이 모양 그대로 검색 인덱스에 저장된다 — 필드가 바뀌면 INDEX_VERSION을 올릴 것 */ +export interface SearchMessage { + /** 매칭된 ConversationItem의 uuid — 검색 결과에서 이 아이템으로 이동하는 데 쓴다 */ + ref: string + role: 'user' | 'assistant' + text: string +} + +/** + * 대화에서 검색 대상 텍스트만 뽑는다. + * + * 도구 호출·결과는 사용자가 기억하는 문장이 아니면서 분량으로 결과를 압도하고, 사고 + * 과정은 모델 내부 독백이라 뺀다. meta가 붙은 사용자 아이템 중 본문이 실려 오는 건 + * 커맨드 출력뿐이고 그것도 사용자 발화가 아니다. 다만 이 규칙 때문에 bashRun의 명령어와 + * compact의 이월 요약은 화면에 보이면서 검색에는 잡히지 않는다. + * + * ref는 반드시 파서 출력의 uuid여야 한다. 파서가 연속 assistant 엔트리를 한 아이템으로 + * 병합하므로, JSONL을 직접 읽어 엔트리 uuid를 쓰면 화면에 없는 아이템을 가리킨다. + */ +export function extractSearchMessages(conversation: Conversation): SearchMessage[] { + const messages: SearchMessage[] = [] + for (const item of conversation.items) { + if (item.kind === 'user') { + if (item.meta !== null) continue + const text = item.text.trim() + if (text) messages.push({ ref: item.uuid, role: 'user', text }) + continue + } + const texts: string[] = [] + for (const block of item.blocks) { + if (block.type === 'text') texts.push(block.text) + } + const text = texts.join('\n').trim() + if (text) messages.push({ ref: item.uuid, role: 'assistant', text }) + } + return messages +} diff --git a/src/main/lib/searchIndex.ts b/src/main/lib/searchIndex.ts new file mode 100644 index 0000000..945c087 --- /dev/null +++ b/src/main/lib/searchIndex.ts @@ -0,0 +1,276 @@ +import { mkdir, readdir, readFile, rename, writeFile } from 'fs/promises' +import { dirname, join } from 'path' +import { app, type BrowserWindow } from 'electron' +import type { SearchHit, SearchProgress, SearchResults, SessionMeta } from '../../shared/types' +import { listSessions, projectsRoot } from './scanner' +import { parseConversation } from './parser' +import { extractSearchMessages, type SearchMessage } from './searchExtract' +import { + foldCase, + indexMessages, + isDocumentFresh, + matchDocument, + type SearchDocument +} from './searchMatch' + +/** + * 저장 형식이 바뀌면 올린다 — 값이 다르면 저장된 인덱스를 버리고 다시 만든다. + * StoredDocument의 필드뿐 아니라 parser.ts의 아이템 경계나 uuid 부여 방식이 바뀔 때도 + * 올려야 한다. ref가 파서 출력의 uuid라서, 그대로 두면 저장된 ref가 오류 없이 엉뚱한 + * 메시지를 가리킨다. + */ +const INDEX_VERSION = 1 +const MAX_SNIPPETS_PER_SESSION = 5 +const MAX_HITS = 200 +/** 검색이 들어올 때마다 전체 정합을 돌리지 않도록 두는 최소 간격 */ +const RECONCILE_INTERVAL_MS = 5000 +/** 첫 화면 로딩과 CPU를 다투지 않도록 인덱싱 시작을 미루는 시간 */ +const INDEX_START_DELAY_MS = 1500 + +/** 디스크에 저장하는 형태 — 소문자 사본은 적재할 때 다시 만든다 */ +type StoredDocument = Omit & { messages: SearchMessage[] } + +const documents = new Map() +let ready = false +let failed = false +let revision = 0 +let progress: SearchProgress = { done: 0, total: 0, ready: false, revision: 0, failed: false } +let running: Promise | null = null +let lastReconcileAt = 0 +let progressWindow: BrowserWindow | null = null + +function indexPath(): string { + return join(app.getPath('userData'), 'search-index.jsonl') +} + +function emitProgress(done: number, total: number): void { + progress = { done, total, ready, revision, failed } + if (progressWindow && !progressWindow.isDestroyed()) { + progressWindow.webContents.send('search:progress', progress) + } +} + +function isStoredMessage(value: unknown): value is SearchMessage { + const message = value as SearchMessage | null + return ( + !!message && + typeof message.ref === 'string' && + typeof message.text === 'string' && + (message.role === 'user' || message.role === 'assistant') + ) +} + +function isStoredDocument(value: unknown): value is StoredDocument { + const stored = value as StoredDocument | null + return ( + !!stored && + typeof stored.sessionId === 'string' && + typeof stored.projectId === 'string' && + typeof stored.filePath === 'string' && + typeof stored.title === 'string' && + typeof stored.updatedAt === 'number' && + typeof stored.fileSize === 'number' && + Array.isArray(stored.messages) && + stored.messages.every(isStoredMessage) + ) +} + +async function loadFromDisk(): Promise { + let raw: string + try { + raw = await readFile(indexPath(), 'utf8') + } catch (error) { + // 첫 실행이면 파일이 없는 게 정상이다. 다른 이유라면 매 실행 전체 재인덱싱으로 + // 이어지므로 흔적을 남긴다 + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[search] 저장된 인덱스를 읽지 못했다', error) + } + return + } + + const lines = raw.split('\n') + let version: unknown + try { + version = (JSON.parse(lines[0]) as { v?: unknown }).v + } catch { + version = null + } + if (version !== INDEX_VERSION) return + + for (const line of lines.slice(1)) { + if (!line.trim()) continue + let stored: unknown + try { + stored = JSON.parse(line) + } catch { + // 줄 하나가 깨져도 나머지는 살린다. JSON.parse만 감싼다 — 아래 변환에서 나는 + // 예외는 데이터가 아니라 이쪽 코드의 문제다 + continue + } + if (!isStoredDocument(stored)) continue + documents.set(stored.filePath, { ...stored, messages: indexMessages(stored.messages) }) + } +} + +async function persist(): Promise { + const path = indexPath() + const temporary = `${path}.tmp` + const lines = [JSON.stringify({ v: INDEX_VERSION })] + for (const document of documents.values()) { + const stored: StoredDocument = { + ...document, + messages: document.messages.map(({ ref, role, text }) => ({ ref, role, text })) + } + lines.push(JSON.stringify(stored)) + } + await mkdir(dirname(path), { recursive: true }) + // 부분 기록된 인덱스를 남기지 않도록 임시 파일에 쓰고 원자적으로 바꿔 끼운다 + await writeFile(temporary, lines.join('\n')) + await rename(temporary, path) +} + +/** null이면 루트를 읽지 못한 것이다 — 빈 배열("프로젝트가 없다")과 섞으면 정리 단계가 인덱스를 통째로 비운다 */ +async function listProjectIds(): Promise { + try { + const dirents = await readdir(projectsRoot(), { withFileTypes: true }) + return dirents.filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name) + } catch (error) { + // Claude Code를 아직 쓴 적이 없으면 루트가 없는 게 정상이다 + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + console.error('[search] 프로젝트 루트를 읽지 못했다', error) + return null + } +} + +async function buildDocument(meta: SessionMeta): Promise { + const conversation = await parseConversation(meta.filePath).catch((error) => { + // 세션 하나가 깨져도 나머지 인덱싱은 계속하되, 조용히 사라지게 두지는 않는다 + console.error('[search] 세션 파싱 실패', meta.filePath, error) + return null + }) + if (!conversation) return null + return { + sessionId: meta.id, + projectId: meta.projectId, + filePath: meta.filePath, + title: meta.title, + updatedAt: meta.updatedAt, + fileSize: meta.fileSize, + // 검색할 본문이 없는 세션도 빈 문서로 남긴다. 인덱스에서 빼면 아래 스킵 검사가 영영 + // 걸리지 않아 매 정합마다 다시 파싱하고 revision을 올린다 + messages: indexMessages(extractSearchMessages(conversation)) + } +} + +async function reconcile(): Promise { + const projectIds = await listProjectIds() + // 루트를 못 읽은 채로 진행하면 아래 정리 단계가 "세션이 전부 사라졌다"로 오판한다 + if (projectIds === null) throw new Error('search: projects root unreadable') + emitProgress(0, projectIds.length) + + const seen = new Set() + let changed = false + let done = 0 + + for (const projectId of projectIds) { + const metas = await listSessions(projectId).catch((error) => { + console.error('[search] 세션 목록을 읽지 못했다', projectId, error) + return null + }) + if (metas === null) { + // 목록을 못 읽었을 뿐이므로, 이 프로젝트의 기존 문서를 사라진 것으로 취급하지 않는다 + for (const document of documents.values()) { + if (document.projectId === projectId) seen.add(document.filePath) + } + } else { + for (const meta of metas) { + seen.add(meta.filePath) + if (isDocumentFresh(documents.get(meta.filePath), meta)) continue + const document = await buildDocument(meta) + if (document) { + documents.set(meta.filePath, document) + changed = true + } else if (documents.delete(meta.filePath)) { + changed = true + } + } + } + done += 1 + emitProgress(done, projectIds.length) + // 메타가 전부 캐시에 걸리면 프로젝트 루프가 거의 동기로 돈다. 그때도 렌더러 IPC가 + // 끼어들 틈이 생기도록 프로젝트마다 한 번 넘긴다 + await new Promise((resolve) => setImmediate(resolve)) + } + + for (const filePath of [...documents.keys()]) { + if (seen.has(filePath)) continue + documents.delete(filePath) + changed = true + } + + ready = true + failed = false + lastReconcileAt = Date.now() + if (changed) revision += 1 + emitProgress(done, projectIds.length) + if (changed) { + await persist().catch((error) => console.error('[search] 인덱스 저장 실패', error)) + } +} + +function ensureIndex(): Promise { + if (!running) { + running = (async () => { + try { + if (!ready) await loadFromDisk() + await reconcile() + } catch (error) { + // ready를 세우지 않으면 검색이 영영 "인덱싱 중"에 묶인다. 대신 failed로 인덱스가 + // 불완전함을 알려, 빈 결과가 "그런 대화는 없다"로 읽히지 않게 한다 + console.error('[search] 인덱싱 실패', error) + ready = true + failed = true + lastReconcileAt = Date.now() + emitProgress(progress.done, progress.total) + } + })().finally(() => { + running = null + }) + } + return running +} + +export function initSearchIndex(window: BrowserWindow): void { + progressWindow = window + setTimeout(() => void ensureIndex(), INDEX_START_DELAY_MS) +} + +export async function searchSessions(query: string): Promise { + const trimmed = query.trim() + if (!trimmed) { + return { query: trimmed, hits: [], truncated: false, indexing: !ready, degraded: failed } + } + + // 정합을 기다리지 않는다 — 지금 인덱스로 즉시 답하고, 갱신되면 revision이 올라 + // 렌더러가 같은 질의를 다시 던진다 + if (!ready || Date.now() - lastReconcileAt > RECONCILE_INTERVAL_MS) { + void ensureIndex() + } + + // 본문과 같은 함수로 접어야 매칭 인덱스를 원문 슬라이스에 그대로 쓸 수 있다 + const folded = foldCase(trimmed) + const hits: SearchHit[] = [] + for (const document of documents.values()) { + const hit = matchDocument(document, folded, MAX_SNIPPETS_PER_SESSION) + if (hit) hits.push(hit) + } + hits.sort((a, b) => b.updatedAt - a.updatedAt) + + return { + query: trimmed, + hits: hits.slice(0, MAX_HITS), + truncated: hits.length > MAX_HITS, + indexing: !ready, + degraded: failed + } +} diff --git a/src/main/lib/searchMatch.test.ts b/src/main/lib/searchMatch.test.ts new file mode 100644 index 0000000..a7fe18b --- /dev/null +++ b/src/main/lib/searchMatch.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest' +import type { SearchMessage } from './searchExtract' +import { + foldCase, + indexMessages, + isDocumentFresh, + matchDocument, + type SearchDocument +} from './searchMatch' + +function document(messages: SearchMessage[]): SearchDocument { + return { + sessionId: 'session-1', + projectId: 'project-1', + filePath: '/tmp/session-1.jsonl', + title: '세션 제목', + updatedAt: 1_700_000_000_000, + fileSize: 1024, + messages: indexMessages(messages) + } +} + +describe('foldCase', () => { + it('대소문자를 접고 길이를 그대로 둔다', () => { + expect(foldCase('SearchIndex')).toBe('searchindex') + expect(foldCase('SearchIndex')).toHaveLength('SearchIndex'.length) + }) + + it('한글은 그대로 둔다', () => { + expect(foldCase('대화 내용 검색')).toBe('대화 내용 검색') + }) + + it('소문자에서 길어지는 글자는 접지 않고 길이를 지킨다', () => { + // U+0130(İ)은 toLowerCase에서 2코드유닛이 된다. 접어버리면 이후 인덱스가 전부 밀려 + // 스니펫이 엉뚱한 자리에서 잘린다 + expect('İ'.toLowerCase()).toHaveLength(2) + expect(foldCase('İstanbul')).toBe('İstanbul') + expect(foldCase('İstanbul')).toHaveLength('İstanbul'.length) + }) + + it('길이가 변하는 글자가 섞여도 매칭 구간이 원문과 어긋나지 않는다', () => { + const text = 'İstanbul 회고' + const snippet = matchDocument( + document([{ ref: 'u1', role: 'user', text }]), + foldCase('stanbul'), + 5 + )?.snippets[0] + expect(snippet?.match).toBe('stanbul') + }) +}) + +describe('isDocumentFresh', () => { + const meta = { updatedAt: 1_700_000_000_000, fileSize: 1024 } + + it('인덱스에 없던 세션은 신선하지 않다', () => { + expect(isDocumentFresh(undefined, meta)).toBe(false) + }) + + it('마지막 엔트리 시각과 크기가 모두 같아야 신선하다', () => { + expect(isDocumentFresh(document([]), meta)).toBe(true) + }) + + it('크기가 달라지면 신선하지 않다', () => { + expect(isDocumentFresh(document([]), { ...meta, fileSize: 2048 })).toBe(false) + }) + + it('마지막 엔트리 시각이 달라지면 신선하지 않다', () => { + expect(isDocumentFresh(document([]), { ...meta, updatedAt: 1_700_000_000_001 })).toBe(false) + }) +}) + +describe('matchDocument', () => { + it('매칭이 없으면 null을 돌려준다', () => { + const result = matchDocument(document([{ ref: 'u1', role: 'user', text: '고래' }]), '상어', 5) + expect(result).toBeNull() + }) + + it('빈 질의는 매칭으로 치지 않는다', () => { + const result = matchDocument(document([{ ref: 'u1', role: 'user', text: '고래' }]), '', 5) + expect(result).toBeNull() + }) + + it('대소문자를 무시하고 찾으며 원문 대소문자로 스니펫을 만든다', () => { + const result = matchDocument( + document([{ ref: 'u1', role: 'user', text: 'SearchIndex 를 만들자' }]), + 'searchindex', + 5 + ) + expect(result?.matchCount).toBe(1) + expect(result?.snippets[0].match).toBe('SearchIndex') + }) + + it('한 문서 안의 모든 매칭을 센다', () => { + const result = matchDocument( + document([ + { ref: 'u1', role: 'user', text: '검색 검색' }, + { ref: 'a1', role: 'assistant', text: '검색이요' } + ]), + '검색', + 5 + ) + expect(result?.matchCount).toBe(3) + }) + + it('스니펫 수는 상한을 넘지 않지만 matchCount는 전체를 센다', () => { + const result = matchDocument( + document([{ ref: 'u1', role: 'user', text: '가 가 가 가 가' }]), + '가', + 2 + ) + expect(result?.matchCount).toBe(5) + expect(result?.snippets).toHaveLength(2) + }) + + it('긴 텍스트는 앞뒤를 잘라 말줄임표를 붙이고 개행을 눌러 준다', () => { + const long = `${'앞'.repeat(200)}\n표적\n${'뒤'.repeat(200)}` + const result = matchDocument(document([{ ref: 'u1', role: 'user', text: long }]), '표적', 5) + const snippet = result?.snippets[0] + expect(snippet?.before.startsWith('…')).toBe(true) + expect(snippet?.after.endsWith('…')).toBe(true) + expect(snippet?.before).not.toContain('\n') + expect(snippet?.after).not.toContain('\n') + expect(snippet?.match).toBe('표적') + }) + + it('짧은 텍스트에는 말줄임표를 붙이지 않는다', () => { + const result = matchDocument(document([{ ref: 'u1', role: 'user', text: '표적' }]), '표적', 5) + expect(result?.snippets[0]).toEqual({ + ref: 'u1', + role: 'user', + before: '', + match: '표적', + after: '' + }) + }) + + it('앞 50자·뒤 110자가 정확히 들어맞으면 말줄임표를 붙이지 않는다', () => { + // 문맥 폭(CONTEXT_BEFORE/AFTER)을 고정한다. 200자짜리 텍스트로만 검사하면 폭을 + // 5·11로 줄여도 "말줄임표가 붙는다"는 단언은 그대로 통과한다. + const text = `${'앞'.repeat(50)}표적${'뒤'.repeat(110)}` + const snippet = matchDocument(document([{ ref: 'u1', role: 'user', text }]), '표적', 5) + ?.snippets[0] + expect(snippet?.before).toBe('앞'.repeat(50)) + expect(snippet?.after).toBe('뒤'.repeat(110)) + }) + + it('문맥 폭을 한 글자라도 넘으면 그만큼 잘라내고 말줄임표를 붙인다', () => { + // 경계 바로 바깥. 위 테스트와 짝을 이뤄 start > 0 / end < length 의 부등호가 + // >= / <= 로 바뀌는 오프바이원을 양쪽에서 잡는다. + const text = `${'앞'.repeat(51)}표적${'뒤'.repeat(111)}` + const snippet = matchDocument(document([{ ref: 'u1', role: 'user', text }]), '표적', 5) + ?.snippets[0] + expect(snippet?.before).toBe(`…${'앞'.repeat(50)}`) + expect(snippet?.after).toBe(`${'뒤'.repeat(110)}…`) + }) + + it('텍스트 맨 앞의 매칭은 앞 말줄임표가, 맨 끝의 매칭은 뒤 말줄임표가 없다', () => { + const head = matchDocument( + document([{ ref: 'u1', role: 'user', text: `표적${'뒤'.repeat(300)}` }]), + '표적', + 5 + )?.snippets[0] + expect(head?.before).toBe('') + expect(head?.after.endsWith('…')).toBe(true) + + const tail = matchDocument( + document([{ ref: 'u1', role: 'user', text: `${'앞'.repeat(300)}표적` }]), + '표적', + 5 + )?.snippets[0] + expect(tail?.before.startsWith('…')).toBe(true) + expect(tail?.after).toBe('') + }) + + it('매칭은 겹치지 않게 전진한다', () => { + // 'aaaa'에서 'aa'는 겹쳐 세면 3, 겹치지 않게 세면 2다. 전진 폭을 1로 바꾸면 + // matchCount가 부풀고 거의 같은 자리를 가리키는 스니펫이 중복으로 쌓인다. + const four = matchDocument(document([{ ref: 'u1', role: 'user', text: 'aaaa' }]), 'aa', 5) + expect(four?.matchCount).toBe(2) + expect(four?.snippets.map((snippet) => snippet.before)).toEqual(['', 'aa']) + + const three = matchDocument(document([{ ref: 'u1', role: 'user', text: 'aaa' }]), 'aa', 5) + expect(three?.matchCount).toBe(1) + }) + + it('스니펫 상한은 메시지별이 아니라 문서 전체에 걸린다', () => { + // 앞 메시지가 상한을 다 쓰면 뒤 메시지는 매칭이 있어도 스니펫을 얻지 못한다. + // 의도된 동작이지만 결과 화면에서 눈에 띄므로 고정해 둔다. + const result = matchDocument( + document([ + { ref: 'u1', role: 'user', text: '표적 표적' }, + { ref: 'a1', role: 'assistant', text: '표적' } + ]), + '표적', + 2 + ) + expect(result?.matchCount).toBe(3) + expect(result?.snippets.map((snippet) => snippet.ref)).toEqual(['u1', 'u1']) + }) + + it('세션 식별 정보를 그대로 옮긴다', () => { + const result = matchDocument(document([{ ref: 'u1', role: 'user', text: '표적' }]), '표적', 5) + expect(result).toMatchObject({ + sessionId: 'session-1', + projectId: 'project-1', + filePath: '/tmp/session-1.jsonl', + title: '세션 제목', + updatedAt: 1_700_000_000_000 + }) + }) +}) diff --git a/src/main/lib/searchMatch.ts b/src/main/lib/searchMatch.ts new file mode 100644 index 0000000..2b28876 --- /dev/null +++ b/src/main/lib/searchMatch.ts @@ -0,0 +1,126 @@ +import type { SearchHit, SearchSnippet, SessionMeta } from '../../shared/types' +import type { SearchMessage } from './searchExtract' + +/** + * 스니펫에서 매칭 앞뒤로 남길 문자 수. flatten이 슬라이스 뒤에 돌기 때문에 이 값은 + * 공백을 누르기 전 예산이다 — 공백이 많은 텍스트는 화면에서 더 짧게 보인다. + * 뒤쪽을 넉넉히 두는 이유는 매칭 다음에 오는 말이 대개 더 궁금하기 때문이다. + */ +const CONTEXT_BEFORE = 50 +const CONTEXT_AFTER = 110 + +/** + * 길이를 보존하는 대소문자 접기. + * + * 매칭은 접은 문자열에서 찾고 스니펫은 원문에서 잘라내므로, 두 문자열의 인덱스가 + * 어긋나면 안 된다. `toLowerCase()`로 길이가 변하는 코드포인트는 유니코드 전체에서 + * U+0130(İ) 하나뿐이고, 그런 글자는 접지 않고 원문 그대로 둔다. 그 글자만 대소문자를 + * 구분하게 되는데, 터키어에서 İ와 i는 애초에 다른 글자다. + */ +export function foldCase(text: string): string { + const lower = text.toLowerCase() + if (lower.length === text.length) return lower + let folded = '' + for (const character of text) { + const lowered = character.toLowerCase() + folded += lowered.length === character.length ? lowered : character + } + return folded +} + +export interface IndexedMessage extends SearchMessage { + /** text를 접은 사본 — 검색마다 코퍼스 전체를 다시 변환하지 않으려고 적재 시 한 번 만든다 */ + lower: string +} + +export interface SearchDocument { + sessionId: string + projectId: string + filePath: string + title: string + updatedAt: number + fileSize: number + messages: IndexedMessage[] +} + +export function indexMessages(messages: SearchMessage[]): IndexedMessage[] { + return messages.map((message) => ({ ...message, lower: foldCase(message.text) })) +} + +/** + * 세션 내용이 인덱스에 담긴 그대로인지 판정한다. + * + * 세션 JSONL은 append-only라, 내용이 바뀌면 fileSize가 반드시 커지고 마지막 엔트리 + * 시각도 함께 움직인다. 크기가 같은 채로 다시 쓰이는 경우까지 걸러내려고 둘을 함께 본다. + * 이 판정이 느슨해지면 검색 결과가 오류 없이 영원히 낡는다. + */ +export function isDocumentFresh( + existing: SearchDocument | undefined, + meta: Pick +): boolean { + return ( + existing !== undefined && + existing.updatedAt === meta.updatedAt && + existing.fileSize === meta.fileSize + ) +} + +function flatten(text: string): string { + return text.replace(/\s+/g, ' ') +} + +export function buildSnippet( + message: SearchMessage, + matchIndex: number, + matchLength: number +): SearchSnippet { + const start = Math.max(0, matchIndex - CONTEXT_BEFORE) + const end = Math.min(message.text.length, matchIndex + matchLength + CONTEXT_AFTER) + const head = start > 0 ? '…' : '' + const tail = end < message.text.length ? '…' : '' + return { + ref: message.ref, + role: message.role, + before: head + flatten(message.text.slice(start, matchIndex)), + match: flatten(message.text.slice(matchIndex, matchIndex + matchLength)), + after: flatten(message.text.slice(matchIndex + matchLength, end)) + tail + } +} + +/** + * 문서 하나에서 질의를 모두 찾는다. 매칭이 없으면 null. + * folded는 질의를 foldCase로 접어서 넘겨야 한다 — 본문과 같은 함수를 써야 인덱스가 맞는다. + */ +export function matchDocument( + document: SearchDocument, + folded: string, + maxSnippets: number +): SearchHit | null { + // 빈 질의는 indexOf가 매번 0을 돌려줘 무한 루프가 된다 + if (!folded) return null + + let matchCount = 0 + const snippets: SearchSnippet[] = [] + for (const message of document.messages) { + let from = 0 + for (;;) { + const index = message.lower.indexOf(folded, from) + if (index === -1) break + matchCount += 1 + if (snippets.length < maxSnippets) { + snippets.push(buildSnippet(message, index, folded.length)) + } + from = index + folded.length + } + } + if (matchCount === 0) return null + return { + sessionId: document.sessionId, + projectId: document.projectId, + filePath: document.filePath, + title: document.title, + updatedAt: document.updatedAt, + matchCount, + snippets + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 3e6b2d7..461c8e5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,10 +1,11 @@ import { contextBridge, ipcRenderer } from 'electron' -import type { ClaudeHistoryApi, UpdateEvent } from '../shared/types' +import type { ClaudeHistoryApi, SearchProgress, UpdateEvent } from '../shared/types' const api: ClaudeHistoryApi = { listProjects: () => ipcRenderer.invoke('projects:list'), listSessions: (projectId) => ipcRenderer.invoke('sessions:list', projectId), loadConversation: (filePath) => ipcRenderer.invoke('conversation:load', filePath), + searchSessions: (query) => ipcRenderer.invoke('search:query', query), resumeSession: (sessionId, cwd) => ipcRenderer.invoke('session:resume', sessionId, cwd), forkSession: (sessionId, cwd) => ipcRenderer.invoke('session:fork', sessionId, cwd), deleteSession: (filePath) => ipcRenderer.invoke('session:delete', filePath), @@ -24,6 +25,12 @@ const api: ClaudeHistoryApi = { ipcRenderer.on('menu:open-settings', listener) return () => ipcRenderer.removeListener('menu:open-settings', listener) }, + onSearchProgress: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, payload: SearchProgress): void => + callback(payload) + ipcRenderer.on('search:progress', listener) + return () => ipcRenderer.removeListener('search:progress', listener) + }, showSessionMenu: (labels) => ipcRenderer.invoke('session:menu', labels), installUpdate: () => ipcRenderer.invoke('update:install') } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0ac6cfa..3002bba 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -11,6 +11,9 @@ import type { AppSettings, Conversation, ProjectInfo, + SearchHit, + SearchProgress, + SearchResults, SessionMeta, UpdateInfo } from '../../shared/types' @@ -22,11 +25,13 @@ type UpdateBannerState = import { makeTranslate, resolveLanguage } from './i18n' import { DEFAULT_SETTINGS, PrefsContext, type Prefs } from './prefs' import { buildGroups, SYNTHETIC_PREFIX } from './lib/groups' +import { shortcut } from './lib/platform' import { Sidebar } from './components/Sidebar' import { ConversationView } from './components/ConversationView' +import { SearchPalette } from './components/SearchPalette' +import { TopBar } from './components/TopBar' import { ConfirmDialog } from './components/ConfirmDialog' import { SettingsDialog } from './components/SettingsDialog' -import { SidebarExpand } from './components/SidebarExpand' import { WakeMark } from './components/WakeMark' export default function App(): ReactElement { @@ -42,6 +47,14 @@ export default function App(): ReactElement { const [showSettings, setShowSettings] = useState(false) const [update, setUpdate] = useState(null) const [toast, setToast] = useState(null) + const [searchOpen, setSearchOpen] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState(null) + const [searchProgress, setSearchProgress] = useState(null) + const [searchFailed, setSearchFailed] = useState(false) + const [highlightRef, setHighlightRef] = useState(null) + const searchRequestRef = useRef(0) + const selectRequestRef = useRef(0) const searchRef = useRef(null) const loadedProjects = useRef>(new Set()) const [sidebarWidth, setSidebarWidth] = useState(() => { @@ -143,15 +156,66 @@ export default function App(): ReactElement { // 애플리케이션 메뉴(wake > 설정…)에서 설정 열기 useEffect(() => window.api.onOpenSettings(() => setShowSettings(true)), []) + useEffect(() => window.api.onSearchProgress(setSearchProgress), []) + + // 인덱스가 갱신되면(revision) 같은 질의를 자동으로 다시 돌린다 + useEffect(() => { + // 질의가 비거나 팔레트를 닫아도 번호를 올린다 — 그래야 이미 떠난 요청의 응답이 + // 뒤늦게 돌아와 상태를 덮어쓰지 않는다 + const requestId = ++searchRequestRef.current + if (!searchOpen) return undefined + const trimmed = searchQuery.trim() + if (!trimmed) return undefined + const timer = window.setTimeout(() => { + // 다시 시도하는 동안에는 이전 실패 표시를 걷는다 + setSearchFailed(false) + window.api + .searchSessions(trimmed) + .then((results) => { + // 늦게 도착한 이전 질의의 응답은 버린다 + if (requestId !== searchRequestRef.current) return + setSearchFailed(false) + setSearchResults(results) + }) + .catch((error) => { + if (requestId !== searchRequestRef.current) return + console.error('[search] 질의 실패', error) + // 이전 질의의 결과를 남겨두면 새 질의의 결과처럼 읽힌다 + setSearchResults(null) + setSearchFailed(true) + }) + }, 200) + return () => window.clearTimeout(timer) + }, [searchOpen, searchQuery, searchProgress?.ready, searchProgress?.revision]) + + // 강조는 대화가 실제로 그려진 뒤부터 재는데, 로딩이 길어도 표시를 놓치지 않기 위해서다 + useEffect(() => { + if (!highlightRef || !conversation) return undefined + const timer = window.setTimeout(() => setHighlightRef(null), 3000) + return () => window.clearTimeout(timer) + }, [highlightRef, conversation]) + // 검색 시 아직 안 읽은 프로젝트의 세션 메타를 모두 로드한다 useEffect(() => { if (!query.trim()) return for (const project of projects) loadSessions(project.id) }, [query, projects, loadSessions]) + const openContentSearch = useCallback(() => { + setSearchOpen(true) + // 사이드바에 입력해 둔 질의가 있으면 그대로 이어받는다 + setSearchQuery((previous) => previous || query) + }, [query]) + useEffect(() => { const onKeyDown = (event: KeyboardEvent): void => { - if ((event.metaKey || event.ctrlKey) && event.key === 'f') { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault() + openContentSearch() + return + } + // CapsLock이 켜져 있으면 ⌘F도 key가 'F'로 오므로 양쪽 다 정규화해서 비교한다 + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') { event.preventDefault() searchRef.current?.focus() } @@ -162,11 +226,12 @@ export default function App(): ReactElement { if (event.key === 'Escape') { setShowSettings(false) setDeleteTarget(null) + setSearchOpen(false) } } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - }, []) + }, [openContentSearch]) const toggleProject = useCallback( (projectId: string) => { @@ -183,17 +248,30 @@ export default function App(): ReactElement { [loadSessions] ) - const selectSession = useCallback(async (session: SessionMeta) => { - setSelected(session) - setConversation(null) - setLoadingConversation(true) - try { - const loaded = await window.api.loadConversation(session.filePath) - setConversation(loaded) - } finally { - setLoadingConversation(false) - } - }, []) + const selectSession = useCallback( + async (session: SessionMeta, highlight: string | null = null) => { + // 두 세션을 빠르게 연달아 고르면 로드가 역순으로 끝날 수 있다. 그때 늦게 온 + // 대화가 헤더와 다른 세션의 본문을 그리지 않도록 마지막 선택만 반영한다 + const requestId = ++selectRequestRef.current + setSelected(session) + setConversation(null) + setHighlightRef(highlight) + setLoadingConversation(true) + try { + const loaded = await window.api.loadConversation(session.filePath) + if (requestId !== selectRequestRef.current) return + setConversation(loaded) + } catch (error) { + if (requestId !== selectRequestRef.current) return + // 실패하면 헤더만 남은 빈 패널이 되므로, 왜 비었는지는 알려줘야 한다 + console.error('[conversation] 로드 실패', error) + showToast(t('toast.loadFailed')) + } finally { + if (requestId === selectRequestRef.current) setLoadingConversation(false) + } + }, + [showToast, t] + ) const openSessionMenu = useCallback( async (session: SessionMeta) => { @@ -212,6 +290,62 @@ export default function App(): ReactElement { [projects, selected] ) + const groups = useMemo(() => buildGroups(projects), [projects]) + + const projectLabel = useCallback( + (projectId: string) => { + const project = projects.find((candidate) => candidate.id === projectId) + if (!project) return projectId + const group = groups.find((candidate) => + candidate.worktrees.some((worktree) => worktree.id === projectId) + ) + // 워크트리 세션은 어느 저장소의 어느 워크트리인지 같이 보여야 구분이 된다 + if (group) return `${group.root.name} ⎇ ${project.worktree?.name ?? project.name}` + return project.name + }, + [groups, projects] + ) + + const openHit = useCallback( + async (hit: SearchHit, ref: string) => { + // 사이드바 목록은 프로젝트당 한 번만 읽어 두므로, 그 뒤 생긴 세션은 검색에만 잡힌다 + let session = sessions[hit.projectId]?.find((meta) => meta.id === hit.sessionId) + if (!session) { + let metas: SessionMeta[] + try { + metas = await window.api.listSessions(hit.projectId) + } catch (error) { + // 여기서 그냥 던지면 클릭이 아무 반응 없이 죽는다 + console.error('[search] 세션 목록 조회 실패', error) + showToast(t('search.openFailed')) + return + } + loadedProjects.current.add(hit.projectId) + setSessions((previous) => ({ ...previous, [hit.projectId]: metas })) + session = metas.find((meta) => meta.id === hit.sessionId) + } + if (!session) { + showToast(t('search.missing')) + return + } + // 워크트리 세션은 그룹 루트까지 펼쳐야 사이드바에서 선택 상태가 보인다 + const group = groups.find( + (candidate) => + candidate.root.id === hit.projectId || + candidate.worktrees.some((worktree) => worktree.id === hit.projectId) + ) + setExpanded((previous) => { + const next = new Set(previous) + if (group) next.add(group.root.id) + next.add(hit.projectId) + return next + }) + setSearchOpen(false) + await selectSession(session, ref || null) + }, + [groups, selectSession, sessions, showToast, t] + ) + const runAction = useCallback( async (kind: 'resume' | 'fork') => { if (!selected) return @@ -259,6 +393,11 @@ export default function App(): ReactElement { data-font-scale={settings.fontScale} style={{ '--sidebar-width': `${sidebarCollapsed ? 0 : sidebarWidth}px` } as CSSProperties} > + {selected ? ( @@ -279,24 +417,37 @@ export default function App(): ReactElement { project={selectedProject} conversation={conversation} loading={loadingConversation} + highlightRef={highlightRef} onResume={() => runAction('resume')} onFork={() => runAction('fork')} onDelete={() => setDeleteTarget(selected)} onReveal={() => window.api.revealSession(selected.filePath)} - onExpandSidebar={sidebarCollapsed ? toggleSidebar : null} /> ) : (
- {sidebarCollapsed && }

{t('empty.title')}

-

{t('empty.hint')}

+

+ {t('empty.hint', { find: shortcut('F'), search: shortcut('K') })} +

)} + {searchOpen && ( + setSearchOpen(false)} + /> + )} {deleteTarget && ( (['bashRun', 'interrupt', 'compact']) @@ -13,11 +12,12 @@ interface Props { project: ProjectInfo | null conversation: Conversation | null loading: boolean + /** 검색 결과에서 열었을 때 이동·강조할 아이템 uuid */ + highlightRef: string | null onResume: () => void onFork: () => void onDelete: () => void onReveal: () => void - onExpandSidebar: (() => void) | null } export function ConversationView({ @@ -25,13 +25,14 @@ export function ConversationView({ project, conversation, loading, + highlightRef, onResume, onFork, onDelete, - onReveal, - onExpandSidebar + onReveal }: Props): ReactElement { const { t, settings } = usePrefs() + const scrollRef = useRef(null) const cwd = session.cwd ?? project?.realPath ?? null const items = conversation ? conversation.items.filter( @@ -42,10 +43,20 @@ export function ConversationView({ ALWAYS_VISIBLE_META.has(item.meta.kind) ) : [] + + // 검색은 meta 없는 사용자 아이템과 어시스턴트 turn만 인덱싱하므로, showMeta 설정과 + // 무관하게 대상 노드가 항상 그려져 있다. 강조 해제 시점은 App이 관리한다 + useEffect(() => { + if (!highlightRef || !conversation) return + // start 정렬 — 어시스턴트 턴은 화면보다 길 수 있어 center로 맞추면 강조 테두리가 화면 밖으로 나간다 + scrollRef.current + ?.querySelector(`[data-uuid="${CSS.escape(highlightRef)}"]`) + ?.scrollIntoView({ block: 'start' }) + }, [highlightRef, conversation]) + return (
- {onExpandSidebar && }

{session.title}

@@ -74,7 +85,7 @@ export function ConversationView({

-
+
{loading &&

{t('conversation.loading')}

} {!loading && conversation && items.length === 0 && (

{t('conversation.empty')}

@@ -83,9 +94,13 @@ export function ConversationView({
{items.map((item) => item.kind === 'user' ? ( - + ) : ( - + ) )}
diff --git a/src/renderer/src/components/MessageItems.tsx b/src/renderer/src/components/MessageItems.tsx index 7268895..878a57a 100644 --- a/src/renderer/src/components/MessageItems.tsx +++ b/src/renderer/src/components/MessageItems.tsx @@ -100,7 +100,13 @@ function CompactDivider({ item }: { item: UserItem }): ReactElement { ) } -export function UserMessage({ item }: { item: UserItem }): ReactElement { +export function UserMessage({ + item, + highlighted = false +}: { + item: UserItem + highlighted?: boolean +}): ReactElement { const { t } = usePrefs() if (item.meta) { if (item.meta.kind === 'bashRun') return @@ -109,8 +115,9 @@ export function UserMessage({ item }: { item: UserItem }): ReactElement { return } const clock = formatClock(item.timestamp) + // 위쪽 메타 분기에는 data-uuid를 붙이지 않는다 — 검색이 인덱싱하지 않아 이동 대상이 아니다 return ( -
+
{clock && }
{item.text}
@@ -146,10 +153,19 @@ function ThinkingBlock({ text }: { text: string }): ReactElement { ) } -export function AssistantTurn({ item }: { item: AssistantItem }): ReactElement { +export function AssistantTurn({ + item, + highlighted = false +}: { + item: AssistantItem + highlighted?: boolean +}): ReactElement { const clock = formatClock(item.timestamp) return ( -
+
{clock && } {item.blocks.map((block, index) => { if (block.type === 'text') return diff --git a/src/renderer/src/components/SearchPalette.tsx b/src/renderer/src/components/SearchPalette.tsx new file mode 100644 index 0000000..413a570 --- /dev/null +++ b/src/renderer/src/components/SearchPalette.tsx @@ -0,0 +1,195 @@ +import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react' +import type { SearchHit, SearchProgress, SearchResults, SearchSnippet } from '../../../shared/types' +import { formatRelativeTime } from '../lib/format' +import { usePrefs } from '../prefs' + +interface Props { + query: string + /** 지금 입력된 질의에 대한 결과만 넘어온다 — 낡은 결과는 null */ + results: SearchResults | null + progress: SearchProgress | null + failed: boolean + /** projectId를 사이드바에 보이는 이름으로 옮긴다 */ + projectLabel: (projectId: string) => string + onQueryChange: (query: string) => void + onOpenHit: (hit: SearchHit, ref: string) => void + onClose: () => void +} + +interface Row { + hit: SearchHit + snippet: SearchSnippet + /** 이 행이 세션의 첫 스니펫이면 위에 세션 머리글을 그린다 */ + first: boolean +} + +export function SearchPalette({ + query, + results, + progress, + failed, + projectLabel, + onQueryChange, + onOpenHit, + onClose +}: Props): ReactElement { + const { t } = usePrefs() + const inputRef = useRef(null) + const listRef = useRef(null) + const [active, setActive] = useState(0) + + const rows = useMemo( + () => + (results?.hits ?? []).flatMap((hit) => + hit.snippets.map((snippet, index) => ({ hit, snippet, first: index === 0 })) + ), + [results] + ) + + // 인덱스 갱신으로 결과가 줄어들면 선택이 목록 밖을 가리킬 수 있다 + const activeIndex = Math.min(active, Math.max(0, rows.length - 1)) + + // 선택이 화면 밖으로 나가면 따라 스크롤한다 + useEffect(() => { + listRef.current?.querySelector('.snippet.is-active')?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex]) + + // 닫을 때 팔레트를 연 컨트롤로 포커스를 돌려준다. + // 첫 렌더에서 잡아야 한다 — 이펙트 시점에는 입력창의 autoFocus가 이미 가져간 뒤다 + const [opener] = useState(() => document.activeElement) + useEffect( + () => () => { + if (opener instanceof HTMLElement && opener.isConnected) opener.focus() + }, + [opener] + ) + + const trimmed = query.trim() + // 응답도 진행률도 못 받았으면 인덱싱 중으로 본다 — 그 사이에 "결과 없음"을 보여주면 안 된다 + const indexing = results ? results.indexing : !progress?.ready + + let status: string + if (indexing) { + status = + progress && progress.total > 0 + ? t('search.indexing', { done: progress.done, total: progress.total }) + : t('search.indexingPrep') + } else if (!trimmed) { + // 질의를 지웠으면 직전 실패보다 안내가 먼저다 + status = t('search.hint') + } else if (failed) { + status = t('search.failed') + } else if (!results) { + // 디바운스·왕복 중이다. 아직 결과가 없다고 단정하지 않는다 + status = t('search.searching') + } else if (results.degraded) { + // 인덱스가 불완전하면 찾은 게 있어도 "이게 전부"라고 말하지 않는다 + status = results.hits.length === 0 ? t('search.failed') : t('search.partial') + } else if (results.hits.length === 0) { + status = t('search.empty', { query: trimmed }) + } else { + const matches = results.hits.reduce((sum, hit) => sum + hit.matchCount, 0) + status = t('search.summary', { sessions: results.hits.length, matches }) + } + + const onKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (rows.length === 0) return + event.preventDefault() + const step = event.key === 'ArrowDown' ? 1 : -1 + setActive((activeIndex + step + rows.length) % rows.length) + return + } + if (event.key === 'Enter') { + const row = rows[activeIndex] + if (row) onOpenHit(row.hit, row.snippet.ref) + } + } + + return ( + // 바깥을 누르면 닫힌다. 이 오버레이는 팔레트 뒤 화면을 가리지 않는다 +
+
event.stopPropagation()} + // Tab으로 뒤 화면의 컨트롤로 빠져나가면 ↑↓·↵가 더는 듣지 않는다. + // 팔레트는 입력창에서만 조작하므로 Tab 자체를 막아 포커스를 붙잡아 둔다 + onKeyDown={(event) => { + if (event.key === 'Tab') event.preventDefault() + }} + > +
+ + { + // 질의가 바뀌면 선택을 맨 위로 되돌린다 + setActive(0) + onQueryChange(event.target.value) + }} + onKeyDown={onKeyDown} + spellCheck={false} + autoFocus + /> +
+

{status}

+ {rows.length > 0 && ( +
+ {rows.map((row, index) => ( +
+ {row.first && ( +
+ {row.hit.title} + + {projectLabel(row.hit.projectId)} · {formatRelativeTime(row.hit.updatedAt, t)}{' '} + · {t('search.matches', { n: row.hit.matchCount })} + +
+ )} + +
+ ))} + {results?.truncated && ( +

+ {t('search.truncated', { n: results.hits.length })} +

+ )} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 9949a7f..1b6af5f 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -7,6 +7,7 @@ import { import type { ProjectInfo, SessionMeta } from '../../../shared/types' import { formatRelativeTime, shortenPath } from '../lib/format' import { buildGroups } from '../lib/groups' +import { shortcut } from '../lib/platform' import { usePrefs } from '../prefs' interface Props { @@ -20,7 +21,6 @@ interface Props { onToggleProject: (projectId: string) => void onSelectSession: (session: SessionMeta) => void onSessionMenu: (session: SessionMeta) => void - onCollapseSidebar: () => void onResizeStart: (event: ReactMouseEvent) => void } @@ -49,6 +49,15 @@ function matches(session: SessionMeta, query: string): boolean { ) } +/** 폴더 이름이 걸리면 그 안의 세션은 제목과 무관하게 전부 보여준다 */ +function folderMatches(project: ProjectInfo, query: string): boolean { + const lowered = query.toLowerCase() + return ( + project.name.toLowerCase().includes(lowered) || + (project.worktree?.name.toLowerCase().includes(lowered) ?? false) + ) +} + function SessionList({ items, selectedSessionId, @@ -97,7 +106,6 @@ export function Sidebar({ onToggleProject, onSelectSession, onSessionMenu, - onCollapseSidebar, onResizeStart }: Props): ReactElement { const { t } = usePrefs() @@ -106,34 +114,11 @@ export function Sidebar({ return (