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
+18
View File
@@ -0,0 +1,18 @@
# Node 빌드/캐시
node_modules/
dist/
dist-tsbuild/
tsconfig.tsbuildinfo
.vitest-cache/
# 테스트/E2E 결과물
e2e/
playwright-report/
test-results/
# Env
# Vite는 빌드 타임에 .env / .env.[mode]를 읽어 VITE_* 값을 번들에 박음.
# 따라서 빌드 컨테이너 안에 .env 가 있어야 함. (VITE_* 는 정의상 public)
# 개발자 머신 전용 오버라이드(.local)만 image에서 제외.
.env.local
.env.*.local
+9
View File
@@ -0,0 +1,9 @@
# API base URL. 풀 URL 또는 '/' 로 시작하는 절대경로.
# dev/prd 모두 single-origin 전제라 상대경로가 기본.
VITE_API_BASE_URL=/api/v1
# dev 서버 '/api' proxy 대상 백엔드 origin (기본값: http://localhost:8001)
VITE_DEV_API_TARGET=http://localhost:8001
# Entra Graph scope (선택, 기본값: User.Read)
# VITE_ENTRA_GRAPH_SCOPE=User.Read
+9
View File
@@ -0,0 +1,9 @@
# API base URL. 풀 URL 또는 '/' 로 시작하는 절대경로.
# dev/prd 모두 single-origin 전제라 상대경로가 기본.
VITE_API_BASE_URL=/api/v1
# dev 서버 '/api' proxy 대상 백엔드 origin (기본값: http://localhost:8001)
VITE_DEV_API_TARGET=http://localhost:8001
# Entra Graph scope (선택, 기본값: User.Read)
# VITE_ENTRA_GRAPH_SCOPE=User.Read
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
dist-ssr
dist-tsbuild
*.local
.DS_Store
.idea
coverage
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
coverage
package-lock.json
+8
View File
@@ -0,0 +1,8 @@
{
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"]
}
+47
View File
@@ -0,0 +1,47 @@
# 2_frontend — React SPA 템플릿
Vite + React + TypeScript + Tailwind + shadcn/ui + Zustand + TanStack Query 기반.
백엔드(`1_backend/`, FastAPI + SSE + JWT)와 연동.
## 시작하기
```bash
npm install
cp .env.example .env
npm run dev # http://localhost:5173
```
백엔드도 같이 띄울 것: `cd ../1_backend && uv run uvicorn main:app --reload --reload-dir src --port 8001 --app-dir src`
## 주요 명령
| 명령 | 설명 |
| ---------------- | ------------------ |
| `npm run dev` | dev 서버 (5173) |
| `npm run build` | 프로덕션 빌드 |
| `npm run lint` | ESLint |
| `npm run test` | Vitest 단위 테스트 |
| `npm run format` | Prettier 포맷 |
자세한 설계: `../docs/superpowers/specs/2026-05-05-frontend-template-design.md`
## 검증 완료 (2026-05-05)
- 부트스트랩 (Vite + TS + Tailwind + ESLint + Prettier 설정)
- core lib (tokenStore, api client + 401 refresh, SSE wrapper, queryClient, cn)
- shared (shadcn/ui core, Layout, ProtectedRoute, ErrorBoundary)
- features/auth (schemas, store, api, hooks, components, pages)
- features/users (api, hooks, MePage)
- features/chat (store, SSE wrapper, hook, components, ChatPage)
- routing + Provider 와이어업
테스트 34개 통과, lint 깨끗, build 성공.
수동 시나리오 검증 (백엔드 8001 + 프론트 5173 같이 띄우고):
1. `/` → ProtectedRoute가 `/login` 으로 redirect
2. `happy@pwc.com / a1234` 로그인 → `/chat` 이동
3. 헤더에 이메일 보임
4. `/me` 클릭 → 내 정보 표시
5. `/chat` 메시지 보내면 SSE echo로 토큰 단위 누적
6. Logout → `/login` 이동
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/shared",
"utils": "@/lib/utils/cn",
"ui": "@/shared/ui",
"lib": "@/lib",
"hooks": "@/lib/hooks"
},
"iconLibrary": "lucide"
}
+17
View File
@@ -0,0 +1,17 @@
# docs-lib
이 모듈(2_frontend)이 쓰는 라이브러리 참조 문서 모음. 구현 전에 여기 먼저 보고 감 — 기억으로 API 쓰지 말 것. 파일이 크면 통째로 읽지 말고 Grep 으로 필요한 부분만 꺼내 쓰기.
## 카탈로그
| 파일 | 라이브러리 | 출처 | 갱신법 |
| ------------------- | ----------------------- | ------------------------------------------------------------------------------- | ------------------------------------ |
| `react-markdown.md` | react-markdown v10 | https://raw.githubusercontent.com/remarkjs/react-markdown/main/readme.md | curl 로 다시 받기 |
| `tauri-api.md` | @tauri-apps/api v2.11.1 | **패키지 실물** `node_modules/@tauri-apps/api/*.d.ts`·`*.js` 에서 직접 확인 | 버전 올라가면 `.d.ts` 다시 보고 갱신 |
| `tanstack-query.md` | TanStack Query v5 | https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries | 공식 문서에서 필요한 부분 갱신 |
| `axios.md` | Axios v1 | https://axios-http.com/docs/interceptors | 공식 문서에서 필요한 부분 갱신 |
| `react-router.md` | React Router | https://reactrouter.com/api/hooks/useRoutes | 공식 문서에서 필요한 부분 갱신 |
## 갱신법
출처 URL 을 curl 로 다시 받아 덮어쓰면 됨. 새 라이브러리 문서 추가하면 위 표에 한 줄 등록.
+17
View File
@@ -0,0 +1,17 @@
# Axios v1 인터셉터 참조
출처: https://axios-http.com/docs/interceptors
확인일: 2026-09-11
## 응답 인터셉터
2xx 밖 응답은 response interceptor의 reject handler로 들어옴. 오류를 처리하지 않을 때는 reject/throw로 다음 호출자에게 전달해야 함.
```ts
instance.interceptors.response.use(
(response) => response,
(error) => Promise.reject(error)
)
```
여러 response interceptor는 등록 순서(FIFO)로 실행됨.
+879
View File
@@ -0,0 +1,879 @@
<!--
Notes for maintaining this document:
* update the version of the link for `commonmark-html` once in a while
-->
# react-markdown
[![Build][badge-build-image]][badge-build-url]
[![Coverage][badge-coverage-image]][badge-coverage-url]
[![Downloads][badge-downloads-image]][badge-downloads-url]
[![Size][badge-size-image]][badge-size-url]
React component to render markdown.
## Feature highlights
- [x] **[safe][section-security] by default**
(no `dangerouslySetInnerHTML` or XSS attacks)
- [x] **[components][section-components]**
(pass your own component to use instead of `<h2>` for `## hi`)
- [x] **[plugins][section-plugins]**
(many plugins you can pick and choose from)
- [x] **[compliant][section-syntax]**
(100% to CommonMark, 100% to GFM with a plugin)
## Contents
- [What is this?](#what-is-this)
- [When should I use this?](#when-should-i-use-this)
- [Install](#install)
- [Use](#use)
- [API](#api)
- [`Markdown`](#markdown)
- [`MarkdownAsync`](#markdownasync)
- [`MarkdownHooks`](#markdownhooks)
- [`defaultUrlTransform(url)`](#defaulturltransformurl)
- [`AllowElement`](#allowelement)
- [`Components`](#components)
- [`ExtraProps`](#extraprops)
- [`HooksOptions`](#hooksoptions)
- [`Options`](#options)
- [`UrlTransform`](#urltransform)
- [Examples](#examples)
- [Use a plugin](#use-a-plugin)
- [Use a plugin with options](#use-a-plugin-with-options)
- [Use custom components (syntax highlight)](#use-custom-components-syntax-highlight)
- [Use remark and rehype plugins (math)](#use-remark-and-rehype-plugins-math)
- [Plugins](#plugins)
- [Syntax](#syntax)
- [Compatibility](#compatibility)
- [Architecture](#architecture)
- [Appendix A: HTML in markdown](#appendix-a-html-in-markdown)
- [Appendix B: Components](#appendix-b-components)
- [Appendix C: line endings in markdown (and JSX)](#appendix-c-line-endings-in-markdown-and-jsx)
- [Security](#security)
- [Related](#related)
- [Contribute](#contribute)
- [License](#license)
## What is this?
This package is a [React][] component that can be given a string of markdown
that itll safely render to React elements.
You can pass plugins to change how markdown is transformed and pass components
that will be used instead of normal HTML elements.
- to learn markdown, see this [cheatsheet and tutorial][commonmark-help]
- to try out `react-markdown`, see [our demo][github-io-react-markdown]
## When should I use this?
There are other ways to use markdown in React out there so why use this one?
The three main reasons are that they often rely on `dangerouslySetInnerHTML`,
have bugs with how they handle markdown, or dont let you swap elements for
components.
`react-markdown` builds a virtual DOM, so React only replaces what changed,
from a syntax tree.
Thats supported because we use [unified][github-unified],
specifically [remark][github-remark] for markdown and [rehype][github-rehype]
for HTML,
which are popular tools to transform content with plugins.
This package focusses on making it easy for beginners to safely use markdown in
React.
When youre familiar with unified, you can use a modern hooks based alternative
[`react-remark`][github-react-remark] or [`rehype-react`][github-rehype-react]
manually.
If you instead want to use JavaScript and JSX _inside_ markdown files, use
[MDX][github-mdx].
## Install
This package is [ESM only][esm].
In Node.js (version 16+), install with [npm][npm-install]:
```sh
npm install react-markdown
```
In Deno with [`esm.sh`][esmsh]:
```js
import Markdown from "https://esm.sh/react-markdown@10"
```
In browsers with [`esm.sh`][esmsh]:
```html
<script type="module">
import Markdown from "https://esm.sh/react-markdown@10?bundle"
</script>
```
## Use
A basic hello world:
```js
import React from "react"
import { createRoot } from "react-dom/client"
import Markdown from "react-markdown"
const markdown = "# Hi, *Pluto*!"
createRoot(document.body).render(<Markdown>{markdown}</Markdown>)
```
<details>
<summary>Show equivalent JSX</summary>
```js
<h1>
Hi, <em>Pluto</em>!
</h1>
```
</details>
Here is an example that shows how to use a plugin
([`remark-gfm`][github-remark-gfm],
which adds support for footnotes, strikethrough, tables, tasklists and
URLs directly):
```js
import React from "react"
import { createRoot } from "react-dom/client"
import Markdown from "react-markdown"
import remarkGfm from "remark-gfm"
const markdown = `Just a link: www.nasa.gov.`
createRoot(document.body).render(<Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>)
```
<details>
<summary>Show equivalent JSX</summary>
```js
<p>
Just a link: <a href="http://www.nasa.gov">www.nasa.gov</a>.
</p>
```
</details>
## API
This package exports the identifiers
[`MarkdownAsync`][api-markdown-async],
[`MarkdownHooks`][api-markdown-hooks],
and
[`defaultUrlTransform`][api-default-url-transform].
The default export is [`Markdown`][api-markdown].
It also exports the additional [TypeScript][] types
[`AllowElement`][api-allow-element],
[`Components`][api-components],
[`ExtraProps`][api-extra-props],
[`HooksOptions`][api-hooks-options],
[`Options`][api-options],
and
[`UrlTransform`][api-url-transform].
### `Markdown`
Component to render markdown.
This is a synchronous component.
When using async plugins,
see [`MarkdownAsync`][api-markdown-async] or
[`MarkdownHooks`][api-markdown-hooks].
###### Parameters
- `options` ([`Options`][api-options])
— props
###### Returns
React element (`ReactElement`).
### `MarkdownAsync`
Component to render markdown with support for async plugins
through async/await.
Components returning promises are supported on the server.
For async support on the client,
see [`MarkdownHooks`][api-markdown-hooks].
###### Parameters
- `options` ([`Options`][api-options])
— props
###### Returns
Promise to a React element (`Promise<ReactElement>`).
### `MarkdownHooks`
Component to render markdown with support for async plugins through hooks.
This uses `useEffect` and `useState` hooks.
Hooks run on the client and do not immediately render something.
For async support on the server,
see [`MarkdownAsync`][api-markdown-async].
###### Parameters
- `options` ([`Options`][api-options])
— props
###### Returns
React node (`ReactNode`).
### `defaultUrlTransform(url)`
Make a URL safe.
This follows how GitHub works.
It allows the protocols `http`, `https`, `irc`, `ircs`, `mailto`, and `xmpp`,
and URLs relative to the current protocol (such as `/something`).
###### Parameters
- `url` (`string`)
— URL
###### Returns
Safe URL (`string`).
### `AllowElement`
Filter elements (TypeScript type).
###### Parameters
- `node` ([`Element` from `hast`][github-hast-element])
— element to check
- `index` (`number | undefined`)
— index of `element` in `parent`
- `parent` ([`Node` from `hast`][github-hast-nodes])
— parent of `element`
###### Returns
Whether to allow `element` (`boolean`, optional).
### `Components`
Map tag names to components (TypeScript type).
###### Type
```ts
import type { ExtraProps } from "react-markdown"
import type { ComponentProps, ElementType } from "react"
type Components = {
[Key in Extract<ElementType, string>]?: ElementType<ComponentProps<Key> & ExtraProps>
}
```
### `ExtraProps`
Extra fields we pass to components (TypeScript type).
###### Fields
- `node` ([`Element` from `hast`][github-hast-element], optional)
— original node
### `HooksOptions`
Configuration for [`MarkdownHooks`][api-markdown-hooks] (TypeScript type);
extends the regular [`Options`][api-options] with a `fallback` prop.
###### Extends
[`Options`][api-options].
###### Fields
- `fallback` (`ReactNode`, optional)
— content to render while the processor processing the markdown
### `Options`
Configuration (TypeScript type).
###### Fields
- `allowElement` ([`AllowElement`][api-allow-element], optional)
— filter elements;
`allowedElements` / `disallowedElements` is used first
- `allowedElements` (`Array<string>`, default: all tag names)
— tag names to allow;
cannot combine w/ `disallowedElements`
- `children` (`string`, optional)
— markdown
- `components` ([`Components`][api-components], optional)
— map tag names to components
- `disallowedElements` (`Array<string>`, default: `[]`)
— tag names to disallow;
cannot combine w/ `allowedElements`
- `rehypePlugins` (`Array<Plugin>`, optional)
— list of [rehype plugins][github-rehype-plugins] to use
- `remarkPlugins` (`Array<Plugin>`, optional)
— list of [remark plugins][github-remark-plugins] to use
- `remarkRehypeOptions`
([`Options` from `remark-rehype`][github-remark-rehype-options],
optional)
— options to pass through to `remark-rehype`
- `skipHtml` (`boolean`, default: `false`)
— ignore HTML in markdown completely
- `unwrapDisallowed` (`boolean`, default: `false`)
— extract (unwrap) whats in disallowed elements;
normally when say `strong` is not allowed, it and its children are dropped,
with `unwrapDisallowed` the element itself is replaced by its children
- `urlTransform` ([`UrlTransform`][api-url-transform], default:
[`defaultUrlTransform`][api-default-url-transform])
— change URLs
### `UrlTransform`
Transform URLs (TypeScript type).
###### Parameters
- `url` (`string`)
— URL
- `key` (`string`, example: `'href'`)
— property name
- `node` ([`Element` from `hast`][github-hast-element])
— element to check
###### Returns
Transformed URL (`string`, optional).
## Examples
### Use a plugin
This example shows how to use a remark plugin.
In this case, [`remark-gfm`][github-remark-gfm],
which adds support for strikethrough, tables, tasklists and URLs directly:
```js
import React from "react"
import { createRoot } from "react-dom/client"
import Markdown from "react-markdown"
import remarkGfm from "remark-gfm"
const markdown = `A paragraph with *emphasis* and **strong importance**.
> A block quote with ~strikethrough~ and a URL: https://reactjs.org.
* Lists
* [ ] todo
* [x] done
A table:
| a | b |
| - | - |
`
createRoot(document.body).render(<Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>)
```
<details>
<summary>Show equivalent JSX</summary>
```js
<>
<p>
A paragraph with <em>emphasis</em> and <strong>strong importance</strong>.
</p>
<blockquote>
<p>
A block quote with <del>strikethrough</del> and a URL:{" "}
<a href="https://reactjs.org">https://reactjs.org</a>.
</p>
</blockquote>
<ul className="contains-task-list">
<li>Lists</li>
<li className="task-list-item">
<input type="checkbox" disabled /> todo
</li>
<li className="task-list-item">
<input type="checkbox" disabled checked /> done
</li>
</ul>
<p>A table:</p>
<table>
<thead>
<tr>
<th>a</th>
<th>b</th>
</tr>
</thead>
</table>
</>
```
</details>
### Use a plugin with options
This example shows how to use a plugin and give it options.
To do that, use an array with the plugin at the first place, and the options
second.
[`remark-gfm`][github-remark-gfm] has an option to allow only double tildes for
strikethrough:
```js
import React from "react"
import { createRoot } from "react-dom/client"
import Markdown from "react-markdown"
import remarkGfm from "remark-gfm"
const markdown = "This ~is not~ strikethrough, but ~~this is~~!"
createRoot(document.body).render(
<Markdown remarkPlugins={[[remarkGfm, { singleTilde: false }]]}>{markdown}</Markdown>
)
```
<details>
<summary>Show equivalent JSX</summary>
```js
<p>
This ~is not~ strikethrough, but <del>this is</del>!
</p>
```
</details>
### Use custom components (syntax highlight)
This example shows how you can overwrite the normal handling of an element by
passing a component.
In this case, we apply syntax highlighting with the seriously super amazing
[`react-syntax-highlighter`][github-react-syntax-highlighter] by
[**@conorhastings**][github-conorhastings]:
<!-- To do: currently broken on actual ESM; lets find an alternative? -->
```js
import React from "react"
import { createRoot } from "react-dom/client"
import Markdown from "react-markdown"
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
import { dark } from "react-syntax-highlighter/dist/esm/styles/prism"
// Did you know you can use tildes instead of backticks for code in markdown? ✨
const markdown = `Here is some JavaScript code:
~~~js
console.log('It works!')
~~~
`
createRoot(document.body).render(
<Markdown
children={markdown}
components={{
code(props) {
const { children, className, node, ...rest } = props
const match = /language-(\w+)/.exec(className || "")
return match ? (
<SyntaxHighlighter
{...rest}
PreTag="div"
children={String(children).replace(/\n$/, "")}
language={match[1]}
style={dark}
/>
) : (
<code {...rest} className={className}>
{children}
</code>
)
},
}}
/>
)
```
<details>
<summary>Show equivalent JSX</summary>
```js
<>
<p>Here is some JavaScript code:</p>
<pre>
<SyntaxHighlighter
language="js"
style={dark}
PreTag="div"
children="console.log('It works!')"
/>
</pre>
</>
```
</details>
### Use remark and rehype plugins (math)
This example shows how a syntax extension
(through [`remark-math`][github-remark-math])
is used to support math in markdown, and a transform plugin
([`rehype-katex`][github-rehype-katex]) to render that math.
```js
import React from "react"
import { createRoot } from "react-dom/client"
import Markdown from "react-markdown"
import rehypeKatex from "rehype-katex"
import remarkMath from "remark-math"
import "katex/dist/katex.min.css" // `rehype-katex` does not import the CSS for you
const markdown = `The lift coefficient ($C_L$) is a dimensionless coefficient.`
createRoot(document.body).render(
<Markdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
{markdown}
</Markdown>
)
```
<details>
<summary>Show equivalent JSX</summary>
```js
<p>
The lift coefficient (
<span className="katex">
<span className="katex-mathml">
<math xmlns="http://www.w3.org/1998/Math/MathML">{/* … */}</math>
</span>
<span className="katex-html" aria-hidden="true">
{/* … */}
</span>
</span>
) is a dimensionless coefficient.
</p>
```
</details>
## Plugins
We use [unified][github-unified],
specifically [remark][github-remark] for markdown and
[rehype][github-rehype] for HTML,
which are tools to transform content with plugins.
Here are three good ways to find plugins:
- [`awesome-remark`][github-awesome-remark] and
[`awesome-rehype`][github-awesome-rehype]
— selection of the most awesome projects
- [List of remark plugins][github-remark-plugins] and
[list of rehype plugins][github-rehype-plugins]
— list of all plugins
- [`remark-plugin`][github-topic-remark-plugin] and
[`rehype-plugin`][github-topic-rehype-plugin] topics
— any tagged repo on GitHub
## Syntax
`react-markdown` follows CommonMark, which standardizes the differences between
markdown implementations, by default.
Some syntax extensions are supported through plugins.
We use [`micromark`][github-micromark] under the hood for our parsing.
See its documentation for more information on markdown, CommonMark, and
extensions.
## Compatibility
Projects maintained by the unified collective are compatible with maintained
versions of Node.js.
When we cut a new major release, we drop support for unmaintained versions of
Node.
This means we try to keep the current release line, `react-markdown@10`,
compatible with Node.js 16.
They work in all modern browsers (essentially: everything not IE 11).
You can use a bundler (such as esbuild, webpack, or Rollup) to use this package
in your project, and use its options (or plugins) to add support for legacy
browsers.
## Architecture
<pre><code> react-markdown
+----------------------------------------------------------------------------------------------------------------+
| |
| +----------+ +----------------+ +---------------+ +----------------+ +------------+ |
| | | | | | | | | | | |
<a href="https://commonmark.org">markdown</a>-+->+ <a href="https://github.com/remarkjs/remark">remark</a> +-<a href="https://github.com/syntax-tree/mdast">mdast</a>->+ <a href="https://github.com/remarkjs/remark/blob/main/doc/plugins.md">remark plugins</a> +-<a href="https://github.com/syntax-tree/mdast">mdast</a>->+ <a href="https://github.com/remarkjs/remark-rehype">remark-rehype</a> +-<a href="https://github.com/syntax-tree/hast">hast</a>->+ <a href="https://github.com/rehypejs/rehype/blob/main/doc/plugins.md">rehype plugins</a> +-<a href="https://github.com/syntax-tree/hast">hast</a>->+ <a href="#appendix-b-components">components</a> +-+->react elements
| | | | | | | | | | | |
| +----------+ +----------------+ +---------------+ +----------------+ +------------+ |
| |
+----------------------------------------------------------------------------------------------------------------+
</code></pre>
To understand what this project does, its important to first understand what
unified does: please read through the [`unifiedjs/unified`][github-unified]
readme
(the part until you hit the API section is required reading).
`react-markdown` is a unified pipeline — wrapped so that most folks dont need
to directly interact with unified.
The processor goes through these steps:
- parse markdown to mdast (markdown syntax tree)
- transform through remark (markdown ecosystem)
- transform mdast to hast (HTML syntax tree)
- transform through rehype (HTML ecosystem)
- render hast to React with components
## Appendix A: HTML in markdown
`react-markdown` typically escapes HTML (or ignores it, with `skipHtml`)
because it is dangerous and defeats the purpose of this library.
However, if you are in a trusted environment (you trust the markdown), and
can spare the bundle size (±60kb minzipped), then you can use
[`rehype-raw`][github-rehype-raw]:
```js
import React from "react"
import { createRoot } from "react-dom/client"
import Markdown from "react-markdown"
import rehypeRaw from "rehype-raw"
const markdown = `<div class="note">
Some *emphasis* and <strong>strong</strong>!
</div>`
createRoot(document.body).render(<Markdown rehypePlugins={[rehypeRaw]}>{markdown}</Markdown>)
```
<details>
<summary>Show equivalent JSX</summary>
```js
<div className="note">
<p>
Some <em>emphasis</em> and <strong>strong</strong>!
</p>
</div>
```
</details>
**Note**: HTML in markdown is still bound by how [HTML works in
CommonMark][commonmark-html].
Make sure to use blank lines around block-level HTML that again contains
markdown!
## Appendix B: Components
You can also change the things that come from markdown:
```js
<Markdown
components={{
// Map `h1` (`# heading`) to use `h2`s.
h1: "h2",
// Rewrite `em`s (`*like so*`) to `i` with a red foreground color.
em(props) {
const { node, ...rest } = props
return <i style={{ color: "red" }} {...rest} />
},
}}
/>
```
The keys in components are HTML equivalents for the things you write with
markdown (such as `h1` for `# heading`).
Normally, in markdown, those are: `a`, `blockquote`, `br`, `code`, `em`, `h1`,
`h2`, `h3`, `h4`, `h5`, `h6`, `hr`, `img`, `li`, `ol`, `p`, `pre`, `strong`, and
`ul`.
With [`remark-gfm`][github-remark-gfm],
you can also use `del`, `input`, `table`, `tbody`, `td`, `th`, `thead`, and `tr`.
Other remark or rehype plugins that add support for new constructs will also
work with `react-markdown`.
The props that are passed are what you probably would expect: an `a` (link) will
get `href` (and `title`) props, and `img` (image) an `src`, `alt` and `title`,
etc.
Every component will receive a `node`.
This is the original [`Element` from `hast`][github-hast-element] element being
turned into a React element.
## Appendix C: line endings in markdown (and JSX)
You might have trouble with how line endings work in markdown and JSX.
We recommend the following, which solves all line ending problems:
```js
// If you write actual markdown in your code, put your markdown in a variable;
// **do not indent markdown**:
const markdown = `
# This is perfect!
`
// Pass the value as an expression as an only child:
const result = <Markdown>{markdown}</Markdown>
```
👆 That works.
Read on for what doesnt and why that is.
You might try to write markdown directly in your JSX and find that it **does
not** work:
```js
<Markdown># Hi This is **not** a paragraph.</Markdown>
```
The is because in JSX the whitespace (including line endings) is collapsed to
a single space.
So the above example is equivalent to:
```js
<Markdown> # Hi This is **not** a paragraph. </Markdown>
```
Instead, to pass markdown to `Markdown`, you can use an expression:
with a template literal:
```js
<Markdown>{`
# Hi
This is a paragraph.
`}</Markdown>
```
Template literals have another potential problem, because they keep whitespace
(including indentation) inside them.
That means that the following **does not** turn into a heading:
```js
<Markdown>{`
# This is **not** a heading, its an indented code block
`}</Markdown>
```
## Security
Use of `react-markdown` is secure by default.
Overwriting `urlTransform` to something insecure will open you up to XSS
vectors.
Furthermore, the `remarkPlugins`, `rehypePlugins`, and `components` you use may
be insecure.
To make sure the content is completely safe, even after what plugins do,
use [`rehype-sanitize`][github-rehype-sanitize].
It lets you define your own schema of what is and isnt allowed.
## Related
- [`MDX`][github-mdx]
— JSX _in_ markdown
- [`remark-gfm`][github-remark-gfm]
— add support for GitHub flavored markdown support
- [`react-remark`][github-react-remark]
— hook based alternative
- [`rehype-react`][github-rehype-react]
— turn HTML into React elements
## Contribute
See [`contributing.md`][health-contributing] in [`remarkjs/.github`][health]
for ways to get started.
See [`support.md`][health-support] for ways to get help.
This project has a [code of conduct][health-coc].
By interacting with this repository, organization, or community you agree to
abide by its terms.
## License
[MIT][file-license] © [Espen Hovlandsdal][author]
[api-allow-element]: #allowelement
[api-components]: #components
[api-default-url-transform]: #defaulturltransformurl
[api-extra-props]: #extraprops
[api-hooks-options]: #hooksoptions
[api-markdown]: #markdown
[api-markdown-async]: #markdownasync
[api-markdown-hooks]: #markdownhooks
[api-options]: #options
[api-url-transform]: #urltransform
[author]: https://espen.codes/
[badge-build-image]: https://github.com/remarkjs/react-markdown/workflows/main/badge.svg
[badge-build-url]: https://github.com/remarkjs/react-markdown/actions
[badge-coverage-image]: https://img.shields.io/codecov/c/github/remarkjs/react-markdown.svg
[badge-coverage-url]: https://codecov.io/github/remarkjs/react-markdown
[badge-downloads-image]: https://img.shields.io/npm/dm/react-markdown.svg
[badge-downloads-url]: https://www.npmjs.com/package/react-markdown
[badge-size-image]: https://img.shields.io/bundlejs/size/react-markdown
[badge-size-url]: https://bundlejs.com/?q=react-markdown
[commonmark-help]: https://commonmark.org/help/
[commonmark-html]: https://spec.commonmark.org/0.31.2/#html-blocks
[esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c
[esmsh]: https://esm.sh
[file-license]: license
[github-awesome-rehype]: https://github.com/rehypejs/awesome-rehype
[github-awesome-remark]: https://github.com/remarkjs/awesome-remark
[github-conorhastings]: https://github.com/conorhastings
[github-hast-element]: https://github.com/syntax-tree/hast#element
[github-hast-nodes]: https://github.com/syntax-tree/hast#nodes
[github-io-react-markdown]: https://remarkjs.github.io/react-markdown/
[github-mdx]: https://github.com/mdx-js/mdx/
[github-micromark]: https://github.com/micromark/micromark
[github-react-remark]: https://github.com/remarkjs/react-remark
[github-react-syntax-highlighter]: https://github.com/react-syntax-highlighter/react-syntax-highlighter
[github-rehype]: https://github.com/rehypejs/rehype
[github-rehype-katex]: https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex
[github-rehype-plugins]: https://github.com/rehypejs/rehype/blob/main/doc/plugins.md#list-of-plugins
[github-rehype-raw]: https://github.com/rehypejs/rehype-raw
[github-rehype-react]: https://github.com/rehypejs/rehype-react
[github-rehype-sanitize]: https://github.com/rehypejs/rehype-sanitize
[github-remark]: https://github.com/remarkjs/remark
[github-remark-gfm]: https://github.com/remarkjs/remark-gfm
[github-remark-math]: https://github.com/remarkjs/remark-math
[github-remark-plugins]: https://github.com/remarkjs/remark/blob/main/doc/plugins.md#list-of-plugins
[github-remark-rehype-options]: https://github.com/remarkjs/remark-rehype#options
[github-topic-rehype-plugin]: https://github.com/topics/rehype-plugin
[github-topic-remark-plugin]: https://github.com/topics/remark-plugin
[github-unified]: https://github.com/unifiedjs/unified
[health]: https://github.com/remarkjs/.github
[health-coc]: https://github.com/remarkjs/.github/blob/main/code-of-conduct.md
[health-contributing]: https://github.com/remarkjs/.github/blob/main/contributing.md
[health-support]: https://github.com/remarkjs/.github/blob/main/support.md
[npm-install]: https://docs.npmjs.com/cli/install
[react]: http://reactjs.org
[section-components]: #appendix-b-components
[section-plugins]: #plugins
[section-security]: #security
[section-syntax]: #syntax
[typescript]: https://www.typescriptlang.org
+10
View File
@@ -0,0 +1,10 @@
# React Router route 정리 참고
출처: https://reactrouter.com/api/hooks/useRoutes
- `useRoutes(routes)``RouteObject[]`로 route 트리를 만들고 현재 URL과 맞는 element를 반환함.
- 부모 route의 `children`은 중첩 UI를 구성하며 부모 element의 `Outlet` 위치에 렌더됨.
- 선언한 route와 맞지 않는 URL은 마지막 `path: "*"` route에서 `Navigate`로 기본 화면에 보낼 수 있음.
- `Navigate``replace`는 잘못된 URL을 브라우저 방문 기록에 남기지 않을 때 사용함.
이 프로젝트에서는 `routes.tsx`가 유일한 route 트리이며, `PATHS`가 주소 문자열의 진실원천임.
+18
View File
@@ -0,0 +1,18 @@
# TanStack Query v5 참조
출처: https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries
확인일: 2026-09-11
## enabled와 초기 로딩
- `enabled: false`이고 캐시가 없으면 `status === "pending"`, `fetchStatus === "idle"`임.
- `isLoading``isPending && isFetching`이라 실제 첫 fetch 중일 때만 참임.
- 조건이 충족된 뒤 자동 fetch해야 하면 `enabled`에 조건을 넘김.
```tsx
const query = useQuery({
queryKey: ["todos", filter],
queryFn: () => fetchTodos(filter),
enabled: Boolean(filter),
})
```
+192
View File
@@ -0,0 +1,192 @@
# @tauri-apps/api v2 — 프론트가 쓸 표면 박제
**작성**: 2026-08-15 | **근거**: `specs/004-tauri-shell/research.md` R3
**여기 적힌 건 전부 `node_modules/@tauri-apps/api/` 의 실제 `.d.ts`·`.js` 에서 뽑은 것이다.** 공식 가이드 예제는 옛 버전이 섞여 있어 시그니처 근거로 안 쓴다.
**박제 기준 버전: 2.11.1** (`package.json``^2.11.1`)
우리가 쓰는 건 딱 두 개 — `invoke`(JS→Rust)와 `listen`(Rust→JS). 나머지 모듈(`window`, `menu`, `tray`, `path` …)은 **안 쓴다.** 창·트레이·핫키는 전부 Rust 쪽에서 하고, 프론트는 우리가 만든 `#[tauri::command]` 만 부른다.
---
## 1. `invoke` — JS → Rust
```ts
// node_modules/@tauri-apps/api/core.d.ts:127
declare function invoke<T>(cmd: string, args?: InvokeArgs, options?: InvokeOptions): Promise<T>
// core.d.ts:105
type InvokeArgs = Record<string, unknown> | number[] | ArrayBuffer | Uint8Array
```
```ts
import { invoke } from "@tauri-apps/api/core"
await invoke<Snippet[]>("snippets_list")
await invoke("paste_code", { text })
```
- **`Promise` 를 돌려준다** → 요청↔응답 짝맞춤이 공짜. `snippetBridge.ts``reqId`·`pending` Map 이 새 껍데기 경로에선 통째로 필요 없어지는 근거 (research R6)
- Rust 커맨드가 `Err(String)` 을 주면 **그 문자열로 reject** 된다 → 한글 오류 메시지가 그대로 `Error` 로 올라와 sonner 토스트까지 흐름 (`contracts/transport-mapping.md` §3)
- 인자 이름은 Rust 커맨드의 파라미터 이름과 맞아야 한다 (`paste_code(text: String)``{ text }`)
### 속을 보면
```js
// core.js:201
async function invoke(cmd, args = {}, options) {
return window.__TAURI_INTERNALS__.invoke(cmd, args, options)
}
```
**`window.__TAURI_INTERNALS__.invoke` 를 그대로 부른다.** 이게 아래 호스트 판별에서 중요해진다.
---
## 2. `listen` — Rust → JS
```ts
// event.d.ts:87
declare function listen<T>(
event: EventName,
handler: EventCallback<T>,
options?: Options
): Promise<UnlistenFn>
// event.d.ts:25,33,34
interface Event<T> {
event: string
id: number
payload: T
}
type EventCallback<T> = (event: Event<T>) => void
type UnlistenFn = () => void
```
```ts
import { listen } from "@tauri-apps/api/event"
const unlisten = await listen<BridgeMessage>("bridge", (e) => {
handle(e.payload) // ← 페이로드는 e.payload 안에 있다
})
```
### ⚠️ 함정 2개
**(1) 페이로드가 한 겹 더 들어있다.** WebView2 는 `event.data` 가 곧 메시지인데, Tauri 는 `Event<T>` 로 감싸서 `event.payload` 안에 있다. `bridgeNavigate.ts` 의 분기 본문을 그대로 재사용하려면 **transport 층에서 `e.payload` 를 벗겨서** 넘겨야 한다 (research R5 가 노린 "분기 로직 무변경"이 이 한 줄에 달림).
**(2) `listen` 은 async 다.** `Promise<UnlistenFn>` 을 돌려준다.
지금 `bridgeNavigate.ts``initBridgeNavigate()` 는 **동기 함수**고, WebView2 의 `addEventListener` 는 즉시 붙는다. Tauri 경로는 그렇지 않아서:
- `initBridgeNavigate()` 를 async 로 바꾸면 **호출부(앱 루트)가 바뀐다** → SC-001("통로 파일 바깥 변경 0줄") 위반
- 그래서 **transport 안에서 Promise 를 삼키고**(fire-and-forget) 밖에는 동기 시그니처를 유지하는 쪽이 맞다
- 대신 **리스너 붙기 전에 도착한 푸시는 놓친다.** 다행히 이 코드에는 이미 대비가 있다 — `lastPasteTarget` 스냅샷과 `pendingCaptureImage` 의 read-once 소비가 마운트 레이스를 막으려고 들어간 것이라, 같은 장치가 여기서도 먹는다
관련: `once`(1회), `emit`/`emitTo`(JS→Rust 이벤트) 도 있지만 **우리는 안 쓴다.** 계약상 JS→Rust 는 전부 `invoke` (transport-mapping §1).
---
## 3. 호스트 판별 — `isTauri()` 를 쓰지 말 것
패키지가 공식 함수를 하나 주긴 한다:
```js
// core.js:278
function isTauri() {
return !!(globalThis || window).isTauri
}
```
**이건 `window.isTauri` 라는 별개 플래그를 볼 뿐, `invoke` 가 실제로 쓰는 `__TAURI_INTERNALS__` 와 다르다.**
research R4 가 정한 `__TAURI_INTERNALS__` 기준이 맞다 — `invoke` 가 바로 그걸 부르므로, **"invoke 를 부를 수 있는가"를 직접 재는 셈**이라 한 단계 더 정확하다.
### 그런데 `"__TAURI_INTERNALS__" in window` 도 부족하다 ⚠️
테스트용 `clearMocks()` 를 뜯어보면:
```js
// mocks.js:4 — mockIPC 가 부르는 것
function mockInternals() {
window.__TAURI_INTERNALS__ = window.__TAURI_INTERNALS__ ?? {}
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = window.__TAURI_EVENT_PLUGIN_INTERNALS__ ?? {}
}
// mocks.js:267 — clearMocks
function clearMocks() {
if (typeof window.__TAURI_INTERNALS__ !== "object") return
delete window.__TAURI_INTERNALS__.invoke
delete window.__TAURI_INTERNALS__.transformCallback
// ... 속성만 지운다. window.__TAURI_INTERNALS__ 객체 자체는 안 지움
}
```
**`clearMocks()` 는 속성만 지우고 빈 객체를 남긴다.** 그래서:
| 판별식 | mockIPC 후 | clearMocks 후 | 판정 |
| ---------------------------------------------------------- | ---------- | ---------------- | ----------------------------------------- |
| `"__TAURI_INTERNALS__" in window` | true | **true (틀림)** | ❌ 브라우저 모드 테스트가 tauri 로 오판됨 |
| `typeof window.__TAURI_INTERNALS__?.invoke === "function"` | true | **false (맞음)** | ✅ |
`in` 으로 재면 "브라우저에서는 조용히 no-op / 즉시 reject"(FR-003) 테스트가 **tauri 경로를 타서 `__TAURI_INTERNALS__.invoke is not a function` 으로 터진다.** 우리가 의도한 한글 "데스크톱 전용" reject 가 아니라 엉뚱한 TypeError.
**결론: `.invoke` 가 함수인지로 판별한다.** 실제 앱에서도 Tauri 가 앱 스크립트보다 먼저 내부 객체를 통째로 주입하므로 안전하고, 의미도 더 정확하다("통로가 있나"가 아니라 "통로가 작동하나").
---
## 4. 테스트 — 공식 mock 이 있다
```ts
// mocks.d.ts
export declare function mockIPC(
cb: (cmd: string, payload?: InvokeArgs) => unknown,
options?: MockIPCOptions
): void
export declare function clearMocks(): void
export declare function mockWindows(current: string, ..._additionalWindows: string[]): void
export declare function mockConvertFileSrc(osName: string): void
export interface MockIPCOptions {
shouldMockEvents?: boolean
} // 2.7.0+
```
```ts
import { mockIPC, clearMocks } from "@tauri-apps/api/mocks"
afterEach(() => clearMocks())
it("snippets_list 를 부른다", async () => {
mockIPC((cmd) => (cmd === "snippets_list" ? [] : undefined))
await expect(snippetsApi.list()).resolves.toEqual([])
})
```
**`shouldMockEvents: true` 를 주면 `listen`/`emit` 도 mock 된다** — Rust 푸시(`emit("bridge", ...)`)를 흉내내 `bridgeNavigate` 분기를 테스트할 수 있다.
```ts
mockIPC(() => {}, { shouldMockEvents: true })
// 이제 emit('bridge', {...}) 하면 listen 핸들러가 불림
```
> ⚠️ 주의: `plugin:event|` 로 시작하는 invoke 를 **전부 가로챈다**(mocks.js:89). `shouldMockEvents` 를 켠 테스트에서는 이벤트 관련 invoke 가 우리 mock 콜백에 안 온다.
### 세 호스트 테스트할 때 (T012)
`mockIPC`**테스트 본문에서 런타임에** `window.__TAURI_INTERNALS__` 를 심는다. 기존 브릿지 테스트가 `window.chrome` 을 런타임에 심는 것과 똑같은 방식이다.
**transport 가 모듈 로드 시점에 호스트를 상수로 굳히면 이 mock 들이 전부 안 먹는다.** research R4 의 "한 번 판별하고 굳힌다"를 문자 그대로 구현하면 테스트 3개 + 신규 테스트가 다 깨진다. 판별 시점을 **최초 사용 시 1회**로 하거나, 굳히되 테스트용 재판별 훅을 같이 내야 한다 (`specs/004-tauri-shell/tasks.md` T015 가 이 결정을 먼저 하라고 박아둔 이유).
`clearMocks()` 를 쓸 거면 위 §3 의 `.invoke` 판별식이 **필수**다.
---
## 5. 안 쓰는 것
패키지에 이만큼 더 있지만 이 기능에서는 **안 쓴다** — 창·트레이·핫키·경로는 전부 Rust 담당이고, 프론트는 우리 커맨드만 부른다 (`contracts/transport-mapping.md`).
`window`, `webviewWindow`, `webview`, `menu`, `tray`, `path`, `app`, `dpi`, `image`
혹시 쓰게 되면 **`capabilities/default.json` 에 권한을 추가해야 한다.** 지금은 `core:default` 하나뿐이라 그것들은 **런타임에 조용히 거부**된다 (컴파일 에러 안 남). 자세한 건 `4_rust_tauri/docs-lib/tauri-v2.md` §9.
+53
View File
@@ -0,0 +1,53 @@
import js from "@eslint/js"
import globals from "globals"
import reactHooks from "eslint-plugin-react-hooks"
import reactRefresh from "eslint-plugin-react-refresh"
import jsxA11y from "eslint-plugin-jsx-a11y"
import tseslint from "typescript-eslint"
import prettierConfig from "eslint-config-prettier"
export default tseslint.config(
{ ignores: ["dist", "dist-tsbuild", "node_modules", "coverage"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: { ecmaVersion: 2022, globals: globals.browser },
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
"jsx-a11y": jsxA11y,
},
rules: {
...reactHooks.configs.recommended.rules,
...jsxA11y.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
},
},
{
files: ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
{
// shadcn/ui generated components — third-party patterns, a11y rules relaxed
files: ["src/shared/ui/**/*.{ts,tsx}"],
rules: {
"jsx-a11y/heading-has-content": "off",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/no-noninteractive-element-interactions": "off",
"jsx-a11y/no-static-element-interactions": "off",
"react-refresh/only-export-components": "off",
},
},
{
// TanStack Table column defs — meta cell renderers can have click handlers
files: ["src/features/**/components/*Columns.tsx"],
rules: {
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/no-static-element-interactions": "off",
},
},
prettierConfig
)
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Frontend Template</title>
<script>
// FOUC 방지 — React 마운트 전 [data-theme] + .dark 미리 박음.
// 팔레트는 localStorage 'theme' 키, 다크모드는 'theme-store' (zustand persist).
;(function () {
try {
var pal = localStorage.getItem("theme") || "clean-blue"
// 이전 기본 베이지는 한 번만 교체. 이후 직접 고른 팔레트는 유지.
if (!localStorage.getItem("clean-blue-default-v1")) {
if (pal === "warm-tan") {
pal = "clean-blue"
localStorage.setItem("theme", pal)
}
localStorage.setItem("clean-blue-default-v1", "1")
}
document.documentElement.setAttribute("data-theme", pal)
var raw = localStorage.getItem("theme-store")
var mode = "light"
if (raw) {
try {
mode = (JSON.parse(raw).state || {}).theme || "light"
} catch (e) {}
}
var dark =
mode === "dark" ||
(mode === "system" &&
window.matchMedia &&
matchMedia("(prefers-color-scheme: dark)").matches)
if (dark) document.documentElement.classList.add("dark")
document.documentElement.style.colorScheme = dark ? "dark" : "light"
} catch (e) {}
})()
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+10694
View File
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
{
"name": "frontend-template",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"test": "vitest run",
"test:watch": "vitest",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui"
},
"dependencies": {
"@azure/msal-browser": "^3.30.0",
"@fontsource-variable/geist": "^5.2.8",
"@hookform/resolvers": "^3.9.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.59.0",
"@tauri-apps/api": "^2.11.1",
"axios": "^1.16.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"highlight.js": "^11.11.1",
"idb-keyval": "^6.3.0",
"lucide-react": "^0.453.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.27.0",
"remark-gfm": "^4.0.1",
"sonner": "^1.7.4",
"tailwind-merge": "^2.5.4",
"zod": "^3.23.8",
"zustand": "^5.0.0"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.2.4",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^22.7.5",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.2",
"axios-mock-adapter": "^2.1.0",
"eslint": "^9.12.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.37.1",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.11.0",
"jsdom": "^25.0.1",
"msw": "^2.14.3",
"postcss": "^8.4.47",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.8",
"tailwindcss": "^4.2.4",
"tw-animate-css": "^1.4.0",
"typescript": "^5.6.3",
"typescript-eslint": "^8.8.1",
"vite": "^5.4.8",
"vitest": "^2.1.2"
}
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig, devices } from "@playwright/test"
const PORT = Number(process.env.PORT ?? 5173)
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${PORT}`
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "list",
use: {
baseURL: BASE_URL,
trace: "on-first-retry",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: "npm run dev",
url: BASE_URL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
+9101
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0f172a"/>
<text x="50%" y="50%" dy="0.35em" text-anchor="middle"
font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"
font-weight="700" font-size="36" fill="#38bdf8">F</text>
</svg>

After

Width:  |  Height:  |  Size: 344 B

+31
View File
@@ -0,0 +1,31 @@
import { useEffect } from "react"
import { useLocation, useNavigate, useRoutes } from "react-router-dom"
import { routes } from "./routes"
import { ErrorBoundary } from "./shared/components/ErrorBoundary"
import { initBridgeNavigate, setBridgeNavigate } from "@/lib/bridge/bridgeNavigate"
import { reportRoute } from "@/lib/bridge/webviewBridge"
import { DesktopWindowFrame } from "@/shared/components/DesktopWindowFrame"
export default function App() {
const element = useRoutes(routes)
const navigate = useNavigate()
const location = useLocation()
// C#가 navigate 푸시(예: Ctrl+Shift+7 → /snippet)를 보내면 이 콜백으로 라우팅.
useEffect(() => {
setBridgeNavigate(navigate)
initBridgeNavigate()
return () => setBridgeNavigate(null)
}, [navigate])
// route 바뀔 때마다 호스트에 보고 — C#가 마지막 챗봇 위치 기억(Ctrl+Shift+8 복귀용).
useEffect(() => {
reportRoute(location.pathname)
}, [location.pathname])
return (
<DesktopWindowFrame>
<ErrorBoundary>{element}</ErrorBoundary>
</DesktopWindowFrame>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest"
import { parseEnv } from "./env"
describe("parseEnv", () => {
it("VITE_API_BASE_URL 필수", () => {
expect(() => parseEnv({})).toThrow()
})
it("올바른 풀 URL 통과", () => {
const env = parseEnv({ VITE_API_BASE_URL: "http://localhost:8001/api" })
expect(env.apiBaseUrl).toBe("http://localhost:8001/api")
})
it("'/'로 시작하는 상대경로 통과 (vite proxy 전제)", () => {
const env = parseEnv({ VITE_API_BASE_URL: "/api/v1" })
expect(env.apiBaseUrl).toBe("/api/v1")
})
it("URL도 '/' 시작도 아니면 실패", () => {
expect(() => parseEnv({ VITE_API_BASE_URL: "not-a-url" })).toThrow()
})
it("앞 '/' 없는 상대경로 실패", () => {
expect(() => parseEnv({ VITE_API_BASE_URL: "api/v1" })).toThrow()
})
it("빈 문자열 실패", () => {
expect(() => parseEnv({ VITE_API_BASE_URL: "" })).toThrow()
})
it("VITE_ENTRA_GRAPH_SCOPE 미지정 시 'User.Read' 기본값", () => {
const env = parseEnv({ VITE_API_BASE_URL: "/api/v1" })
expect(env.entraGraphScope).toBe("User.Read")
})
it("VITE_ENTRA_GRAPH_SCOPE 커스텀 값 통과", () => {
const env = parseEnv({
VITE_API_BASE_URL: "/api/v1",
VITE_ENTRA_GRAPH_SCOPE: "User.ReadBasic.All",
})
expect(env.entraGraphScope).toBe("User.ReadBasic.All")
})
})
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod"
// dev/prd 모두 single-origin 전제라 '/api/v1' 같은 상대경로가 기본.
// 다른 origin 직접 호출 시나리오만 풀 URL 허용.
const envSchema = z.object({
VITE_API_BASE_URL: z.union([
z.string().url(),
z.string().regex(/^\/[^\s]*$/, "must be absolute URL or path starting with '/'"),
]),
VITE_ENTRA_GRAPH_SCOPE: z.string().min(1).default("User.Read"),
})
export interface AppEnv {
apiBaseUrl: string
entraGraphScope: string
}
export function parseEnv(raw: Record<string, unknown>): AppEnv {
const parsed = envSchema.parse(raw)
return {
apiBaseUrl: parsed.VITE_API_BASE_URL,
entraGraphScope: parsed.VITE_ENTRA_GRAPH_SCOPE,
}
}
export const env: AppEnv = parseEnv(import.meta.env)
+10
View File
@@ -0,0 +1,10 @@
export const PATHS = {
HOME: "/snap",
LOGIN: "/login",
SNAP: "/snap",
SNAP_NEW: "/snap/new",
SNAP_SESSION: "/snap/s/:id",
SNIPPET: "/snippet",
} as const
export type Path = (typeof PATHS)[keyof typeof PATHS]
@@ -0,0 +1,151 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"
import MockAdapter from "axios-mock-adapter"
import { authApi } from "./auth.api"
import { apiClient } from "@/lib/api/client"
const fakeToken = {
token: "a",
tokenExpirationTime: 0,
refreshToken: "r",
refreshTokenExpirationTime: 0,
tokenType: "bearer",
user: { id: "u", email: "x@x.com", userName: null, role: "USER" as const },
}
function envelope<T>(data: T) {
return {
success: true,
statusCode: 200,
code: null,
message: null,
data,
counts: null,
errors: [] as string[],
timestamp: "2026-01-01T00:00:00Z",
meta: null,
}
}
let mock: MockAdapter
beforeEach(() => {
mock = new MockAdapter(apiClient)
})
afterEach(() => {
mock.restore()
})
describe("authApi", () => {
it("login POST /auth/login → user 반환 (token은 쿠키로 처리)", async () => {
let body: unknown
mock.onPost("/auth/login").reply((config) => {
body = JSON.parse(config.data as string)
return [200, envelope(fakeToken)]
})
const user = await authApi.login({ email: "x@x.com", password: "abcd" })
expect(user).toEqual(fakeToken.user)
expect(body).toEqual({ email: "x@x.com", password: "abcd" })
})
it("refresh POST /auth/refresh — body 없음", async () => {
let body: unknown
mock.onPost("/auth/refresh").reply((config) => {
body = config.data
return [200, envelope(fakeToken)]
})
await authApi.refresh()
expect(body).toBeUndefined()
})
it("logout POST /auth/logout — __skipAuth로 401에서도 인터셉터 우회", async () => {
let called = false
mock.onPost("/auth/logout").reply(() => {
called = true
return [200, envelope(null)]
})
await authApi.logout()
expect(called).toBe(true)
})
it("getMe GET /users/me", async () => {
mock.onGet("/users/me").reply(200, envelope({ ...fakeToken.user, isActive: true }))
const me = await authApi.getMe()
expect(me.email).toBe("x@x.com")
})
})
describe("entraLogin", () => {
it("POST /auth/entra/login 후 응답 user를 반환", async () => {
const user = {
id: "u1",
email: "a@b.com",
userName: "A",
role: "USER" as const,
employeeId: "EMP1",
department: "IT",
authProvider: "entra" as const,
}
mock.onPost("/auth/entra/login").reply(200, {
success: true,
statusCode: 200,
code: null,
message: null,
data: {
token: "t",
tokenExpirationTime: 1,
refreshToken: "r",
refreshTokenExpirationTime: 2,
tokenType: "bearer",
user,
},
counts: null,
errors: [],
timestamp: "",
meta: null,
})
const result = await authApi.entraLogin({ idToken: "ID", graphAccessToken: "G" })
expect(result).toEqual(user)
})
})
describe("getEntraConfig", () => {
it("200 응답 → config 반환", async () => {
const cfg = { clientId: "c", authority: "https://...", tenantId: "t" }
mock.onGet("/auth/entra/config").reply(200, {
success: true,
statusCode: 200,
code: null,
message: null,
data: cfg,
counts: null,
errors: [],
timestamp: "",
meta: null,
})
const result = await authApi.getEntraConfig()
expect(result).toEqual(cfg)
})
it("501 응답 → null 반환 (throw 안 함)", async () => {
mock.onGet("/auth/entra/config").reply(501, {
success: false,
statusCode: 501,
code: "ENTRA_NOT_CONFIGURED",
message: "Entra ID SSO 가 설정되지 않았습니다.",
data: null,
counts: null,
errors: [],
timestamp: "",
meta: null,
})
const result = await authApi.getEntraConfig()
expect(result).toBeNull()
})
it("500 응답 → throw", async () => {
mock.onGet("/auth/entra/config").reply(500)
await expect(authApi.getEntraConfig()).rejects.toThrow()
})
})
@@ -0,0 +1,62 @@
import { apiPost, apiGet, type CallerConfig } from "@/lib/api/client"
import { ApiError } from "@/lib/api/errors"
import type {
LoginRequest,
TokenResponse,
UserResponse,
UserPayload,
EntraLoginRequest,
EntraConfigResponse,
} from "@/types/api"
const SKIP_AUTH: CallerConfig = { __skipAuth: true }
/**
* API. axios apiClient ( ).
*
* - login: 응답 body의 user만 ( Set-Cookie로 )
* - refresh: 쿠키만으로 (body ). X interceptor에서
* - logout: 인증 . + store도
* - getMe: 마운트 user
*/
export const authApi = {
/**
* __skipAuth: 로그인 401 refresh .
* credentials 401 refresh sessionExpiry .
*/
login: async (req: LoginRequest): Promise<UserPayload> => {
const tokens = await apiPost<TokenResponse>("/auth/login", req, SKIP_AUTH)
return tokens.user
},
/**
* refresh . refresh와 (sliding refresh ).
* ApiError throw. Set-Cookie로 .
*/
refresh: async (): Promise<void> => {
await apiPost<TokenResponse>("/auth/refresh", undefined)
},
/** 인증 불필요 (`__skipAuth`) — 401에서 모달 안 뜨도록 우회. */
logout: () => apiPost<null>("/auth/logout", undefined, SKIP_AUTH),
getMe: () => apiGet<UserResponse>("/users/me", { __skipSessionExpiry: true }),
/**
* __skipAuth: ENTRA_TOKEN_INVALID(401) refresh .
* Microsoft 401 refresh sessionExpiry .
*/
entraLogin: async (req: EntraLoginRequest): Promise<UserPayload> => {
const tokens = await apiPost<TokenResponse>("/auth/entra/login", req, SKIP_AUTH)
return tokens.user
},
getEntraConfig: async (): Promise<EntraConfigResponse | null> => {
try {
return await apiGet<EntraConfigResponse>("/auth/entra/config", SKIP_AUTH)
} catch (e) {
if (e instanceof ApiError && e.status === 501) return null
throw e
}
},
}
@@ -0,0 +1,129 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { toast } from "sonner"
import { AuthError } from "@azure/msal-browser"
import { ApiError } from "@/lib/api/errors"
import { PATHS } from "@/config/routes"
import EntraLoginButton from "./EntraLoginButton"
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }))
const navigateMock = vi.fn()
const invalidateQueries = vi.fn()
vi.mock("react-router-dom", async (orig) => {
const m: any = await orig()
return { ...m, useNavigate: () => navigateMock }
})
const mutate = vi.fn()
let mutationState = { isPending: false }
vi.mock("../hooks/useEntraLogin", () => ({
useEntraLogin: () => ({
mutate,
get isPending() {
return mutationState.isPending
},
}),
}))
vi.mock("@tanstack/react-query", async (orig) => {
const m: any = await orig()
return { ...m, useQueryClient: () => ({ invalidateQueries }) }
})
function renderBtn(search = "") {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[`/login${search}`]}>
<EntraLoginButton />
</MemoryRouter>
</QueryClientProvider>
)
}
beforeEach(() => {
vi.clearAllMocks()
mutationState = { isPending: false }
})
describe("EntraLoginButton", () => {
it("버튼 렌더 + 텍스트", () => {
renderBtn()
expect(screen.getByRole("button", { name: /Microsoft로 로그인/ })).toBeInTheDocument()
})
it("isPending 동안 disabled + '로그인 중...'", () => {
mutationState.isPending = true
renderBtn()
const btn = screen.getByRole("button")
expect(btn).toBeDisabled()
expect(btn).toHaveTextContent("로그인 중...")
})
it("클릭 → mutate 호출 (from 정상 → onSuccess에서 navigate)", async () => {
renderBtn("?from=/snap/new")
await userEvent.click(screen.getByRole("button"))
expect(mutate).toHaveBeenCalledOnce()
// onSuccess 콜백 실행 시뮬레이션
const [, opts] = mutate.mock.calls[0]
opts.onSuccess({ id: "u" })
expect(navigateMock).toHaveBeenCalledWith(PATHS.SNAP_NEW, { replace: true })
})
it("from=외부 도메인 → navigate PATHS.SNAP (safeRedirectPath 보호)", async () => {
renderBtn("?from=//evil.com")
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
opts.onSuccess({ id: "u" })
expect(navigateMock).toHaveBeenCalledWith(PATHS.SNAP, { replace: true })
})
it("onError: user_cancelled → toast 안 띄움", async () => {
renderBtn()
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
opts.onError({ errorCode: "user_cancelled" })
expect(toast.error).not.toHaveBeenCalled()
})
it("onError: popup_window_error → 팝업 차단 토스트", async () => {
renderBtn()
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
opts.onError(new AuthError("popup_window_error"))
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/팝업/))
})
it("onError: ApiError 401 → 인증 실패 안내 토스트", async () => {
renderBtn()
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
const err = new ApiError(401, "Microsoft 인증 실패", [], "AUTH_FAIL")
opts.onError(err)
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/Microsoft 인증에 실패/))
expect(invalidateQueries).not.toHaveBeenCalled()
})
it("onError: ApiError 501 → invalidateQueries + 비활성 안내 토스트", async () => {
renderBtn()
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
const err = new ApiError(501, "Entra 비활성", [], "ENTRA_NOT_CONFIGURED")
opts.onError(err)
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["entra-config"] })
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/비활성화/))
})
it("onError: 모르는 에러 → '로그인 오류: ...' 토스트", async () => {
renderBtn()
await userEvent.click(screen.getByRole("button"))
const [, opts] = mutate.mock.calls[0]
opts.onError(new Error("???"))
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/로그인 오류.*\?{3}/))
})
})
@@ -0,0 +1,80 @@
import { useNavigate, useSearchParams } from "react-router-dom"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { AuthError } from "@azure/msal-browser"
import { Button } from "@/shared/ui/button"
import { ApiError } from "@/lib/api/errors"
import { safeRedirectPath } from "../utils/safeRedirectPath"
import { useEntraLogin } from "../hooks/useEntraLogin"
export default function EntraLoginButton() {
const login = useEntraLogin()
const navigate = useNavigate()
const [params] = useSearchParams()
const queryClient = useQueryClient()
const onClick = () => {
const from = safeRedirectPath(params.get("from"))
login.mutate(undefined, {
onSuccess: () => navigate(from, { replace: true }),
onError: (err: unknown) => {
console.error("[EntraLogin] error:", err)
// ── MSAL 팝업/브라우저 에러 ──────────────────────────
if (err instanceof AuthError) {
const code = err.errorCode
if (code === "user_cancelled") return
if (code === "popup_window_error") {
toast.error("팝업이 차단되었습니다. 브라우저 설정을 확인하십시오.")
return
}
if (code === "interaction_in_progress") {
toast.error("로그인이 진행 중입니다. 잠시 후 다시 시도하십시오.")
return
}
if (code === "monitor_window_timeout" || code === "empty_window_error") {
toast.error("팝업 창이 닫혔습니다. 다시 시도하십시오.")
return
}
toast.error(`Microsoft 인증 오류: ${err.message}`)
return
}
// ── 구형 방식(errorCode 프로퍼티만 있는 객체) 호환 ──
const legacyCode = (err as { errorCode?: string } | null)?.errorCode
if (legacyCode === "user_cancelled") return
// ── 백엔드 API 에러 ──────────────────────────────────
if (err instanceof ApiError) {
if (err.status === 501) {
queryClient.invalidateQueries({ queryKey: ["entra-config"] })
toast.error("Microsoft 로그인이 서버에서 비활성화되어 있습니다.")
return
}
if (err.status === 401) {
toast.error("Microsoft 인증에 실패했습니다. 다시 시도하십시오.")
return
}
toast.error(err.message || "Microsoft 로그인 실패")
return
}
// ── 네트워크 또는 예상치 못한 에러 ──────────────────
const msg = err instanceof Error ? err.message : String(err)
toast.error(`로그인 오류: ${msg}`)
},
})
}
return (
<Button
type="button"
variant="outline"
onClick={onClick}
disabled={login.isPending}
className="w-full"
>
{login.isPending ? "로그인 중..." : "Microsoft로 로그인"}
</Button>
)
}
@@ -0,0 +1,36 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { render, screen } from "@testing-library/react"
import EntraLoginSection from "./EntraLoginSection"
let mockState = { enabled: false, isLoading: false }
vi.mock("../hooks/useEntraEnabled", () => ({
useEntraEnabled: () => mockState,
}))
vi.mock("./EntraLoginButton", () => ({
default: () => <button data-testid="entra-btn">stub</button>,
}))
beforeEach(() => {
mockState = { enabled: false, isLoading: false }
})
describe("EntraLoginSection", () => {
it("enabled=false → null 렌더", () => {
mockState = { enabled: false, isLoading: false }
const { container } = render(<EntraLoginSection />)
expect(container).toBeEmptyDOMElement()
})
it("isLoading=true → null 렌더", () => {
mockState = { enabled: false, isLoading: true }
const { container } = render(<EntraLoginSection />)
expect(container).toBeEmptyDOMElement()
})
it("enabled=true → 버튼 + divider '또는' 같이 노출", () => {
mockState = { enabled: true, isLoading: false }
render(<EntraLoginSection />)
expect(screen.getByTestId("entra-btn")).toBeInTheDocument()
expect(screen.getByText("또는")).toBeInTheDocument()
})
})
@@ -0,0 +1,21 @@
import EntraLoginButton from "./EntraLoginButton"
import { useEntraEnabled } from "../hooks/useEntraEnabled"
export default function EntraLoginSection() {
const { enabled, isLoading } = useEntraEnabled()
if (isLoading || !enabled) return null
return (
<>
<EntraLoginButton />
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background text-muted-foreground px-2"></span>
</div>
</div>
</>
)
}
@@ -0,0 +1,89 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { fireEvent, render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { toast } from "sonner"
import { PATHS } from "@/config/routes"
import LoginForm from "./LoginForm"
vi.mock("sonner", () => ({ toast: { error: vi.fn(), info: vi.fn() } }))
const navigateMock = vi.fn()
vi.mock("react-router-dom", async (orig) => {
const m: any = await orig()
return { ...m, useNavigate: () => navigateMock }
})
const mutate = vi.fn()
vi.mock("../hooks/useLogin", () => ({
useLogin: () => ({ mutate, isPending: false }),
}))
function renderForm(search = "") {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[`/login${search}`]}>
<LoginForm />
</MemoryRouter>
</QueryClientProvider>
)
}
beforeEach(() => vi.clearAllMocks())
describe("LoginForm 회귀", () => {
it("이메일/비번 입력 + submit → useLogin.mutate 호출", async () => {
renderForm()
await userEvent.type(screen.getByLabelText("이메일"), "a@b.com")
await userEvent.type(screen.getByLabelText("비번"), "secret")
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
expect(mutate).toHaveBeenCalledOnce()
expect(mutate.mock.calls[0][0]).toMatchObject({ email: "a@b.com", password: "secret" })
})
it("무효 이메일 → 에러 메시지 + mutate 호출 X", async () => {
const { container } = renderForm()
await userEvent.type(screen.getByLabelText("이메일"), "not-email")
await userEvent.type(screen.getByLabelText("비번"), "secret")
// type="email" 의 HTML5 native validation 이 jsdom 에서 submit 을 막아서
// userEvent.click(submit) 으로는 zod 까지 안 감 → form.submit 직접 발사
const form = container.querySelector("form")!
fireEvent.submit(form)
// "올바른 이메일을 입력하십시오" 에러 메시지 — Label "이메일"과 구분되도록 더 구체적인 regex
// zod resolver 가 async 라 findByText (대기) 사용
expect(await screen.findByText(/올바른 이메일/)).toBeInTheDocument()
expect(mutate).not.toHaveBeenCalled()
})
it("?expired=1 → 세션 만료 토스트 1회", async () => {
renderForm("?expired=1")
expect(toast.info).toHaveBeenCalledOnce()
expect(toast.info).toHaveBeenCalledWith(expect.stringMatching(/세션/))
})
it("from=/snap/new + 성공 → navigate('/snap/new')", async () => {
renderForm("?from=/snap/new")
await userEvent.type(screen.getByLabelText("이메일"), "a@b.com")
await userEvent.type(screen.getByLabelText("비번"), "secret")
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
const [, opts] = mutate.mock.calls[0]
opts.onSuccess({})
expect(navigateMock).toHaveBeenCalledWith(PATHS.SNAP_NEW, { replace: true })
})
it("from=외부 도메인 → navigate PATHS.SNAP (safeRedirectPath 가드)", async () => {
renderForm("?from=//evil.com")
await userEvent.type(screen.getByLabelText("이메일"), "a@b.com")
await userEvent.type(screen.getByLabelText("비번"), "secret")
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
const [, opts] = mutate.mock.calls[0]
opts.onSuccess({})
expect(navigateMock).toHaveBeenCalledWith(PATHS.SNAP, { replace: true })
})
})
@@ -0,0 +1,70 @@
import { useEffect, useRef } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { useNavigate, useSearchParams } from "react-router-dom"
import { toast } from "sonner"
import { Button } from "@/shared/ui/button"
import { Input } from "@/shared/ui/input"
import { Label } from "@/shared/ui/label"
import { loginSchema, type LoginInput } from "../schemas"
import { useLogin } from "../hooks/useLogin"
import { safeRedirectPath } from "../utils/safeRedirectPath"
import { ApiError } from "@/lib/api/errors"
import type { LoginRequest } from "@/types/api"
export default function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
})
const login = useLogin()
const navigate = useNavigate()
const [params] = useSearchParams()
const expiredToastedRef = useRef(false)
// ?expired=1 → 토스트 1회 (StrictMode 이중 렌더 가드)
useEffect(() => {
if (params.get("expired") === "1" && !expiredToastedRef.current) {
expiredToastedRef.current = true
toast.info("세션이 만료되어 로그아웃되었습니다. 다시 로그인하십시오.")
}
}, [params])
const onSubmit = (data: LoginInput) => {
const from = safeRedirectPath(params.get("from"))
login.mutate(data as LoginRequest, {
onSuccess: () => navigate(from, { replace: true }),
onError: (err) => {
const msg = err instanceof ApiError ? err.message : "로그인 실패"
toast.error(msg)
},
})
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-sm space-y-4">
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input id="email" type="email" autoComplete="email" {...register("email")} />
{errors.email && <p className="text-destructive text-sm">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input
id="password"
type="password"
autoComplete="current-password"
{...register("password")}
/>
{errors.password && <p className="text-destructive text-sm">{errors.password.message}</p>}
</div>
<Button type="submit" disabled={login.isPending} className="w-full">
{login.isPending ? "로그인 중..." : "로그인"}
</Button>
</form>
)
}
@@ -0,0 +1,121 @@
import { useNavigate } from "react-router-dom"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { Button } from "@/shared/ui/button"
import { Input } from "@/shared/ui/input"
import { Label } from "@/shared/ui/label"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog"
import { ApiError } from "@/lib/api/errors"
import { PATHS } from "@/config/routes"
import type { LoginRequest } from "@/types/api"
import { useSessionExpiryStore } from "../store/sessionExpiryStore"
import { loginSchema, type LoginInput } from "../schemas"
import { useLogin } from "../hooks/useLogin"
import { useAuthStore } from "../store/authStore"
/**
* (401 + refresh ) .
*
* - ( )
* - retry
* - "로그아웃하고 로그인 페이지로" `/login?expired=1`
*
* `PaletteShell` 1 .
*/
export function SessionExpiryDialog() {
const open = useSessionExpiryStore((s) => s.open)
const cancel = useSessionExpiryStore((s) => s.cancel)
const closeAndFlush = useSessionExpiryStore((s) => s.closeAndFlush)
const clearUser = useAuthStore((s) => s.clearUser)
const login = useLogin()
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: { email: user?.email ?? "", password: "" },
})
const onSubmit = (data: LoginInput) => {
login.mutate(data as LoginRequest, {
onSuccess: async () => {
reset({ email: user?.email ?? "", password: "" })
await closeAndFlush()
},
onError: (err) => {
const msg = err instanceof ApiError ? err.message : "로그인 실패"
toast.error(msg)
},
})
}
const goToLogin = () => {
cancel()
clearUser()
navigate(`${PATHS.LOGIN}?expired=1`, { replace: true })
}
return (
<Dialog open={open}>
<DialogContent
// 외부 클릭·ESC로 닫기 차단 — 강제 재로그인 또는 명시적 로그아웃 두 길만
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
className="sm:max-w-sm"
>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="session-email"></Label>
<Input
id="session-email"
type="email"
autoComplete="email"
readOnly={!!user?.email}
{...register("email")}
/>
{errors.email && <p className="text-destructive text-sm">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="session-password"></Label>
<Input
id="session-password"
type="password"
autoComplete="current-password"
{...register("password")}
/>
{errors.password && (
<p className="text-destructive text-sm">{errors.password.message}</p>
)}
</div>
<DialogFooter className="gap-2">
<Button type="button" variant="ghost" onClick={goToLogin}>
</Button>
<Button type="submit" disabled={login.isPending}>
{login.isPending ? "로그인 중..." : "다시 로그인"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,46 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { ReactNode } from "react"
import { useEntraEnabled } from "./useEntraEnabled"
vi.mock("../api/auth.api", () => ({
authApi: { getEntraConfig: vi.fn() },
}))
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
beforeEach(() => vi.clearAllMocks())
describe("useEntraEnabled", () => {
it("config null → enabled=false, isLoading 결국 false", async () => {
const { authApi } = await import("../api/auth.api")
;(authApi.getEntraConfig as any).mockResolvedValue(null)
const { result } = renderHook(() => useEntraEnabled(), { wrapper })
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.enabled).toBe(false)
})
it("config 있음 → enabled=true", async () => {
const { authApi } = await import("../api/auth.api")
;(authApi.getEntraConfig as any).mockResolvedValue({
clientId: "c",
authority: "https://a",
tenantId: "t",
})
const { result } = renderHook(() => useEntraEnabled(), { wrapper })
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.enabled).toBe(true)
})
it("로딩 중 → isLoading=true, enabled=false", async () => {
const { authApi } = await import("../api/auth.api")
;(authApi.getEntraConfig as any).mockReturnValue(new Promise(() => {}))
const { result } = renderHook(() => useEntraEnabled(), { wrapper })
expect(result.current.isLoading).toBe(true)
expect(result.current.enabled).toBe(false)
})
})
@@ -0,0 +1,21 @@
import { useQuery } from "@tanstack/react-query"
import { authApi } from "../api/auth.api"
/**
* Entra SSO `/auth/entra/config` .
* - config enabled=true
* - config null (501) enabled=false
* staleTime Infinity: .
*/
export function useEntraEnabled() {
const q = useQuery({
queryKey: ["entra-config"],
queryFn: () => authApi.getEntraConfig(),
staleTime: Infinity,
retry: false,
})
return {
enabled: q.data !== null && q.data !== undefined,
isLoading: q.isLoading,
}
}
@@ -0,0 +1,109 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { renderHook, waitFor, act } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import type { ReactNode } from "react"
vi.mock("@/lib/auth/msal", () => ({
loginWithMicrosoft: vi.fn(),
}))
vi.mock("../api/auth.api", () => ({
authApi: { entraLogin: vi.fn() },
}))
vi.mock("../store/authStore", () => ({
useAuthStore: vi.fn(),
}))
function wrapper({ children }: { children: ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
beforeEach(() => vi.clearAllMocks())
describe("useEntraLogin", () => {
it("정상 흐름 → MSAL → entraLogin → setUser 순서 호출", async () => {
const setUser = vi.fn()
const { useAuthStore } = await import("../store/authStore")
;(useAuthStore as any).mockImplementation((sel: any) => sel({ setUser }))
const { loginWithMicrosoft } = await import("@/lib/auth/msal")
;(loginWithMicrosoft as any).mockResolvedValue({
idToken: "ID",
graphAccessToken: "G",
})
const user = {
id: "u1",
email: "a@b.com",
userName: "A",
role: "USER",
employeeId: "E",
department: "D",
authProvider: "entra",
}
const { authApi } = await import("../api/auth.api")
;(authApi.entraLogin as any).mockResolvedValue(user)
const { useEntraLogin } = await import("./useEntraLogin")
const { result } = renderHook(() => useEntraLogin(), { wrapper })
await act(async () => {
await result.current.mutateAsync()
})
expect(loginWithMicrosoft).toHaveBeenCalledOnce()
expect(authApi.entraLogin).toHaveBeenCalledWith({
idToken: "ID",
graphAccessToken: "G",
})
expect(setUser).toHaveBeenCalledWith(user)
})
it("graphAccessToken null → payload에서 graphAccessToken 누락(undefined)", async () => {
const setUser = vi.fn()
const { useAuthStore } = await import("../store/authStore")
;(useAuthStore as any).mockImplementation((sel: any) => sel({ setUser }))
const { loginWithMicrosoft } = await import("@/lib/auth/msal")
;(loginWithMicrosoft as any).mockResolvedValue({
idToken: "ID",
graphAccessToken: null,
})
const { authApi } = await import("../api/auth.api")
;(authApi.entraLogin as any).mockResolvedValue({ id: "u", email: "x" })
const { useEntraLogin } = await import("./useEntraLogin")
const { result } = renderHook(() => useEntraLogin(), { wrapper })
await act(async () => {
await result.current.mutateAsync()
})
expect(authApi.entraLogin).toHaveBeenCalledWith({
idToken: "ID",
graphAccessToken: undefined,
})
})
it("MSAL throw → mutation isError + setUser 호출 X", async () => {
const setUser = vi.fn()
const { useAuthStore } = await import("../store/authStore")
;(useAuthStore as any).mockImplementation((sel: any) => sel({ setUser }))
const { loginWithMicrosoft } = await import("@/lib/auth/msal")
const err: any = new Error("popup_window_error")
err.errorCode = "popup_window_error"
;(loginWithMicrosoft as any).mockRejectedValue(err)
const { useEntraLogin } = await import("./useEntraLogin")
const { result } = renderHook(() => useEntraLogin(), { wrapper })
await act(async () => {
try {
await result.current.mutateAsync()
} catch {
// mutation 실패 의도된 케이스
}
})
await waitFor(() => expect(result.current.isError).toBe(true))
expect(setUser).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,18 @@
import { useMutation } from "@tanstack/react-query"
import { loginWithMicrosoft } from "@/lib/auth/msal"
import { authApi } from "../api/auth.api"
import { useAuthStore } from "../store/authStore"
export function useEntraLogin() {
const setUser = useAuthStore((s) => s.setUser)
return useMutation({
mutationFn: async () => {
const { idToken, graphAccessToken } = await loginWithMicrosoft()
return authApi.entraLogin({
idToken,
graphAccessToken: graphAccessToken ?? undefined,
})
},
onSuccess: (user) => setUser(user),
})
}
@@ -0,0 +1,12 @@
import { useMutation } from "@tanstack/react-query"
import { authApi } from "../api/auth.api"
import { useAuthStore } from "../store/authStore"
import type { LoginRequest } from "@/types/api"
export function useLogin() {
const setUser = useAuthStore((s) => s.setUser)
return useMutation({
mutationFn: (input: LoginRequest) => authApi.login(input),
onSuccess: (user) => setUser(user),
})
}
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { authApi } from "../api/auth.api"
import { useAuthStore } from "../store/authStore"
/**
* logout( ) store react-query .
*
* (· ).
*/
export function useLogout() {
const clearUser = useAuthStore((s) => s.clearUser)
const qc = useQueryClient()
return useMutation({
mutationFn: () => authApi.logout(),
onSettled: () => {
clearUser()
qc.clear()
},
})
}
@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query"
import { authApi } from "../api/auth.api"
import { useAuthStore } from "../store/authStore"
/**
* (`/users/me`) user를 .
* persist된 user(localStorage) UX .
*/
export function useMe(enabled = true) {
const user = useAuthStore((s) => s.user)
return useQuery({
queryKey: ["auth", "me"],
queryFn: () => authApi.getMe(),
enabled: enabled && user !== null,
staleTime: 5 * 60_000,
retry: false,
})
}
@@ -0,0 +1,57 @@
import { useEffect } from "react"
import { authApi } from "../api/auth.api"
import { useAuthStore } from "../store/authStore"
const COOKIE_NAME = "accessTokenExp"
const REFRESH_BEFORE_MS = 60_000
function readExpCookie(): number | null {
if (typeof document === "undefined") return null
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`))
if (!match) return null
const n = Number(match[1])
return Number.isFinite(n) ? n : null
}
/**
* accessToken 60 refresh.
*
* - `accessTokenExp` non-httpOnly (ms epoch)
* - .
* - refresh 401 + sessionExpiryStore
*
* `PaletteShell` 1 .
*/
export function useSlidingRefresh() {
const isAuthed = useAuthStore((s) => s.isAuthenticated())
useEffect(() => {
if (!isAuthed) return
let timerId: ReturnType<typeof setTimeout> | null = null
let cancelled = false
const schedule = () => {
const exp = readExpCookie()
if (exp === null) return // 쿠키 없으면 다음 cycle에서 다시 시도하지 않음
const delay = Math.max(0, exp - Date.now() - REFRESH_BEFORE_MS)
timerId = setTimeout(async () => {
if (cancelled) return
try {
await authApi.refresh()
} catch {
// 인터셉터가 401 → sessionExpiryStore로 이미 처리. 여기선 무시.
return
}
if (!cancelled) schedule()
}, delay)
}
schedule()
return () => {
cancelled = true
if (timerId !== null) clearTimeout(timerId)
}
}, [isAuthed])
}
+2
View File
@@ -0,0 +1,2 @@
export { default as LoginPage } from "./pages/LoginPage"
export { useAuthStore } from "./store/authStore"
@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import LoginPage from "./LoginPage"
vi.mock("../components/EntraLoginSection", () => ({
default: () => <div data-testid="entra-section">stub</div>,
}))
vi.mock("../components/LoginForm", () => ({
default: () => <form data-testid="login-form">stub</form>,
}))
function renderPage() {
const qc = new QueryClient()
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>
<LoginPage />
</MemoryRouter>
</QueryClientProvider>
)
}
describe("LoginPage", () => {
it("EntraLoginSection + LoginForm 둘 다 마운트", () => {
renderPage()
expect(screen.getByTestId("entra-section")).toBeInTheDocument()
expect(screen.getByTestId("login-form")).toBeInTheDocument()
})
it("EntraLoginSection이 LoginForm보다 먼저 옴 (DOM 순서)", () => {
renderPage()
const section = screen.getByTestId("entra-section")
const form = screen.getByTestId("login-form")
expect(section.compareDocumentPosition(form) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it("h1 '로그인' 표시", () => {
renderPage()
expect(screen.getByRole("heading", { level: 1, name: "로그인" })).toBeInTheDocument()
})
it("로그인 입력 영역을 화면 가운데 정렬", () => {
const { container } = renderPage()
expect(container.firstElementChild).toHaveClass(
"flex",
"flex-1",
"items-center",
"justify-center"
)
})
})
@@ -0,0 +1,14 @@
import LoginForm from "../components/LoginForm"
import EntraLoginSection from "../components/EntraLoginSection"
export default function LoginPage() {
return (
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-sm space-y-6">
<h1 className="text-2xl font-semibold"></h1>
<EntraLoginSection />
<LoginForm />
</div>
</div>
)
}
@@ -0,0 +1,7 @@
import { z } from "zod"
export const loginSchema = z.object({
email: z.string().email("올바른 이메일을 입력하십시오"),
password: z.string().min(4, "최소 4자"),
})
export type LoginInput = z.infer<typeof loginSchema>
@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach } from "vitest"
import { useAuthStore } from "./authStore"
const sampleUser = {
id: "u1",
email: "x@x.com",
userName: null,
role: "USER" as const,
employeeId: null,
department: null,
authProvider: "local" as const,
}
describe("authStore", () => {
beforeEach(() => {
useAuthStore.setState({ user: null })
localStorage.clear()
})
it("초기 상태: user null, isAuthenticated false", () => {
const s = useAuthStore.getState()
expect(s.user).toBeNull()
expect(s.isAuthenticated()).toBe(false)
})
it("setUser: user 저장 + isAuthenticated true", () => {
useAuthStore.getState().setUser(sampleUser)
expect(useAuthStore.getState().user).toEqual(sampleUser)
expect(useAuthStore.getState().isAuthenticated()).toBe(true)
})
it("clearUser: user null", () => {
useAuthStore.setState({ user: sampleUser })
useAuthStore.getState().clearUser()
expect(useAuthStore.getState().user).toBeNull()
expect(useAuthStore.getState().isAuthenticated()).toBe(false)
})
// anti-pattern 가드: token/accessToken/refreshToken 필드는 절대 추가하지 않음 (httpOnly 쿠키만 사용)
it("[anti-pattern] state에 token 관련 필드가 없음", () => {
const state = useAuthStore.getState() as unknown as Record<string, unknown>
expect(state).not.toHaveProperty("token")
expect(state).not.toHaveProperty("accessToken")
expect(state).not.toHaveProperty("refreshToken")
})
it("[anti-pattern] setUser 후 persist에 token이 포함되지 않음", () => {
useAuthStore.getState().setUser(sampleUser)
const raw = localStorage.getItem("auth-store")
expect(raw).toBeTruthy()
const parsed = JSON.parse(raw!) as { state: Record<string, unknown> }
expect(parsed.state).not.toHaveProperty("token")
expect(parsed.state).not.toHaveProperty("accessToken")
expect(parsed.state).not.toHaveProperty("refreshToken")
expect(parsed.state.user).toEqual(sampleUser)
})
})
@@ -0,0 +1,34 @@
import { create } from "zustand"
import { persist, createJSONStorage } from "zustand/middleware"
import type { UserPayload } from "@/types/api"
/**
* . ** ** (httpOnly ).
*
* `token`/`accessToken`/`refreshToken` anti-pattern test로 .
*
* `user` localStorage에 (UX용. ).
* `useMe()` .
*/
interface AuthState {
user: UserPayload | null
isAuthenticated: () => boolean
setUser: (user: UserPayload | null) => void
clearUser: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
isAuthenticated: () => get().user !== null,
setUser: (user) => set({ user }),
clearUser: () => set({ user: null }),
}),
{
name: "auth-store",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ user: state.user }),
}
)
)
@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach, vi } from "vitest"
import { useSessionExpiryStore } from "./sessionExpiryStore"
describe("sessionExpiryStore", () => {
beforeEach(() => {
useSessionExpiryStore.setState({ open: false, queue: [] })
})
it("초기 상태: 닫힘 + 큐 비어있음", () => {
const s = useSessionExpiryStore.getState()
expect(s.open).toBe(false)
expect(s.queue).toEqual([])
})
it("openDialog: open=true", () => {
useSessionExpiryStore.getState().openDialog()
expect(useSessionExpiryStore.getState().open).toBe(true)
})
it("pushFailure: retry를 큐에 push + open=true + 호출자 promise 보관", () => {
const retry = vi.fn().mockResolvedValue("ok")
const promise = useSessionExpiryStore.getState().pushFailure(retry)
const state = useSessionExpiryStore.getState()
expect(state.open).toBe(true)
expect(state.queue.length).toBe(1)
expect(retry).not.toHaveBeenCalled() // flush 전엔 호출 안 됨
// dangling promise 정리
state.cancel()
promise.catch(() => {})
})
it("closeAndFlush: 큐에 쌓인 retry 모두 실행 + open=false + queue=[]", async () => {
const retry1 = vi.fn().mockResolvedValue("a")
const retry2 = vi.fn().mockResolvedValue("b")
const p1 = useSessionExpiryStore.getState().pushFailure(retry1)
const p2 = useSessionExpiryStore.getState().pushFailure(retry2)
await useSessionExpiryStore.getState().closeAndFlush()
expect(retry1).toHaveBeenCalledTimes(1)
expect(retry2).toHaveBeenCalledTimes(1)
await expect(p1).resolves.toBe("a")
await expect(p2).resolves.toBe("b")
const state = useSessionExpiryStore.getState()
expect(state.open).toBe(false)
expect(state.queue).toEqual([])
})
it("flush 중 retry 한 건이 실패해도 나머지 진행", async () => {
const retry1 = vi.fn().mockResolvedValue("a")
const retry2 = vi.fn().mockRejectedValue(new Error("boom"))
const retry3 = vi.fn().mockResolvedValue("c")
const p1 = useSessionExpiryStore.getState().pushFailure(retry1)
const p2 = useSessionExpiryStore.getState().pushFailure(retry2)
const p3 = useSessionExpiryStore.getState().pushFailure(retry3)
await useSessionExpiryStore.getState().closeAndFlush()
await expect(p1).resolves.toBe("a")
await expect(p2).rejects.toThrow("boom")
await expect(p3).resolves.toBe("c")
})
it("cancel: open=false + 큐 폐기 (호출자 promise는 dangling)", () => {
const retry = vi.fn()
useSessionExpiryStore.getState().pushFailure(retry)
useSessionExpiryStore.getState().cancel()
const state = useSessionExpiryStore.getState()
expect(state.open).toBe(false)
expect(state.queue).toEqual([])
expect(retry).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,52 @@
import { create } from "zustand"
/**
* (401 + refresh ) .
* retry.
*
* store는 user . `authStore` .
*/
export type RetryFn = () => Promise<unknown>
interface SessionExpiryState {
open: boolean
/** refresh 실패로 reject 직전에 대기 중인 요청들의 retry 함수 큐. */
queue: RetryFn[]
/** 모달 열기. 동일 사이클에서 여러 401이 와도 1회만 열림. */
openDialog: () => void
/** 재로그인 성공 시: 큐 전부 retry → resolve/reject 각자에게 위임 → 모달 닫음. */
closeAndFlush: () => Promise<void>
/** 사용자가 명시적으로 모달 닫기(취소·로그아웃 등). 큐도 폐기. */
cancel: () => void
/** 401 + refresh 실패 직전에 retry 함수 push. 호출자는 반환된 promise로 결과 받음. */
pushFailure: (retry: RetryFn) => Promise<unknown>
}
export const useSessionExpiryStore = create<SessionExpiryState>((set, get) => ({
open: false,
queue: [],
openDialog: () => set({ open: true }),
cancel: () => set({ open: false, queue: [] }),
closeAndFlush: async () => {
const { queue } = get()
set({ open: false, queue: [] })
// 각 retry는 독립 실행 — 한 건 실패해도 나머지 진행
await Promise.allSettled(queue.map((fn) => fn()))
},
pushFailure: (retry) => {
return new Promise((resolve, reject) => {
const wrapped: RetryFn = async () => {
try {
const result = await retry()
resolve(result)
return result
} catch (err) {
reject(err)
throw err
}
}
set((s) => ({ queue: [...s.queue, wrapped], open: true }))
})
},
}))
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest"
import { safeRedirectPath } from "./safeRedirectPath"
import { PATHS } from "@/config/routes"
describe("safeRedirectPath", () => {
it("null → SNAP", () => {
expect(safeRedirectPath(null)).toBe(PATHS.SNAP)
})
it("빈 문자열 → SNAP", () => {
expect(safeRedirectPath("")).toBe(PATHS.SNAP)
})
it("'/'로 시작 안 함 → SNAP", () => {
expect(safeRedirectPath("snap/new")).toBe(PATHS.SNAP)
})
it("'//' protocol-relative URL → SNAP", () => {
expect(safeRedirectPath("//evil.com/path")).toBe(PATHS.SNAP)
})
it("정상 절대 경로 → 그대로 반환", () => {
expect(safeRedirectPath(PATHS.SNAP_NEW)).toBe(PATHS.SNAP_NEW)
})
it("쿼리스트링 포함된 경로 → 그대로", () => {
expect(safeRedirectPath("/users?page=2")).toBe("/users?page=2")
})
})
@@ -0,0 +1,12 @@
import { PATHS } from "@/config/routes"
/**
* redirect할 path .
* - `/`
* - `//` protocol-relative URL이라
*/
export function safeRedirectPath(raw: string | null): string {
if (!raw) return PATHS.SNAP
if (!raw.startsWith("/") || raw.startsWith("//")) return PATHS.SNAP
return raw
}
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook, waitFor } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { useSessionList, useSessionMessages } from "./snap.api"
import * as client from "@/lib/api/client"
vi.mock("@/lib/api/client")
function wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
const SESSION = {
id: "s1",
title: "t",
titleLlm: null,
isGenerating: false,
createdAt: "2026-07-18T00:00:00Z",
updatedAt: "2026-07-18T00:00:00Z",
}
describe("snap.api (real)", () => {
beforeEach(() => vi.clearAllMocks())
it("useSessionList 는 GET /chat/sessions 를 페이지네이션으로 호출", async () => {
vi.mocked(client.apiList).mockResolvedValue({ items: [SESSION], meta: null, counts: 1 })
const { result } = renderHook(() => useSessionList(1), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(client.apiList).toHaveBeenCalledWith("/chat/sessions", {
params: { page: 1, limit: 3 },
})
expect(result.current.data!.items[0].id).toBe("s1")
})
it("useSessionMessages 는 GET /chat/sessions/{id}/messages 를 호출", async () => {
vi.mocked(client.apiGet).mockResolvedValue({ ...SESSION, messages: [] })
const { result } = renderHook(() => useSessionMessages("s1"), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(client.apiGet).toHaveBeenCalledWith("/chat/sessions/s1/messages")
expect(result.current.data!.messages).toEqual([])
})
})
@@ -0,0 +1,56 @@
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query"
import { apiGet, apiList, apiPost } from "@/lib/api/client"
import type { SnapMessage, SnapSession, SnapSessionDetail } from "../contract/types"
const PAGE_SIZE = 3 // 첫 화면 "최근 진행 대화"는 3개만 peek — 나머지는 검색으로
const SEARCH_PAGE_SIZE = 20
// 세션 목록 — 서버 페이지네이션. page 단위로 목록 교체(더보기 append 아님).
export function useSessionList(page: number) {
return useQuery({
queryKey: ["snap", "sessions", page],
queryFn: () => apiList<SnapSession>("/chat/sessions", { params: { page, limit: PAGE_SIZE } }),
placeholderData: keepPreviousData,
// 전역 staleTime 30s 끔 — 새 세션 만들고 30초 내 목록 복귀 시 캐시가 그대로 나와
// "방금 만든 세션이 목록에 없음"이 되던 원인. 목록 진입마다 refetch.
staleTime: 0,
})
}
// 메시지 본문 검색 — 서버 /chat/sessions/search (ILIKE, 소유 세션 전체 대상). 매칭된 메시지 반환.
export function useSearchMessages(query: string, page: number) {
return useQuery({
queryKey: ["snap", "search", query, page],
enabled: query.trim().length > 0,
staleTime: 0, // 같은 검색어 재검색 시에도 최신 메시지 반영
queryFn: () =>
apiList<SnapMessage>("/chat/sessions/search", {
params: { query, page, limit: SEARCH_PAGE_SIZE },
}),
placeholderData: keepPreviousData,
})
}
export function useSessionMessages(id: string) {
return useQuery({
queryKey: ["snap", "session", id],
enabled: !!id,
queryFn: (): Promise<SnapSessionDetail> =>
apiGet<SnapSessionDetail>(`/chat/sessions/${id}/messages`),
// 전역 staleTime 30s 를 끔 — 대화 내용은 스트리밍으로 계속 바뀌어서 30초 캐시가
// "재진입하면 빈/옛 대화" 버그의 뿌리였음. 재진입(마운트)마다 무조건 refetch.
staleTime: 0,
// 생성 중이면(스트림을 이 탭에서 잃었어도) 백엔드가 끝내는 순간 답변이 DB 에 뜨므로
// 그때까지 폴링 → 완료되면 isGenerating=false 로 폴링 멈춤. 재진입 시 답변 유실 자가복구.
refetchInterval: (query) => (query.state.data?.isGenerating ? 1500 : false),
})
}
export function useCreateSession() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (): Promise<SnapSession> => apiPost<SnapSession>("/chat/sessions", {}),
// 생성 즉시 목록 캐시 무효화 — 목록으로 돌아가면 새 세션이 바로 보이게.
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["snap", "sessions"] }),
})
}
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { snapStream, cancelStream } from "./snap.stream"
import * as streaming from "@/lib/streaming"
import * as client from "@/lib/api/client"
vi.mock("@/lib/streaming", () => ({ streamLLM: vi.fn() }))
vi.mock("@/lib/api/client", () => ({ apiPost: vi.fn() }))
describe("snapStream (real)", () => {
beforeEach(() => vi.clearAllMocks())
it("streamLLM 을 /chat/stream 계약으로 호출한다", async () => {
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
streamLLM.mockResolvedValue(undefined)
await snapStream({ sessionId: "s1", content: "hi" }, { onToken: vi.fn(), onDone: vi.fn() })
expect(streamLLM).toHaveBeenCalledTimes(1)
const arg = streamLLM.mock.calls[0][0]
expect(arg.path).toBe("/chat/stream")
expect(arg.body).toEqual({ sessionId: "s1", content: "hi" })
})
it("이미지 계약을 요청 body에 그대로 전달한다", async () => {
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
streamLLM.mockResolvedValue(undefined)
const images = [{ mediaType: "image/png" as const, data: "data:image/png;base64,eA==" }]
await snapStream(
{ sessionId: "s1", content: "", images },
{ onToken: vi.fn(), onDone: vi.fn() }
)
expect(streamLLM.mock.calls[0][0].body).toEqual({ sessionId: "s1", content: "", images })
})
it("onToken/onTitle/onDone/onError 를 그대로 배선한다", async () => {
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
streamLLM.mockImplementation(
async (opts: {
handlers: {
onToken: (d: string) => void
onTitle?: (t: string) => void
onDone: (p: object) => void
}
}) => {
opts.handlers.onToken("a")
opts.handlers.onTitle?.("제목")
opts.handlers.onDone({})
}
)
const onToken = vi.fn()
const onTitle = vi.fn()
const onDone = vi.fn()
await snapStream({ sessionId: "s1", content: "x" }, { onToken, onDone, onTitle })
expect(onToken).toHaveBeenCalledWith("a")
expect(onTitle).toHaveBeenCalledWith("제목")
expect(onDone).toHaveBeenCalledTimes(1)
})
})
describe("cancelStream", () => {
beforeEach(() => vi.clearAllMocks())
it("POST /chat/sessions/{id}/cancel 를 호출한다", async () => {
vi.mocked(client.apiPost).mockResolvedValue(null)
await cancelStream("s1")
expect(client.apiPost).toHaveBeenCalledWith("/chat/sessions/s1/cancel", {})
})
it("실패해도 throw 하지 않는다(best-effort)", async () => {
vi.mocked(client.apiPost).mockRejectedValue(new Error("404"))
await expect(cancelStream("s1")).resolves.toBeUndefined()
})
})
@@ -0,0 +1,41 @@
import { streamLLM, type LLMUsagePayload } from "@/lib/streaming"
import { apiPost } from "@/lib/api/client"
import type { SnapStreamRequest } from "../contract/types"
export interface SnapStreamHandlers {
onToken: (delta: string) => void
onDone: () => void
onTitle?: (title: string) => void
onUsage?: (usage: LLMUsagePayload) => void
onError?: (e: Error) => void
}
// base-backend POST /chat/stream (SSE) 직결. token/done/error/title 이벤트 소비.
export function snapStream(
req: SnapStreamRequest,
handlers: SnapStreamHandlers,
opts?: { signal?: AbortSignal }
): Promise<void> {
return streamLLM({
path: "/chat/stream",
body: req,
signal: opts?.signal,
handlers: {
onToken: handlers.onToken,
onDone: () => handlers.onDone(),
onTitle: handlers.onTitle,
onUsage: handlers.onUsage,
onError: handlers.onError,
},
})
}
// 백엔드에 생성 취소를 알린다(best-effort). 엔드포인트 미구현/실패면 조용히 무시 —
// 로컬 stop(abort+freeze)은 호출 측에서 이미 적용됨.
export async function cancelStream(sessionId: string): Promise<void> {
try {
await apiPost(`/chat/sessions/${sessionId}/cancel`, {})
} catch {
// 취소 엔드포인트 아직 없거나 실패 — degrade. 백엔드는 기존대로 끝까지 생성.
}
}
@@ -0,0 +1,54 @@
import { ChevronLeft } from "lucide-react"
import { useShallow } from "zustand/react/shallow"
import { Kbd } from "@/shared/components/Kbd"
import type { SnapSession } from "../contract/types"
import { useSnapChatStore } from "../store/snapChatStore"
import { fmtTokens } from "../lib/format"
interface Props {
session: SnapSession
onBack: () => void
}
export function ChatHeader({ session, onBack }: Props) {
const title = session.titleLlm ?? session.title ?? "새 대화 세션"
const { used, limit } = useSnapChatStore(
useShallow((s) => ({ used: s.sessionUsed, limit: s.sessionLimit }))
)
const ratio = limit > 0 ? Math.min(1, used / limit) : 0
// 게이지 색 — 90%↑ 빨강, 70%↑ 주황, 그 외 기본.
const barColor = ratio >= 0.9 ? "bg-red-500" : ratio >= 0.7 ? "bg-amber-500" : "bg-primary"
return (
<div className="border-border bg-card flex flex-none items-center gap-2 border-b px-3 py-2.5">
<button
type="button"
onClick={onBack}
title="목록으로 (Esc)"
className="border-border bg-background text-muted-foreground hover:border-ring hover:text-foreground inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[11px]"
>
<ChevronLeft className="size-3" />
<Kbd>Esc</Kbd>
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-serif text-base font-semibold italic">{title}</div>
<div className="text-muted-foreground font-mono text-[9.5px] tracking-wide uppercase">
{(session.tag ?? "SESSION").toUpperCase()} ·
</div>
</div>
{/* 세션 토큰 게이지 — 현재 점유 / 한도 */}
<div
className="flex flex-none flex-col items-end gap-1"
title={`${used.toLocaleString()} / ${limit.toLocaleString()} tokens`}
>
<span className="text-muted-foreground font-mono text-[10px] tabular-nums">
{fmtTokens(used)}
<span className="text-muted-foreground/50"> / {fmtTokens(limit)}</span>
</span>
<div className="bg-border h-1 w-24 overflow-hidden rounded-full">
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${ratio * 100}%` }} />
</div>
</div>
</div>
)
}
@@ -0,0 +1,232 @@
import { useEffect, useRef, useState } from "react"
import { get, set } from "idb-keyval"
import { Clipboard, Type } from "lucide-react"
import { toast } from "sonner"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip"
// IndexedDB 에 저장하는 형태 — 이미지는 Blob 그대로(base64 불필요, 용량 절약).
type StoredItem =
| { id: string; kind: "text"; value: string }
| { id: string; kind: "image"; blob: Blob }
// 렌더용 — 이미지는 <img> 에 물릴 object URL 을 얹은 형태.
type ClipItem =
| { id: string; kind: "text"; value: string }
| { id: string; kind: "image"; blob: Blob; url: string }
const STORE_KEY = "snap-clip-history"
const CAP = 30 // 최대 저장 개수. 화면은 스크롤로 ~10개 보이고 나머지는 굴려서.
function newId(): string {
return typeof crypto.randomUUID === "function" ? crypto.randomUUID() : String(Date.now())
}
// 저장형 → 렌더형(이미지에 object URL 부여). 반대는 url 만 떼면 됨.
function toClip(stored: StoredItem[]): ClipItem[] {
return stored.map((i) => (i.kind === "image" ? { ...i, url: URL.createObjectURL(i.blob) } : i))
}
function toStored(items: ClipItem[]): StoredItem[] {
return items.map((i) => (i.kind === "image" ? { id: i.id, kind: "image", blob: i.blob } : i))
}
export function ClipboardHistory() {
const [items, setItems] = useState<ClipItem[]>([])
// 마운트 시 IndexedDB 에서 로드. hydrate 전엔 persist 를 막아 빈 배열로 덮어쓰는 레이스 방지.
const hydrated = useRef(false)
useEffect(() => {
let cancelled = false
get<StoredItem[]>(STORE_KEY)
.then((stored) => {
if (!cancelled && stored?.length) setItems(toClip(stored))
})
.finally(() => {
hydrated.current = true
})
return () => {
cancelled = true
}
}, [])
// 이력 변경 시 IndexedDB 에 통째로 저장(텍스트+이미지 blob 둘 다 로컬 영속).
useEffect(() => {
if (!hydrated.current) return
set(STORE_KEY, toStored(items)).catch(() => {
// storage 실패 — degrade
})
}, [items])
// 새 clip 을 맨 앞에. 직전과 같은 텍스트면 스킵(중복 방지). CAP 초과분은 잘라내며 object URL 정리.
const add = (item: ClipItem) =>
setItems((prev) => {
if (item.kind === "text" && prev[0]?.kind === "text" && prev[0].value === item.value)
return prev
const next = [item, ...prev]
for (const dropped of next.slice(CAP)) {
if (dropped.kind === "image") URL.revokeObjectURL(dropped.url)
}
return next.slice(0, CAP)
})
// 클립보드 소스 2입구(paste 폴백 + 네이티브 postMessage).
// 계약: { type: "clipboard", payload: {kind:"text", value} | {kind:"image", dataUrl} }
const addRef = useRef(add)
addRef.current = add
useEffect(() => {
const imageItem = (blob: Blob): ClipItem => ({
id: newId(),
kind: "image",
blob,
url: URL.createObjectURL(blob),
})
const onPaste = (e: ClipboardEvent) => {
const dt = e.clipboardData
if (!dt) return
for (let i = 0; i < dt.items.length; i++) {
if (dt.items[i].type.startsWith("image/")) {
const blob = dt.items[i].getAsFile()
if (!blob) return
addRef.current(imageItem(blob))
return
}
}
const text = dt.getData("text/plain")
if (text) addRef.current({ id: newId(), kind: "text", value: text })
}
const onMessage = (e: MessageEvent) => {
const d = e.data
if (!d || d.type !== "clipboard") return
const p = d.payload
if (p?.kind === "image" && typeof p.dataUrl === "string") {
// data URL → Blob 로 변환해 저장(paste 와 동일 취급).
fetch(p.dataUrl)
.then((r) => r.blob())
.then((blob) => addRef.current(imageItem(blob)))
.catch(() => {})
} else if (p?.kind === "text" && typeof p.value === "string") {
addRef.current({ id: newId(), kind: "text", value: p.value })
}
}
document.addEventListener("paste", onPaste)
window.addEventListener("message", onMessage)
return () => {
document.removeEventListener("paste", onPaste)
window.removeEventListener("message", onMessage)
}
}, [])
// 언마운트 시 남은 object URL 정리.
const itemsRef = useRef(items)
itemsRef.current = items
useEffect(
() => () => {
for (const it of itemsRef.current) {
if (it.kind === "image") URL.revokeObjectURL(it.url)
}
},
[]
)
// 이력 클릭 → OS 클립보드로 다시 복사(다른 앱에 붙이게).
const copyText = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
toast.success("클립보드에 복사됨")
} catch {
toast.error("복사 실패")
}
}
const copyImage = async (blob: Blob) => {
try {
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })])
toast.success("이미지 클립보드에 복사됨")
} catch {
toast.error("이미지 복사 실패")
}
}
return (
<div className="border-border flex max-h-72 flex-none flex-col border-b">
<div className="flex flex-none items-center gap-1.5 px-3 py-2.5">
<Clipboard className="text-muted-foreground size-3" />
<span className="text-muted-foreground font-mono text-[10px] tracking-widest uppercase">
Clipboard
</span>
{items.length > 0 && (
<span className="text-muted-foreground/60 ml-auto font-mono text-[9px]">
{items.length}
</span>
)}
</div>
{items.length === 0 ? (
<p className="border-border text-muted-foreground/60 mx-2.5 mb-2.5 rounded border border-dashed px-2 py-3 text-center font-mono text-[10px] leading-relaxed">
Ctrl+V
<br />
</p>
) : (
<TooltipProvider delayDuration={200}>
<div className="flex flex-col gap-1 overflow-y-auto px-1.5 pb-1.5">
{items.map((it) =>
it.kind === "image" ? (
<Tooltip key={it.id}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => copyImage(it.blob)}
title="클릭하면 이미지 클립보드에 복사"
className="border-border hover:border-ring relative overflow-hidden rounded border"
>
<img
src={it.url}
alt="클립보드 이미지"
className="bg-muted/40 max-h-20 w-full object-contain"
/>
<span className="bg-background/80 text-muted-foreground absolute top-1 right-1 rounded px-1 font-mono text-[8px]">
IMG
</span>
</button>
</TooltipTrigger>
{/* hover 미리보기 — 큰 이미지 */}
<TooltipContent side="right" align="start" className="p-1">
<img
src={it.url}
alt="클립보드 이미지 미리보기"
className="max-h-64 max-w-xs rounded object-contain"
/>
</TooltipContent>
</Tooltip>
) : (
<Tooltip key={it.id}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => copyText(it.value)}
title="클릭하면 클립보드에 복사"
className="hover:bg-accent flex items-start gap-1.5 rounded px-2 py-1.5 text-left"
>
<Type className="text-muted-foreground/70 mt-0.5 size-3 flex-none" />
<span className="text-foreground/80 line-clamp-2 font-mono text-[10px] leading-snug break-words whitespace-pre-wrap">
{it.value.slice(0, 160)}
</span>
</button>
</TooltipTrigger>
{/* hover 미리보기 — 전체 텍스트(길면 스크롤) */}
<TooltipContent
side="right"
align="start"
className="max-h-72 max-w-md overflow-auto"
>
<pre className="font-mono text-[11px] leading-snug break-words whitespace-pre-wrap">
{it.value}
</pre>
</TooltipContent>
</Tooltip>
)
)}
</div>
</TooltipProvider>
)}
</div>
)
}
@@ -0,0 +1,163 @@
import { useMemo, useState } from "react"
import { Check, ClipboardPaste, Copy } from "lucide-react"
import { toast } from "sonner"
import { isWebView, pasteToApp } from "@/lib/bridge/webviewBridge"
import hljs from "highlight.js/lib/core"
import sql from "highlight.js/lib/languages/sql"
import json from "highlight.js/lib/languages/json"
import diff from "highlight.js/lib/languages/diff"
import yaml from "highlight.js/lib/languages/yaml"
import plaintext from "highlight.js/lib/languages/plaintext"
import abap from "./abapHljs"
import "highlight.js/styles/github-dark.css"
import "./abapLight.css"
// 코어 빌드에 필요한 언어만 등록(번들 최소화). ABAP 은 커스텀 문법.
hljs.registerLanguage("sql", sql)
hljs.registerLanguage("json", json)
hljs.registerLanguage("diff", diff)
hljs.registerLanguage("yaml", yaml)
hljs.registerLanguage("plaintext", plaintext)
hljs.registerLanguage("abap", abap)
// 펜스 언어 라벨 → 등록된 문법 매핑.
const ALIAS: Record<string, string> = {
cds: "abap",
ddl: "abap",
abapsql: "abap",
sqlscript: "sql",
text: "plaintext",
txt: "plaintext",
yml: "yaml",
}
function escapeHtml(s: string): string {
return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c] ?? c)
}
interface Props {
code: string
lang?: string
/** NavRail 점프 대상 식별용 인덱스. */
index?: number
/** 주변 UI가 제목과 액션을 담당하는 독립 프리뷰에서는 코드만 표시함. */
plain?: boolean
}
export function CodeBlock({ code, lang, index, plain = false }: Props) {
const key = (lang ?? "").toLowerCase()
const resolved = ALIAS[key] ?? key
// ABAP 은 SAP 에디터처럼 밝은 배경으로 렌더 — 나머지 언어는 어두운 테마 유지.
const isAbap = resolved === "abap"
// 언어 없는 펜스(ASCII 다이어그램 등)도 밝게 — 어두운 코드 테마는 언어 지정 블록만.
const isLight = isAbap || resolved === ""
const html = useMemo(() => {
if (resolved && hljs.getLanguage(resolved)) {
return hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value
}
return escapeHtml(code)
}, [code, resolved])
const lineCount = useMemo(() => code.replace(/\n$/, "").split("\n").length, [code])
return (
<div
data-code-block={index ?? 0}
data-code-lang={lang ?? "code"}
className={
plain
? `h-full min-h-0 overflow-hidden ${isLight ? "bg-white text-zinc-900" : "bg-[#0d1117] text-zinc-100"}`
: isLight
? "my-2.5 overflow-hidden rounded-lg border border-zinc-300 bg-white text-zinc-900 shadow-sm"
: "my-2.5 overflow-hidden rounded-lg border border-zinc-700/60 bg-[#0d1117] text-zinc-100 shadow-sm"
}
>
{!plain && (
<div
className={
isLight
? "flex items-center gap-2 border-b border-zinc-200 bg-zinc-50 px-3 py-1.5"
: "flex items-center gap-2 border-b border-white/5 bg-white/[0.03] px-3 py-1.5"
}
>
<span className="flex gap-1">
<span className="size-2 rounded-full bg-rose-400/70" />
<span className="size-2 rounded-full bg-amber-400/70" />
<span className="size-2 rounded-full bg-emerald-400/70" />
</span>
<span
className={
isLight
? "ml-1 font-mono text-[10px] tracking-wider text-zinc-500 uppercase"
: "ml-1 font-mono text-[10px] tracking-wider text-zinc-400 uppercase"
}
>
#<span data-code-num>{(index ?? 0) + 1}</span> · {lang ?? "code"}
</span>
<CodeActions code={code} isLight={isLight} />
</div>
)}
<div
className={`flex overflow-auto text-xs leading-relaxed ${plain ? "h-full" : "max-h-96"}`}
>
<div
aria-hidden
className={
isLight
? "flex-none border-r border-zinc-200 px-2.5 py-3 text-right font-mono text-zinc-400 select-none"
: "flex-none border-r border-white/5 px-2.5 py-3 text-right font-mono text-zinc-600 select-none"
}
>
{Array.from({ length: lineCount }, (_, i) => (
<div key={i}>{i + 1}</div>
))}
</div>
<pre className="min-w-0 flex-1 px-3 py-3 font-mono whitespace-pre">
<code
className={isAbap ? "hljs-abap-light" : undefined}
dangerouslySetInnerHTML={{ __html: html }}
/>
</pre>
</div>
</div>
)
}
/** 코드 카드와 스니펫 하단에서 같은 복사·붙여넣기 동작을 재사용함. */
export function CodeActions({ code, isLight = true }: { code: string; isLight?: boolean }) {
const [copied, setCopied] = useState(false)
// 헤더 버튼 공통 스타일(복사·붙여넣기 공유). ml-auto 는 감싸는 컨테이너가 가짐.
const btnClass = isLight
? "inline-flex items-center gap-1 rounded border border-zinc-300 px-2 py-0.5 font-mono text-[10px] text-zinc-600 transition-colors hover:border-zinc-400 hover:text-zinc-900"
: "inline-flex items-center gap-1 rounded border border-zinc-700 px-2 py-0.5 font-mono text-[10px] text-zinc-300 transition-colors hover:border-zinc-500 hover:text-white"
const copy = async () => {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
toast.success("클립보드에 복사됨")
setTimeout(() => setCopied(false), 1400)
} catch {
toast.error("복사 실패")
}
}
return (
<div className="ml-auto flex items-center gap-1.5">
{isWebView() && (
<button
type="button"
onClick={() => pasteToApp(code)}
className={btnClass}
title="런처 소환 직전 앱에 붙여넣기(붙이고 창은 자동으로 숨김)"
>
<ClipboardPaste className="size-3" />
</button>
)}
<button type="button" onClick={copy} className={btnClass}>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied ? "복사됨" : "복사"}
</button>
</div>
)
}
@@ -0,0 +1,82 @@
import { describe, expect, it, afterEach, vi } from "vitest"
import { render, screen, cleanup, act, fireEvent } from "@testing-library/react"
import { Composer } from "./Composer"
import { initBridgeNavigate, consumePendingCaptureImage } from "@/lib/bridge/bridgeNavigate"
type Listener = (e: MessageEvent) => void
function mockWebview() {
let listener: Listener | undefined
;(window as unknown as { chrome?: unknown }).chrome = {
webview: {
postMessage: () => {},
addEventListener: (type: string, cb: Listener) => {
if (type === "message") listener = cb
},
},
}
return { emit: (data: unknown) => listener?.({ data } as MessageEvent) }
}
describe("Composer 캡쳐 이미지 첨부", () => {
afterEach(() => {
cleanup()
delete (window as unknown as { chrome?: unknown }).chrome
})
// 코드리뷰 Critical 회귀 재현: onCapture 가 이벤트 detail 만 읽고 pendingCaptureImage 를
// 안 비우면, 리마운트(다른 대화 갔다가 새 대화 재진입) 시 마운트 이펙트가 옛 캡쳐를 재소비함.
it("리마운트 시 이미 소비된 캡쳐 이미지를 다시 첨부하지 않음", () => {
const wv = mockWebview()
initBridgeNavigate()
const { unmount } = render(<Composer onSend={() => {}} acceptCapture />)
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,abc" }))
expect(screen.getAllByRole("img")).toHaveLength(1)
unmount()
render(<Composer onSend={() => {}} acceptCapture />) // 다른 대화로 이동했다가 새 대화 재진입 시뮬레이션
expect(screen.queryAllByRole("img")).toHaveLength(0)
})
// 코드리뷰 Important 회귀 재현: 기존 대화방(SessionChatPage, acceptCapture 없음)이 살아있는 채로
// capture.image 가 오면, 리마운트 전에 그 Composer가 pending을 훔쳐가 새 대화 Composer가 못 받음.
it("acceptCapture 없으면 capture 이벤트를 무시하고 pending을 안 건드림(출발지 도둑질 방지)", () => {
const wv = mockWebview()
initBridgeNavigate()
render(<Composer onSend={() => {}} />) // acceptCapture 없음 — 기존 대화방 시뮬레이션
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,xyz" }))
expect(screen.queryAllByRole("img")).toHaveLength(0) // 여기엔 안 붙음
// pending이 안 비워졌어야 — 곧 마운트될 새 대화 Composer가 그대로 소비 가능해야 함
expect(consumePendingCaptureImage()).toBe("data:image/png;base64,xyz")
})
it("캡쳐 이미지만 있어도 이미지 계약으로 전송한다", () => {
const wv = mockWebview()
initBridgeNavigate()
const onSend = vi.fn()
render(<Composer onSend={onSend} acceptCapture />)
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,eA==" }))
fireEvent.click(screen.getByRole("button", { name: "전송" }))
expect(onSend).toHaveBeenCalledWith("", [
{ mediaType: "image/png", data: "data:image/png;base64,eA==" },
])
expect(screen.queryAllByRole("img")).toHaveLength(0)
})
it("이미지는 최대 4장까지만 첨부한다", () => {
const wv = mockWebview()
initBridgeNavigate()
render(<Composer onSend={() => {}} acceptCapture />)
for (let i = 0; i < 5; i++) {
act(() => wv.emit({ type: "capture.image", dataUrl: `data:image/png;base64,eA${i}=` }))
}
expect(screen.queryAllByRole("img")).toHaveLength(4)
})
})
@@ -0,0 +1,298 @@
import { useEffect, useRef, useState } from "react"
import { BookOpen, ClipboardPaste, Send, Square, X } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/ui/button"
import { cn } from "@/lib/utils/cn"
import { consumePendingCaptureImage } from "@/lib/bridge/bridgeNavigate"
import { useSnapChatStore } from "../store/snapChatStore"
import type { SnapImageInput, SnapImageMediaType } from "../contract/types"
interface Props {
onSend: (text: string, images: SnapImageInput[]) => void
busy?: boolean
onStop?: () => void
placeholder?: string
// 캡쳐 이미지 첨부를 받을지 — 새 대화(NewChatPage)만 true. 기존 대화방(SessionChatPage)이 켜져
// 있으면 navigate(/snap/new)+capture.image 순서에서 리마운트 전에 여기가 pending을 훔쳐가
// 정작 새 대화 Composer엔 이미지가 안 붙는 레이스가 생김 — 그래서 출발지는 아예 안 건드리게 게이팅.
acceptCapture?: boolean
}
// Blob → data URL(base64). 클립보드 이미지를 chat 계약으로 바꿀 때 씀.
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const r = new FileReader()
r.onload = () => resolve(r.result as string)
r.onerror = () => reject(r.error)
r.readAsDataURL(blob)
})
}
// 붙여넣기 텍스트가 이 길이 이상이면 입력창에 안 넣고 접힌 칩(첨부)으로 보관.
const PASTE_COLLAPSE = 100
const IMAGE_TYPES = new Set<SnapImageMediaType>(["image/png", "image/jpeg", "image/webp"])
const MAX_IMAGES = 4
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const MAX_TOTAL_IMAGE_BYTES = 15 * 1024 * 1024
function imageByteLength(dataUrl: string): number {
const encoded = dataUrl.slice(dataUrl.indexOf(",") + 1)
const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0
return Math.max(0, Math.floor((encoded.length * 3) / 4) - padding)
}
function imageFromDataUrl(data: string): SnapImageInput | null {
const match = /^data:(image\/(?:png|jpeg|webp));base64,/i.exec(data)
if (!match || !IMAGE_TYPES.has(match[1].toLowerCase() as SnapImageMediaType)) return null
return { mediaType: match[1].toLowerCase() as SnapImageMediaType, data }
}
export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: Props) {
const [value, setValue] = useState("")
// 100자↑ 붙여넣기로 접어둔 텍스트들. 전송 시 입력값과 합쳐 보냄.
const [attachments, setAttachments] = useState<string[]>([])
// 캡쳐·클립보드에서 받은 일회성 이미지 첨부. 백엔드는 원본을 저장하지 않음.
const [imageAttachments, setImageAttachments] = useState<SnapImageInput[]>([])
const explain = useSnapChatStore((s) => s.explain)
const setExplain = useSnapChatStore((s) => s.setExplain)
const addImage = (dataUrl: string) => {
const image = imageFromDataUrl(dataUrl)
if (!image) {
toast.error("PNG, JPEG, WebP 이미지만 첨부할 수 있어")
return
}
const bytes = imageByteLength(dataUrl)
if (bytes > MAX_IMAGE_BYTES) {
toast.error("이미지는 한 장당 5 MiB 이하여야 해")
return
}
setImageAttachments((current) => {
if (current.length >= MAX_IMAGES) {
toast.error("이미지는 최대 4장까지 첨부할 수 있어")
return current
}
const total = current.reduce((sum, item) => sum + imageByteLength(item.data), 0) + bytes
if (total > MAX_TOTAL_IMAGE_BYTES) {
toast.error("이미지 전체 크기는 15 MiB 이하여야 해")
return current
}
return [...current, image]
})
}
// 마운트 시(새 대화·지난 대화 진입) 입력창에 커서. rAF로 webview 포커스 안정화 후.
const taRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
const raf = requestAnimationFrame(() => taRef.current?.focus())
return () => cancelAnimationFrame(raf)
}, [])
// 캡쳐 이미지 수신 — 마운트 시 놓친 것 consume(네비 직후 이벤트를 놓쳐도 반영) + 이후는 이벤트로 누적(FR-008).
// acceptCapture 아니면 아예 pending을 안 건드림 — 기존 대화방(SessionChatPage)이 새 대화
// 마운트보다 먼저 훔쳐가 이미지가 유실되는 레이스 방지(출발지 게이팅).
useEffect(() => {
if (!acceptCapture) return
const pending = consumePendingCaptureImage()
if (pending) addImage(pending)
const onCapture = () => {
// detail 대신 consume — 같은 동기 스택이라 값은 동일, 이걸로 pending도 같이 비워야
// 나중에 리마운트될 때(다른 대화→새 대화) stale 이미지가 재소비되지 않음.
const dataUrl = consumePendingCaptureImage()
if (dataUrl) addImage(dataUrl)
}
window.addEventListener("bridge:captureImage", onCapture)
return () => window.removeEventListener("bridge:captureImage", onCapture)
}, [acceptCapture])
const removeAttachment = (i: number) => setAttachments((a) => a.filter((_, j) => j !== i))
const removeImageAttachment = (i: number) =>
setImageAttachments((a) => a.filter((_, j) => j !== i))
const submit = () => {
// 접어둔 첨부들 먼저, 그다음 입력값 — 빈 건 빼고 이어붙임.
const combined = [...attachments, value.trim()].filter(Boolean).join("\n\n")
if ((!combined && imageAttachments.length === 0) || busy) return
setValue("")
setAttachments([])
const images = imageAttachments
setImageAttachments([])
onSend(combined, images)
}
// 이 환경이 클립보드 읽기(텍스트+이미지)를 지원하는가 — 비 https·구형 웹뷰면 read 가 없음.
const clipboardSupported =
typeof navigator !== "undefined" && typeof navigator.clipboard?.read === "function"
// 클릭(사용자 제스처)이라 read() 가 먹음. 텍스트·이미지만 지원.
const pasteFromClipboard = async () => {
if (!clipboardSupported) {
toast.error("이 환경은 클립보드 읽기를 지원하지 않아")
return
}
try {
const items = await navigator.clipboard.read()
let gotText = false
let gotImage = false
for (const item of items) {
const imageType = item.types.find((t) => t.startsWith("image/"))
if (imageType) {
const dataUrl = await blobToDataUrl(await item.getType(imageType))
addImage(dataUrl)
gotImage = true
} else if (item.types.includes("text/plain")) {
const text = (await (await item.getType("text/plain")).text()).trim()
if (text) {
// 길면 접힌 칩으로, 짧으면 입력창에 그대로.
if (text.length >= PASTE_COLLAPSE) setAttachments((a) => [...a, text])
else setValue((v) => (v ? `${v}\n${text}` : text))
gotText = true
}
}
}
if (gotImage && !gotText) toast.success("이미지를 질문에 첨부했어")
else if (!gotText && !gotImage) toast.info("클립보드에 텍스트·이미지가 없어")
} catch {
// NotAllowedError 등 — 사용자가 권한을 막았거나 브라우저가 거부.
toast.error("클립보드를 읽을 수 없어 (권한 거부됨)")
}
}
return (
<div className="border-border bg-card flex-none border-t p-3">
<div className="border-border bg-background focus-within:border-ring focus-within:ring-ring/20 rounded-lg border p-2 focus-within:ring-2">
{/* 캡쳐·클립보드 이미지 첨부 — 썸네일 + X 로 제거 */}
{imageAttachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{imageAttachments.map((image, i) => (
<span
key={i}
className="border-border bg-muted/60 relative flex items-center rounded-md border p-1"
>
<img
src={image.data}
alt={`캡쳐 이미지 ${i + 1}`}
className="h-12 w-12 rounded object-cover"
/>
<button
type="button"
onClick={() => removeImageAttachment(i)}
aria-label="캡쳐 이미지 제거"
className="bg-background border-border text-muted-foreground hover:text-foreground absolute -top-1.5 -right-1.5 rounded-full border p-0.5"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
{/* 100자↑ 붙여넣기 첨부 — 일부만 보이고 X 로 제거 */}
{attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{attachments.map((a, i) => (
<span
key={i}
className="border-border bg-muted/60 text-muted-foreground flex max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-[11px]"
>
<ClipboardPaste className="size-3.5 flex-none text-emerald-500" />
<span className="min-w-0 truncate font-mono">
{a.replace(/\s+/g, " ").slice(0, 40)}
</span>
<span className="flex-none opacity-50">{a.length}</span>
<button
type="button"
onClick={() => removeAttachment(i)}
aria-label="첨부 제거"
className="hover:text-foreground flex-none"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<textarea
ref={taRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onPaste={(e) => {
const imageFile = Array.from(e.clipboardData.files).find((file) =>
file.type.startsWith("image/")
)
if (imageFile) {
e.preventDefault()
void blobToDataUrl(imageFile).then(addImage)
return
}
const text = e.clipboardData.getData("text/plain")
if (text.length >= PASTE_COLLAPSE) {
e.preventDefault() // 길면 입력창에 안 넣고 접힌 칩으로
setAttachments((a) => [...a, text])
}
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
submit()
}
}}
rows={1}
disabled={busy}
placeholder={busy ? "응답 중…" : (placeholder ?? "메시지를 입력하세요…")}
className="placeholder:text-muted-foreground field-sizing-content max-h-32 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
/>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={pasteFromClipboard}
disabled={busy || !clipboardSupported}
title={clipboardSupported ? "클립보드에서 가져오기" : "이 환경은 클립보드 읽기 미지원"}
className="border-border text-muted-foreground hover:border-ring hover:text-foreground inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[10px] transition-colors disabled:opacity-50"
>
<ClipboardPaste className="size-3" />
</button>
<button
type="button"
onClick={() => setExplain(!explain)}
aria-pressed={explain}
title={explain ? "설명 모드 — 배경·원리까지" : "간결 모드 — 답만"}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[10px] transition-colors",
explain
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-ring hover:text-foreground"
)}
>
<BookOpen className="size-3" />
</button>
<span className="text-muted-foreground font-mono text-[10px] tracking-wide">
Enter · Shift+Enter
</span>
{busy ? (
<Button
type="button"
size="sm"
variant="destructive"
className="ml-auto"
onClick={onStop}
>
<Square className="size-3.5 fill-current" />
</Button>
) : (
<Button
type="button"
size="sm"
className="ml-auto"
disabled={!value.trim() && attachments.length === 0 && imageAttachments.length === 0}
onClick={submit}
>
<Send className="size-3.5" />
</Button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,17 @@
export function Hero() {
return (
<section className="flex flex-col items-center px-6 py-10 text-center">
<div className="bg-primary text-primary-foreground mb-4 grid size-14 place-items-center rounded-xl font-serif text-2xl font-bold italic">
S
</div>
<span className="text-muted-foreground mb-3 inline-flex items-center gap-1.5 font-mono text-[10px] tracking-widest uppercase">
<span className="size-1.5 rounded-full bg-emerald-500" />
New Session
</span>
<h1 className="mb-2 font-serif text-2xl font-semibold italic"> ?</h1>
<p className="text-muted-foreground max-w-sm text-sm leading-relaxed">
ABAP · CDS · HANA · .
</p>
</section>
)
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest"
import { render } from "@testing-library/react"
import { Message } from "./Message"
import type { SnapRole } from "../contract/types"
// JSX 에 role 을 문자열 리터럴로 쓰면 jsx-a11y 가 ARIA role 로 오해함 — 변수로 우회
const assistant: SnapRole = "assistant"
describe("Message 코드펜스 렌더", () => {
it("언어 없는 코드펜스(ASCII 다이어그램)도 CodeBlock 으로 렌더된다", () => {
// 언어 라벨 없는 펜스 — 인라인 code 로 새면 ─── 연속 문자가 말풍선 밖으로 넘침
const content = [
"구조 제안:",
"",
"```",
"┌──────────────────────────────┐",
"│ ABAP Productivity App │",
"└──────────────────────────────┘",
"```",
].join("\n")
const { container } = render(<Message role={assistant} content={content} />)
// 스크롤 컨테이너 있는 CodeBlock 으로 감싸져야 함
const block = container.querySelector("[data-code-block]")
expect(block).not.toBeNull()
// 언어 없는 펜스는 다크 코드 테마가 아니라 라이트로
expect(block?.className).toContain("bg-white")
// 인라인 code 스타일(bg-black/10)로 새지 않아야 함
expect(container.querySelector("code.rounded")).toBeNull()
})
it("언어 있는 코드펜스는 기존대로 CodeBlock + 언어 라벨", () => {
const content = "```sql\nSELECT * FROM t;\n```"
const { container } = render(<Message role={assistant} content={content} />)
expect(container.querySelector('[data-code-lang="sql"]')).not.toBeNull()
})
it("인라인 code 는 그대로 인라인으로 렌더된다", () => {
const { container } = render(<Message role={assistant} content="이건 `SMOINT` 임" />)
expect(container.querySelector("code.rounded")).not.toBeNull()
expect(container.querySelector("[data-code-block]")).toBeNull()
})
})
@@ -0,0 +1,209 @@
import { isValidElement, type ReactElement, type ReactNode } from "react"
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import { MoreHorizontal, Copy, Download } from "lucide-react"
import { toast } from "sonner"
import { cn } from "@/lib/utils/cn"
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/shared/ui/dropdown-menu"
import type { SnapRole } from "../contract/types"
import { CodeBlock } from "./CodeBlock"
import { fmtElapsed, fmtTokens } from "../lib/format"
interface Props {
role: SnapRole
content: string
/** 이 답변 전체토큰(assistant). 있으면 버블 밑에 표기. */
totalTokens?: number
/** 이 답변 소요시간(ms). 라이브만 있음(과거는 elapsed 미저장). */
elapsedMs?: number
}
// assistant 마크다운 본문 prose 스타일 — 목업이 표/헤딩/리스트/diff 를 섞어 써서 각 요소 명시 스타일링.
const PROSE = cn(
"first:[&>*]:mt-0 last:[&>*]:mb-0",
"[&_p]:my-1.5 [&_p]:leading-[1.9]",
"[&_strong]:font-semibold [&_strong]:text-foreground",
"[&_ul]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5",
"[&_blockquote]:my-1.5 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground",
"[&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",
"[&_th]:border [&_th]:border-border [&_th]:bg-foreground/5 [&_th]:px-2.5 [&_th]:py-1 [&_th]:text-left [&_th]:font-semibold",
"[&_td]:border [&_td]:border-border [&_td]:px-2.5 [&_td]:py-1"
)
export function Message({ role, content, totalTokens, elapsedMs }: Props) {
const isUser = role === "user"
let local = 0
// 답변 본문(마크다운) 복사 / .md 다운로드.
const copyMd = async () => {
try {
await navigator.clipboard.writeText(content)
toast.success("복사됨 (마크다운)")
} catch {
toast.error("복사 실패")
}
}
const downloadMd = () => {
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "snap-답변.md"
document.body.appendChild(a) // Firefox/일부 Chrome 은 DOM 에 붙어야 클릭 먹음
a.click()
a.remove()
// 즉시 revoke 하면 다운로드 시작 전에 blob 이 사라져 ERR_FILE_NOT_FOUND — 한 틱 미룸
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
return (
<div className={cn("flex flex-col gap-1", isUser ? "items-end" : "items-start")}>
<div className={cn("flex items-center gap-1.5", !isUser && "w-full")}>
{!isUser && (
<span className="bg-primary text-primary-foreground grid size-4 place-items-center rounded font-serif text-[9px] leading-none font-bold italic">
S
</span>
)}
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
{isUser ? "나 · YOU" : "SNAP MATE"}
</span>
{!isUser && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="답변 메뉴"
className="text-muted-foreground hover:bg-accent hover:text-foreground ml-auto inline-flex size-6 items-center justify-center rounded transition-colors"
>
<MoreHorizontal className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={copyMd}>
<Copy className="size-3.5" />
(MD)
</DropdownMenuItem>
<DropdownMenuItem onClick={downloadMd}>
<Download className="size-3.5" />
(MD)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div
className={cn(
"rounded-lg px-3 py-2 text-sm leading-[1.9]",
isUser
? "bg-secondary text-secondary-foreground max-w-[85%] whitespace-pre-wrap"
: "border-border bg-card text-card-foreground w-full border"
)}
>
{isUser ? (
content
) : (
<div className={PROSE}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// 코드펜스는 언어 유무와 무관하게 전부 CodeBlock 으로.
// pre 를 그냥 언랩하면 언어 없는 펜스(ASCII 다이어그램 등)가
// 인라인 code 로 렌더돼 ─── 연속 문자가 말풍선 밖으로 넘침.
pre: ({ children }) => {
const child = isValidElement(children)
? (children as ReactElement<{ className?: string; children?: ReactNode }>)
: null
if (!child) return <pre>{children}</pre>
const match = /language-(\w+)/.exec(child.props.className ?? "")
const index = local++
return (
<CodeBlock
code={String(child.props.children ?? "").replace(/\n$/, "")}
lang={match?.[1]}
index={index}
/>
)
},
// 헤딩·구분선 여백은 inline style 로 직접 박음(Tailwind 재생성/캐시 이슈 회피).
// top 마진 크게 줘서 앞 섹션이랑 확실히 떨어짐.
h1: ({ children }) => (
<h1
style={{
marginTop: "1.5rem",
marginBottom: "0.5rem",
fontSize: "1.125rem",
fontWeight: 700,
lineHeight: 1.3,
}}
>
{children}
</h1>
),
h2: ({ children }) => (
<h2
style={{
marginTop: "1.5rem",
marginBottom: "0.5rem",
fontSize: "1rem",
fontWeight: 700,
lineHeight: 1.3,
}}
>
{children}
</h2>
),
h3: ({ children }) => (
<h3
style={{
marginTop: "1.25rem",
marginBottom: "0.375rem",
fontSize: "0.9375rem",
fontWeight: 600,
lineHeight: 1.3,
}}
>
{children}
</h3>
),
hr: () => (
<hr
style={{
marginTop: "1rem",
marginBottom: "1rem",
border: 0,
borderTop: "1px solid var(--border)",
}}
/>
),
table: ({ children }) => (
<div className="my-2 overflow-x-auto">
<table className="w-full border-collapse text-xs">{children}</table>
</div>
),
// 블록 코드는 위 pre 에서 다 처리되니 여기 오는 건 인라인뿐.
code: ({ children }) => (
<code className="rounded bg-black/10 px-1 py-0.5 font-mono text-[0.85em]">
{children}
</code>
),
}}
>
{content}
</ReactMarkdown>
</div>
)}
</div>
{!isUser && totalTokens != null && (
<span className="text-muted-foreground/60 px-1 font-mono text-[9px] tabular-nums">
{fmtTokens(totalTokens)} tok
{elapsedMs != null && ` · ${fmtElapsed(elapsedMs)}`}
</span>
)}
</div>
)
}
@@ -0,0 +1,93 @@
import { useState } from "react"
import { Check, Code2, Copy } from "lucide-react"
import { toast } from "sonner"
import { ClipboardHistory } from "./ClipboardHistory"
interface Block {
index: number
lang: string
}
interface Props {
/** 렌더된 코드블럭 목록(DOM 순서, index=data-code-block 과 동일). */
blocks: Block[]
/** 스트림 컨테이너 ref — 코드블럭으로 스크롤 점프. */
containerRef: React.RefObject<HTMLDivElement>
}
export function NavRail({ blocks, containerRef }: Props) {
const [copiedIdx, setCopiedIdx] = useState<number | null>(null)
const jump = (index: number) => {
const el = containerRef.current?.querySelector<HTMLElement>(`[data-code-block="${index}"]`)
el?.scrollIntoView({ behavior: "smooth", block: "center" })
}
// 점프와 같은 방식으로 해당 코드블럭 DOM 을 찾아 실제 코드 텍스트를 복사.
const copy = async (index: number) => {
const el = containerRef.current?.querySelector<HTMLElement>(
`[data-code-block="${index}"] pre code`
)
const text = el?.textContent
if (!text) return
try {
await navigator.clipboard.writeText(text)
setCopiedIdx(index)
toast.success("클립보드에 복사됨")
setTimeout(() => setCopiedIdx(null), 1400)
} catch {
toast.error("복사 실패")
}
}
return (
<aside className="border-border bg-card hidden w-52 flex-none flex-col border-r min-[540px]:flex">
<ClipboardHistory />
<div className="border-border flex flex-none items-center gap-1.5 border-b px-3 py-2.5">
<span className="size-1.5 rounded-full bg-emerald-500" />
<span className="text-muted-foreground font-mono text-[10px] tracking-widest uppercase">
Source Nav
</span>
</div>
{blocks.length === 0 ? (
<div className="text-muted-foreground p-4 font-mono text-[10px] leading-relaxed">
<br />
<br />
.
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-1.5">
{blocks.map((b) => (
<div key={b.index} className="group hover:bg-accent flex items-center rounded">
<button
type="button"
onClick={() => jump(b.index)}
className="flex min-w-0 flex-1 items-center gap-1.5 rounded px-2 py-1.5 text-left font-mono text-[11px]"
>
<Code2 className="text-muted-foreground size-3 flex-none" />
<span className="text-muted-foreground flex-none tabular-nums">#{b.index + 1}</span>
<span className="truncate">{b.lang}</span>
</button>
<button
type="button"
onClick={() => copy(b.index)}
aria-label="코드 복사"
title="코드 복사"
className="text-muted-foreground/50 hover:text-foreground flex-none rounded p-1.5 transition-colors"
>
{copiedIdx === b.index ? (
<Check className="size-3 text-emerald-500" />
) : (
<Copy className="size-3" />
)}
</button>
</div>
))}
</div>
)}
</aside>
)
}
@@ -0,0 +1,60 @@
import { CornerDownRight, MessageSquare } from "lucide-react"
import type { ReactNode } from "react"
import type { SnapMessage } from "../contract/types"
import { formatRelativeKo } from "@/lib/utils/relativeTime"
interface Props {
hit: SnapMessage
query: string
onOpen: (sessionId: string) => void
/** 키보드 이동으로 선택된 항목 — 하이라이트 + 스크롤 대상. */
selected?: boolean
}
// 매칭 지점 주변만 잘라 보여주고, 검색어를 <mark> 로 강조.
function snippet(text: string, query: string): ReactNode {
const idx = text.toLowerCase().indexOf(query.toLowerCase())
if (idx < 0) return text.slice(0, 120)
const start = Math.max(0, idx - 40)
const end = Math.min(text.length, idx + query.length + 80)
return (
<>
{start > 0 && "…"}
{text.slice(start, idx)}
<mark className="bg-primary/20 text-foreground rounded px-0.5">
{text.slice(idx, idx + query.length)}
</mark>
{text.slice(idx + query.length, end)}
{end < text.length && "…"}
</>
)
}
export function SearchHitCard({ hit, query, onOpen, selected }: Props) {
return (
<button
type="button"
onClick={() => onOpen(hit.sessionId)}
data-snap-selected={selected ? "true" : undefined}
className={`focus-visible:ring-ring flex w-full items-start gap-3 rounded-lg p-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none ${
selected ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<MessageSquare className="text-muted-foreground mt-0.5 size-4 flex-none" />
<span className="flex min-w-0 flex-col">
<span className="line-clamp-2 text-xs leading-relaxed break-words whitespace-pre-wrap">
{snippet(hit.content, query)}
</span>
<span className="text-muted-foreground mt-1.5 flex items-center gap-2 font-mono text-[9.5px]">
<span className="tracking-wide uppercase">
{hit.role === "user" ? "나" : "SNAP MATE"}
</span>
<span>·</span>
<span>{formatRelativeKo(hit.createdAt)}</span>
<CornerDownRight className="size-3" />
<span> </span>
</span>
</span>
</button>
)
}
@@ -0,0 +1,43 @@
import { MessageSquare, ChevronRight } from "lucide-react"
import type { SnapSession } from "../contract/types"
import { formatRelativeKo } from "@/lib/utils/relativeTime"
interface Props {
session: SnapSession
onOpen: (id: string) => void
/** 키보드 이동으로 선택된 항목 — 하이라이트 + 스크롤 대상. */
selected?: boolean
}
export function SessionCard({ session, onOpen, selected }: Props) {
const title = session.titleLlm ?? session.title ?? "제목 없음"
return (
// 컴팩트 1행 — 세로를 1/3로. 제목·태그·시간·상태를 한 줄에, 스니펫은 생략(제목 title 로 노출).
<button
type="button"
onClick={() => onOpen(session.id)}
data-snap-selected={selected ? "true" : undefined}
title={session.snippet ?? title}
className={`focus-visible:ring-ring flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none ${
selected ? "bg-accent text-accent-foreground" : "hover:bg-muted"
}`}
>
<span className="bg-muted text-muted-foreground grid size-8 flex-none place-items-center rounded-lg">
<MessageSquare className="size-4" />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{title}</span>
{session.tag && (
<span className="border-border text-muted-foreground flex-none rounded border px-1.5 font-mono text-[9.5px] tracking-wide uppercase">
{session.tag}
</span>
)}
{session.isGenerating && (
<span className="size-1.5 flex-none rounded-full bg-emerald-500" title="생성 중" />
)}
<span className="text-muted-foreground flex-none font-mono text-[9.5px]">
{formatRelativeKo(session.updatedAt)}
</span>
<ChevronRight className="text-muted-foreground size-3.5 flex-none" />
</button>
)
}
@@ -0,0 +1,47 @@
import { useEffect, useRef } from "react"
import { Search, X } from "lucide-react"
interface Props {
value: string
onChange: (v: string) => void
}
export function SessionSearch({ value, onChange }: Props) {
// 런처 답게 뜨자마자 검색창에 포커스 — 여기서 타이핑=필터, ↑↓=목록 이동, Enter=열기.
// 창이 다시 떠서(핫키 재소환) window 가 포커스 받을 때도 다시 잡아준다.
const ref = useRef<HTMLInputElement>(null)
useEffect(() => {
const focus = () => ref.current?.focus()
focus()
window.addEventListener("focus", focus)
return () => window.removeEventListener("focus", focus)
}, [])
return (
<div className="relative flex min-w-0 flex-1 items-center">
<Search className="text-muted-foreground pointer-events-none absolute left-0 size-5" />
<input
ref={ref}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label="대화 내용 검색"
placeholder="대화 내용 검색…"
className="placeholder:text-muted-foreground/75 focus-visible:ring-ring/40 w-full rounded-md bg-transparent py-3 pr-9 pl-9 text-lg outline-none focus-visible:ring-2"
/>
{value && (
<button
type="button"
aria-label="검색어 지우기"
className="text-muted-foreground hover:bg-accent focus-visible:ring-ring absolute right-1 rounded p-1 focus-visible:ring-2"
onClick={() => {
onChange("")
ref.current?.focus()
}}
>
<X className="size-4" />
</button>
)}
</div>
)
}
@@ -0,0 +1,45 @@
import { hostKind } from "@/lib/bridge/transport"
import { useEffect } from "react"
import { Outlet, useNavigate } from "react-router-dom"
import { PATHS } from "@/config/routes"
import { startWindowDrag } from "@/lib/bridge/webviewBridge"
import { SnapUserControls } from "./SnapUserControls"
/** 웹뷰 풀블리드 셸 — .NET 창이 진짜 크롬을 주므로 가짜 타이틀바는 없음. 얇은 브랜드 스트립만. */
export function SnapLayout() {
const navigate = useNavigate()
// Ctrl+N → 새 대화(앱 안 어디서나). 브라우저 기본동작(새 창)은 막음.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.isComposing) return
if (e.ctrlKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "N")) {
e.preventDefault()
navigate(PATHS.SNAP_NEW)
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [navigate])
return (
<div className="bg-background text-foreground flex h-screen flex-col">
{/* 헤더 = 창 드래그 영역(프레임리스 제목표시줄 대체) — title-bar 드래그라 a11y 룰 한 줄 예외 */}
{hostKind() !== "tauri" && (
<header
role="presentation"
onMouseDown={startWindowDrag}
className="border-border flex h-9 flex-none items-center gap-2 border-b px-4 select-none"
>
<span className="font-mono text-[11px] font-semibold tracking-[0.12em]">
Chat Everywhere
</span>
</header>
)}
<SnapUserControls />
<div className="min-h-0 flex-1">
<Outlet />
</div>
</div>
)
}
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { fireEvent, render, screen } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { SnapUserControls } from "./SnapUserControls"
const mutate = vi.fn()
vi.mock("@/features/auth/hooks/useLogout", () => ({
useLogout: () => ({ mutate, isPending: false }),
}))
vi.mock("@/shared/components/ThemeToggle", () => ({
ThemeToggle: () => <button type="button"> </button>,
}))
describe("SnapUserControls", () => {
beforeEach(() => mutate.mockClear())
it("사이드바 없이 테마 변경과 로그아웃을 표시", () => {
render(
<MemoryRouter>
<SnapUserControls />
</MemoryRouter>
)
expect(screen.getByRole("button", { name: "테마 변경" })).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "로그아웃" }))
expect(mutate).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,35 @@
import { LogOut } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { PATHS } from "@/config/routes"
import { useLogout } from "@/features/auth/hooks/useLogout"
import { ThemeToggle } from "@/shared/components/ThemeToggle"
import { Button } from "@/shared/ui/button"
/** 사이드바 없는 Snap 창에서도 테마 변경과 로그아웃에 바로 접근하게 함. */
export function SnapUserControls() {
const navigate = useNavigate()
const logout = useLogout()
const handleLogout = () => {
logout.mutate(undefined, {
onSettled: () => navigate(PATHS.LOGIN, { replace: true }),
})
}
return (
<div className="border-border bg-background/90 fixed top-11 right-3 z-40 flex items-center gap-1 rounded-lg border p-1 shadow-sm backdrop-blur">
<ThemeToggle />
<Button
type="button"
variant="ghost"
size="icon"
aria-label="로그아웃"
title="로그아웃"
disabled={logout.isPending}
onClick={handleLogout}
>
<LogOut className="size-4" aria-hidden="true" />
</Button>
</div>
)
}
@@ -0,0 +1,34 @@
import type { HLJSApi, Language } from "highlight.js"
// highlight.js 코어엔 ABAP 이 없어서 경량 문법을 직접 등록한다.
// ABAP OO + Open SQL + CDS(DDL) + RAP behavior 를 한 문법으로 커버 — 목업 렌더용이라 완벽 파서는 아님.
export default function abap(hljs: HLJSApi): Language {
const KEYWORDS =
"select from into table where and or not as inner left right outer join on group by " +
"order having distinct up to rows loop at endloop do enddo while endwhile if elseif " +
"else endif case when others endcase data types constants field-symbols read append " +
"modify insert update delete clear refresh sort binary search for all entries in " +
"package size cond switch value new corresponding lines of exporting importing changing " +
"returning raising method endmethod class endclass public protected private section " +
"define view entity projection root key managed unmanaged implementation unique strict " +
"behavior persistent lock master authorization instance create determination validation " +
"association service expose annotate with begin end of is initial single"
return {
name: "ABAP",
case_insensitive: true,
keywords: {
keyword: KEYWORDS,
built_in: "sy-subrc sy-tabix sy-index sy-datum sy-uzeit abap_true abap_false",
},
contains: [
hljs.COMMENT("^\\*", "$"), // 전체 줄 주석 (* 로 시작)
hljs.COMMENT('"', "$"), // 인라인 주석 (")
{ className: "string", begin: "'", end: "'" },
{ className: "string", begin: "`", end: "`" },
{ className: "string", begin: "\\|", end: "\\|" }, // 문자열 템플릿 |...|
{ className: "meta", begin: "@[A-Za-z][\\w.]*" }, // @UI.lineItem / @DATA 등
hljs.C_NUMBER_MODE,
],
}
}
@@ -0,0 +1,26 @@
/* SAP ABAP 에디터 : 배경 + 파란 키워드 + 검정 본문.
github-dark 전역 .hljs-* 색을 ABAP 블록에만 스코프로 덮어쓴다
(.hljs-abap-light .hljs-keyword specificity 전역 규칙을 이긴다). */
.hljs-abap-light {
color: #1a1a1a;
}
.hljs-abap-light .hljs-keyword {
color: #0033b3;
font-weight: 600;
}
.hljs-abap-light .hljs-built_in {
color: #0e7490;
}
.hljs-abap-light .hljs-string {
color: #a31515;
}
.hljs-abap-light .hljs-comment {
color: #6b7280;
font-style: italic;
}
.hljs-abap-light .hljs-meta {
color: #7a3e9d;
}
.hljs-abap-light .hljs-number {
color: #098658;
}
@@ -0,0 +1,47 @@
// base-backend modules/chat/schema.py 와 1:1 (camelCase). UI-only 필드는 명시 표기.
export type SnapRole = "user" | "assistant" | "system"
export interface SnapSession {
id: string
title: string | null
titleLlm: string | null
isGenerating: boolean
createdAt: string
updatedAt: string
// --- UI-only: 백엔드에 없음. real 스왑 시 드롭 또는 파생(snippet=마지막메시지) ---
tag?: string
snippet?: string
tokens?: string
}
export interface SnapMessage {
sessionId: string
role: SnapRole
content: string // markdown
createdAt: string
// usage/timing — assistant 행에만 채워짐(user 는 null). 전체토큰 = input+output.
inputTokens?: number | null
outputTokens?: number | null
costUsd?: number | null
elapsedMs?: number | null
}
export interface SnapSessionDetail extends SnapSession {
messages: SnapMessage[]
}
export type SnapImageMediaType = "image/png" | "image/jpeg" | "image/webp"
export interface SnapImageInput {
mediaType: SnapImageMediaType
data: string
}
export interface SnapStreamRequest {
sessionId: string
content: string
images?: SnapImageInput[]
forcedSkill?: string
// 설명 모드 토글 — true면 배경·원리까지, 기본(false)은 간결.
explain?: boolean
}
@@ -0,0 +1,17 @@
import { useEffect, useRef } from "react"
/**
* Esc . (Radix ) Esc(defaultPrevented)
* . handler ref .
*/
export function useEscapeKey(handler: () => void) {
const ref = useRef(handler)
ref.current = handler
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.defaultPrevented) ref.current()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [])
}
@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { renderHook } from "@testing-library/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { toast } from "sonner"
import { useSnapChat } from "./useSnapChat"
import * as stream from "../api/snap.stream"
import { useSnapChatStore } from "../store/snapChatStore"
vi.mock("../api/snap.stream")
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }))
let qc: QueryClient
function wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
beforeEach(() => {
qc = new QueryClient()
useSnapChatStore.getState().reset()
vi.clearAllMocks()
})
describe("useSnapChat", () => {
it("onTitle: 상세 캐시 title 즉시 패치 + 목록 캐시 invalidate", async () => {
qc.setQueryData(["snap", "session", "s1"], {
id: "s1",
title: null,
titleLlm: null,
isGenerating: false,
createdAt: "",
updatedAt: "",
messages: [],
})
const invalidate = vi.spyOn(qc, "invalidateQueries")
vi.mocked(stream.snapStream).mockImplementation(async (_req, handlers) => {
handlers.onTitle?.("새 제목")
})
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
const detail = qc.getQueryData(["snap", "session", "s1"]) as { title: string | null }
expect(detail.title).toBe("새 제목")
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["snap", "sessions"] })
})
it("409 에러면 '이미 생성 중' 안내 toast", async () => {
vi.mocked(stream.snapStream).mockImplementation(async (_req, handlers) => {
handlers.onError?.(new Error("SSE open failed: 409"))
})
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining("이미 생성 중"))
})
it("open 실패로 snapStream 이 reject 해도 send 는 throw 하지 않는다", async () => {
vi.mocked(stream.snapStream).mockRejectedValue(new Error("SSE open failed: 500"))
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await expect(result.current.send("hi")).resolves.toBeUndefined()
})
it("이미 스트리밍 중이면 두 번째 send 는 무시(중복 가드)", async () => {
useSnapChatStore.getState().setStreaming(true)
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
expect(stream.snapStream).not.toHaveBeenCalled()
})
it("이미지를 chat stream 요청에 포함한다", async () => {
vi.mocked(stream.snapStream).mockResolvedValue(undefined)
const images = [{ mediaType: "image/png" as const, data: "data:image/png;base64,eA==" }]
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("", images)
expect(vi.mocked(stream.snapStream).mock.calls[0][0]).toMatchObject({
sessionId: "s1",
content: "",
images,
})
expect(useSnapChatStore.getState().messages[0].content).toBe("첨부 이미지를 분석해줘.")
})
it("에러로 토큰 0개면 빈 assistant 버블을 남기지 않는다", async () => {
vi.mocked(stream.snapStream).mockImplementation(async (_req, handlers) => {
handlers.onError?.(new Error("SSE open failed: 409"))
})
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.send("hi")
const msgs = useSnapChatStore.getState().messages
expect(msgs.some((m) => m.role === "assistant" && m.content === "")).toBe(false)
})
it("stop 은 store.stop 후 cancelStream(id) 를 부른다", async () => {
useSnapChatStore.getState().setStreaming(true)
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
result.current.stop()
expect(vi.mocked(stream.cancelStream)).toHaveBeenCalledWith("s1")
expect(useSnapChatStore.getState().isStreaming).toBe(false)
})
it("retry 는 마지막 유저 메시지를 다시 보낸다", async () => {
useSnapChatStore.getState().reset()
useSnapChatStore.getState().addUserMessage("원래 질문")
vi.mocked(stream.snapStream).mockResolvedValue(undefined)
const { result } = renderHook(() => useSnapChat("s1"), { wrapper })
await result.current.retry()
expect(vi.mocked(stream.snapStream)).toHaveBeenCalled()
const req = vi.mocked(stream.snapStream).mock.calls[0][0]
expect(req.content).toBe("원래 질문")
})
})
@@ -0,0 +1,92 @@
import { useCallback } from "react"
import { toast } from "sonner"
import { useQueryClient, type QueryClient } from "@tanstack/react-query"
import { useSnapChatStore } from "../store/snapChatStore"
import { snapStream, cancelStream } from "../api/snap.stream"
import type { SnapImageInput, SnapSessionDetail } from "../contract/types"
// title 이벤트 → 상세 캐시는 즉시 패치(헤더 제목 실시간 반영), 목록은 invalidate.
// 목록 키는 ["snap","sessions",page] 라 페이지별 직접 패치 대신 무효화가 안전(모양도 {items,meta}).
function patchSessionTitle(queryClient: QueryClient, sessionId: string, title: string): void {
void queryClient.invalidateQueries({ queryKey: ["snap", "sessions"] })
queryClient.setQueryData<SnapSessionDetail>(["snap", "session", sessionId], (prev) =>
prev ? { ...prev, title } : prev
)
}
/** store + snapStream 배선 — 전송/중단. sessionId 는 현재 열린 세션. */
export function useSnapChat(sessionId: string) {
const queryClient = useQueryClient()
const send = useCallback(
async (text: string, images: SnapImageInput[] = []) => {
const store = useSnapChatStore.getState()
if (store.isStreaming) return // 이 클라이언트가 이미 생성 중 — 중복 전송 차단
store.currentController?.abort()
store.addUserMessage(text.trim() || "첨부 이미지를 분석해줘.")
store.startAssistantMessage()
store.setStreaming(true)
const ctrl = new AbortController()
store.setController(ctrl)
try {
await snapStream(
{
sessionId,
content: text,
...(images.length > 0 ? { images } : {}),
explain: store.explain,
},
{
onToken: (d) => useSnapChatStore.getState().appendChunk(d),
// done 시점엔 백엔드가 이미 답변을 DB 에 저장함(streaming.py: persist→usage→done).
// detail 캐시를 무효화해 재진입 시 stale 스냅샷 대신 완성본을 받게 함.
onDone: () =>
void queryClient.invalidateQueries({
queryKey: ["snap", "session", sessionId],
}),
onTitle: (title) => patchSessionTitle(queryClient, sessionId, title),
onUsage: (u) =>
useSnapChatStore.getState().applyUsage({
used: u.used,
limit: u.limit,
elapsedMs: u.elapsed_ms,
}),
onError: (e) => {
if (e.message.includes("409")) {
toast.error("이미 생성 중인 세션이야. 잠깐 기다렸다 다시 보내.")
} else {
toast.error(`스트림 오류: ${e.message}`)
}
useSnapChatStore.getState().dropEmptyAssistantTail()
},
},
{ signal: ctrl.signal }
)
} catch {
// sse.ts 는 open 실패(409/5xx) 시 onError 를 부른 뒤 promise 도 reject 한다.
// 오류 표시는 위 onError 에서 이미 함 → 여기선 rejection 만 삼켜 unhandled 방지.
// abort 는 라이브러리가 resolve 처리하므로 여기로 안 옴.
useSnapChatStore.getState().dropEmptyAssistantTail()
} finally {
const s = useSnapChatStore.getState()
if (s.currentController === ctrl) {
s.setController(null)
s.setStreaming(false)
}
}
},
[sessionId, queryClient]
)
const stop = useCallback(() => {
useSnapChatStore.getState().stop()
void cancelStream(sessionId) // 백엔드 취소 통보(best-effort)
}, [sessionId])
const retry = useCallback(() => {
const q = useSnapChatStore.getState().getRetryQuery()
if (q) void send(q)
}, [send])
return { send, stop, retry }
}
@@ -0,0 +1,11 @@
// 토큰 수 축약 — 512 / 3.2k / 1.05M.
export function fmtTokens(n: number): string {
if (n < 1000) return String(n)
if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`
return `${(n / 1_000_000).toFixed(2)}M`
}
// 소요시간(ms) → 초. 4.1s.
export function fmtElapsed(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`
}
@@ -0,0 +1,58 @@
import { useRef, useState } from "react"
import { useNavigate } from "react-router-dom"
import { ChevronLeft } from "lucide-react"
import { PATHS } from "@/config/routes"
import { useCreateSession } from "../api/snap.api"
import { Hero } from "../components/Hero"
import { NavRail } from "../components/NavRail"
import { Composer } from "../components/Composer"
import { useEscapeKey } from "../hooks/useEscapeKey"
import type { SnapImageInput } from "../contract/types"
export default function NewChatPage() {
const navigate = useNavigate()
const createSession = useCreateSession()
const [pending, setPending] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
// 단계적 Esc: 새 대화 화면에선 목록으로.
useEscapeKey(() => navigate(PATHS.SNAP))
// 첫 전송: 세션 생성 → 채팅 페이지로 이동하며 firstMessage 전달(거기서 스트림).
const start = async (text: string, images: SnapImageInput[]) => {
if (pending) return
setPending(true)
const session = await createSession.mutateAsync()
navigate(`/snap/s/${session.id}`, { state: { firstMessage: text, firstImages: images } })
}
// 새 대화도 좌측 레일 유지 — 클립보드 이력은 바로 쓰고, Source Nav 는 코드블럭 없음 상태로 표시.
return (
<div className="flex h-full flex-col">
<div className="border-border bg-card flex flex-none items-center border-b px-3 py-2.5">
<button
type="button"
onClick={() => navigate(PATHS.SNAP)}
className="border-border bg-background text-muted-foreground hover:border-ring hover:text-foreground inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[11px]"
>
<ChevronLeft className="size-3" />
</button>
</div>
<div className="flex min-h-0 flex-1">
<NavRail blocks={[]} containerRef={scrollRef} />
<div ref={scrollRef} className="min-w-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl">
<Hero />
</div>
</div>
</div>
<Composer
onSend={start}
busy={pending}
placeholder="새 대화를 시작하세요 — 질문을 입력하거나 코드를 붙여넣어봐…"
acceptCapture
/>
</div>
)
}
@@ -0,0 +1,228 @@
import { useEffect, useLayoutEffect, useRef, useState, type WheelEvent } from "react"
import { useParams, useNavigate, useLocation } from "react-router-dom"
import { useShallow } from "zustand/react/shallow"
import { PATHS } from "@/config/routes"
import { StreamingText, StoppedNotice } from "@/lib/streaming"
import { useSessionMessages } from "../api/snap.api"
import { useSnapChatStore } from "../store/snapChatStore"
import { useSnapChat } from "../hooks/useSnapChat"
import { useEscapeKey } from "../hooks/useEscapeKey"
import type { SnapImageInput, SnapSession } from "../contract/types"
import { ChatHeader } from "../components/ChatHeader"
import { NavRail } from "../components/NavRail"
import { Message } from "../components/Message"
import { Composer } from "../components/Composer"
export default function SessionChatPage() {
const { id = "" } = useParams()
const navigate = useNavigate()
const location = useLocation()
const { data: detail } = useSessionMessages(id)
const { send, stop, retry } = useSnapChat(id)
const [navBlocks, setNavBlocks] = useState<{ index: number; lang: string }[]>([])
const scrollRef = useRef<HTMLDivElement>(null)
// 단계적 Esc: 대화창에선 목록/검색 화면으로(창은 목록에서 Esc 로 숨김).
useEscapeKey(() => navigate(PATHS.SNAP))
const { messages, isStreaming, isRevealing } = useSnapChatStore(
useShallow((s) => ({
messages: s.messages,
isStreaming: s.isStreaming,
isRevealing: s.isRevealing,
}))
)
const busy = isStreaming || isRevealing
// 백엔드가 이 세션을 생성 중인데 이 탭에 라이브 스트림이 없음(재진입) —
// 시머로 "생각 중" 표시 + 전송 잠금(보내면 어차피 409). 폴링이 완성본을 곧 가져옴.
const generatingRemotely = !busy && !!detail?.isGenerating && messages.at(-1)?.role === "user"
// 세션 진입: 과거대화 seed. NewChatPage 에서 넘어온 firstMessage 있으면 seed 없이 바로 전송.
const firstRequest = location.state as {
firstMessage?: string
firstImages?: SnapImageInput[]
} | null
const firstMessage = firstRequest?.firstMessage
const firstImages = firstRequest?.firstImages ?? []
useEffect(() => {
const store = useSnapChatStore.getState()
// 라이브(streaming/revealing) 중이면 store 가 유일본 — 절대 안 덮음(seed 는 abort 까지 함).
if (store.sessionId === id && (store.isStreaming || store.isRevealing)) return
if (firstMessage || firstImages.length > 0) {
store.seed(id, [])
void send(firstMessage ?? "", firstImages)
// state 소비 후 제거(새로고침 시 재전송 방지)
navigate(`/snap/s/${id}`, { replace: true, state: null })
} else if (detail) {
// 시머(생성 중 재진입) 응시 중 답변이 폴링으로 도착한 케이스 — seed(즉시 팝) 대신
// 그 답변만 꼬리에 붙여 0부터 타자기로 풀기. 라이브 스트림 봤을 때와 같은 감각.
const tail = detail.messages.at(-1)
if (
store.sessionId === id &&
store.messages.length > 0 &&
store.messages.at(-1)?.role === "user" &&
detail.messages.length === store.messages.length + 1 &&
tail?.role === "assistant"
) {
store.appendRecoveredAssistant(tail)
return
}
// 라이브 아니면 "정보량 많은 쪽" 으로 단조 수렴 — DB 가 store 보다 내용이 많을 때만 seed.
// stale 스냅샷이 완성본을 덮는 것도, 깨진 store(답변 유실)가 pin 되는 것도 다 여기서 걸러짐.
// detail 은 refetch/폴링마다 갱신돼 effect 재실행 → 어느 타이밍에 들어와도 결국 완전본으로 수렴.
const len = (msgs: { content: string }[]) => msgs.reduce((n, m) => n + m.content.length, 0)
if (store.sessionId === id && len(store.messages) >= len(detail.messages)) return
store.seed(id, detail.messages)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, detail])
// 하단 고정(따라가기). 사용자 "제스처"로만 판단 — 강제 점프한 스크롤은 wheel/touch
// 이벤트를 안 쏘니 안 꼬임(onScroll 로 하면 점프가 stick 을 도로 켜서 못 벗어남).
const stickRef = useRef(true)
const restick = () => {
const el = scrollRef.current
if (el) stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80
}
const onWheel = (e: WheelEvent<HTMLDivElement>) => {
if (e.deltaY < 0)
stickRef.current = false // 위로 굴리면 즉시 따라가기 해제
else restick() // 아래로 굴려 바닥 닿으면 재개
}
// 스트리밍 중엔 rAF 로 매 프레임 바닥 고정. 타자기 reveal 이 프레임마다 내용을 늘려서
// messages 변화만으론 못 따라가 튐 — 연속 고정해야 안 번쩍임.
useEffect(() => {
if (!busy) return
let raf = 0
const follow = () => {
const el = scrollRef.current
if (el && stickRef.current) el.scrollTop = el.scrollHeight
raf = requestAnimationFrame(follow)
}
raf = requestAnimationFrame(follow)
return () => cancelAnimationFrame(raf)
}, [busy])
// 유휴 상태 메시지 추가(전송/세션 seed) 시 한 번 바닥으로.
useEffect(() => {
const el = scrollRef.current
if (el && stickRef.current) el.scrollTop = el.scrollHeight
}, [messages])
const session: SnapSession = detail ?? {
id,
title: "새 대화 세션",
titleLlm: null,
isGenerating: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
// 코드블럭 번호를 "실제 렌더된 DOM 순서"로 단일 부여 — 코드블록·NavRail 이 같은 근원을 씀.
// 매 커밋마다 재적용(React 가 prop 값으로 되돌려도 paint 전에 덮음). nav 목록은 바뀔 때만 setState.
useLayoutEffect(() => {
const els = scrollRef.current?.querySelectorAll<HTMLElement>("[data-code-block]")
const list: { index: number; lang: string }[] = []
els?.forEach((el, i) => {
el.dataset.codeBlock = String(i)
const num = el.querySelector("[data-code-num]")
if (num) num.textContent = String(i + 1)
list.push({
index: i,
lang: (el.dataset.codeLang ?? "code").toUpperCase(),
})
})
setNavBlocks((prev) =>
prev.length === list.length &&
prev.every((b, i) => b.index === list[i].index && b.lang === list[i].lang)
? prev
: list
)
})
return (
<div className="flex h-full flex-col">
<ChatHeader session={session} onBack={() => navigate(PATHS.SNAP)} />
<div className="flex min-h-0 flex-1">
<NavRail blocks={navBlocks} containerRef={scrollRef} />
<div
ref={scrollRef}
onWheel={onWheel}
onTouchMove={restick}
className="min-w-0 flex-1 overflow-y-auto"
>
<div className="theme-light mx-auto flex max-w-2xl flex-col gap-4 p-4">
{messages.map((m, i) => {
const isLiveLast =
busy && i === messages.length - 1 && m.role === "assistant" && !m.frozen
if (isLiveLast) {
return (
<div key={m.id} className="flex flex-col items-start gap-1">
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
SNAP MATE
</span>
<div className="border-border bg-card text-card-foreground w-full rounded-lg border px-3 py-2 text-sm leading-[1.9]">
{m.content === "" ? (
// 첫 토큰 오기 전 빈 시간 메움 — 시머 텍스트로 "생각 중" 신호
<span className="shimmer-text text-sm"> </span>
) : (
<StreamingText
text={m.content}
isStreaming={isStreaming}
cps={{ baseCps: 200, maxCps: 200, startEmpty: m.reveal }}
onRevealEnd={() => useSnapChatStore.getState().setRevealing(false)}
/>
)}
</div>
</div>
)
}
if (m.frozen && m.role === "assistant") {
return (
<div key={m.id} className="flex flex-col gap-2">
<Message
role={m.role}
content={m.content}
totalTokens={m.totalTokens}
elapsedMs={m.elapsedMs}
/>
<StoppedNotice onRetry={retry} disabled={busy} />
</div>
)
}
return (
<Message
key={m.id}
role={m.role}
content={m.content}
totalTokens={m.totalTokens}
elapsedMs={m.elapsedMs}
/>
)
})}
{generatingRemotely && (
<div className="flex flex-col items-start gap-1">
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
SNAP MATE
</span>
<div className="border-border bg-card text-card-foreground w-full rounded-lg border px-3 py-2 text-sm leading-[1.9]">
<span className="shimmer-text text-sm"> </span>
</div>
</div>
)}
</div>
</div>
</div>
<Composer
onSend={(t, images) => {
stickRef.current = true // 전송 시 바닥 고정 재개 → 새 질문/답변 따라감
void send(t, images)
}}
busy={busy || generatingRemotely}
onStop={stop}
placeholder="ABAP · CDS · 에러 로그를 붙여넣거나 질문하세요…"
/>
</div>
)
}
@@ -0,0 +1,203 @@
import { useEffect, useRef, useState } from "react"
import { useNavigate } from "react-router-dom"
import { ChevronLeft, ChevronRight, Plus } from "lucide-react"
import { PATHS } from "@/config/routes"
import { useDebounce } from "@/lib/hooks/useDebounce"
import { hideWindow } from "@/lib/bridge/webviewBridge"
import type { SnapSession, SnapMessage } from "../contract/types"
import { Kbd } from "@/shared/components/Kbd"
import { useSessionList, useSearchMessages } from "../api/snap.api"
import { SessionSearch } from "../components/SessionSearch"
import { SessionCard } from "../components/SessionCard"
import { SearchHitCard } from "../components/SearchHitCard"
import { useEscapeKey } from "../hooks/useEscapeKey"
export default function SessionListPage() {
const navigate = useNavigate()
const [q, setQ] = useState("")
const debouncedQ = useDebounce(q, 300)
const searching = debouncedQ.trim().length > 0
// 단계적 Esc 의 끝: 목록/검색 화면에선 검색어 있으면 지우고, 없으면 런처 창을 숨김.
useEscapeKey(() => (q ? setQ("") : hideWindow()))
const [listPage, setListPage] = useState(1)
const [searchPage, setSearchPage] = useState(1)
// 검색어 바뀌면 검색 페이지 1 로 리셋.
useEffect(() => setSearchPage(1), [debouncedQ])
const list = useSessionList(listPage)
const search = useSearchMessages(debouncedQ, searchPage)
const sessions = list.data?.items ?? []
const hits = search.data?.items ?? []
// 검색 여부에 따라 활성 쿼리 스위칭 — 로딩/페이지/개수를 하나로 다룸.
const active = searching ? search : list
const page = searching ? searchPage : listPage
const setPage = searching ? setSearchPage : setListPage
const meta = active.data?.meta
// 목록은 3개 peek 이라 개수는 실제 표시 수로, 검색은 전체 매칭 수로.
const total = searching ? (meta?.totalItems ?? hits.length) : sessions.length
// ── 키보드 목록 이동(raycast식): 검색창 포커스 유지한 채 ↑↓ 이동, Enter 로 열기 ──
const items = searching ? hits : sessions
const [selected, setSelected] = useState(0)
const listRef = useRef<HTMLDivElement>(null)
// 목록/검색 전환·페이지 이동·검색어 변경 시 선택 맨 위로.
useEffect(() => setSelected(0), [searching, page, debouncedQ])
// 항목 수 줄면 선택이 밖으로 안 나가게 클램프.
useEffect(() => setSelected((s) => Math.min(s, Math.max(0, items.length - 1))), [items.length])
// 선택 항목을 보이게 스크롤.
useEffect(() => {
listRef.current
?.querySelector<HTMLElement>('[data-snap-selected="true"]')
?.scrollIntoView({ block: "nearest" })
}, [selected])
// 전역 키 핸들러. 최신 목록/선택은 ref 로 읽어 리스너는 1회만 등록.
const navRef = useRef({ items, searching, selected })
navRef.current = { items, searching, selected }
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.isComposing) return // 다이얼로그 처리분·한글 조합중 무시
const { items, selected, searching } = navRef.current
if (e.key === "ArrowDown") {
if (!items.length) return
e.preventDefault()
setSelected((s) => Math.min(s + 1, items.length - 1))
} else if (e.key === "ArrowUp") {
if (!items.length) return
e.preventDefault()
setSelected((s) => Math.max(s - 1, 0))
} else if (e.key === "Enter") {
const it = items[selected]
if (it)
navigate(`/snap/s/${searching ? (it as SnapMessage).sessionId : (it as SnapSession).id}`)
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [navigate])
return (
<div className="scrollbar-hide mx-auto flex h-full max-w-2xl flex-col gap-4 overflow-y-auto p-6">
<div className="flex items-center gap-3">
<div className="bg-primary text-primary-foreground grid size-9 flex-none place-items-center rounded-md font-serif text-lg font-bold italic">
S
</div>
<div className="flex flex-col">
<span className="font-serif text-base font-semibold italic"> </span>
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
Snap Mate · Workspace
</span>
</div>
<button
type="button"
onClick={() => navigate(PATHS.SNAP_NEW)}
title="새 대화 (Ctrl+N)"
className="bg-primary text-primary-foreground ml-auto grid size-9 flex-none place-items-center rounded-md transition-opacity hover:opacity-90"
>
<Plus className="size-4" />
</button>
</div>
<SessionSearch value={q} onChange={setQ} />
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-mono text-[10px] tracking-wide uppercase">
{searching ? "검색 결과 (본문)" : "최근 진행 대화"}
</span>
<span className="border-border text-muted-foreground rounded-full border px-2 font-mono text-[10px]">
{total}
</span>
</div>
<div ref={listRef} className="flex flex-col gap-2">
{active.isLoading && (
<div className="text-muted-foreground py-8 text-center font-mono text-xs">
</div>
)}
{!active.isLoading && searching && hits.length === 0 && (
<div className="text-muted-foreground py-8 text-center font-mono text-xs">
· NO MATCH
</div>
)}
{!active.isLoading && !searching && sessions.length === 0 && (
<div className="text-muted-foreground py-8 text-center font-mono text-xs">
</div>
)}
{searching
? hits.map((h, i) => (
<SearchHitCard
key={`${h.sessionId}-${h.createdAt}-${i}`}
hit={h}
query={debouncedQ.trim()}
onOpen={(id) => navigate(`/snap/s/${id}`)}
selected={i === selected}
/>
))
: sessions.map((s, i) => (
<SessionCard
key={s.id}
session={s}
onOpen={(id) => navigate(`/snap/s/${id}`)}
selected={i === selected}
/>
))}
{searching && meta && meta.totalPages > 1 && (
<div className="text-muted-foreground mt-1 flex items-center justify-center gap-3 font-mono text-xs">
<button
type="button"
onClick={() => setPage((p) => p - 1)}
disabled={!meta.hasPreviousPage || active.isFetching}
className="border-border hover:border-ring hover:text-foreground disabled:hover:border-border grid size-7 place-items-center rounded-md border transition-colors disabled:opacity-40"
title="이전"
>
<ChevronLeft className="size-4" />
</button>
<span className="tabular-nums">
{page} / {meta.totalPages}
</span>
<button
type="button"
onClick={() => setPage((p) => p + 1)}
disabled={!meta.hasNextPage || active.isFetching}
className="border-border hover:border-ring hover:text-foreground disabled:hover:border-border grid size-7 place-items-center rounded-md border transition-colors disabled:opacity-40"
title="다음"
>
<ChevronRight className="size-4" />
</button>
</div>
)}
</div>
<div className="text-muted-foreground border-border bg-background/95 sticky bottom-0 -mx-6 mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 border-t px-6 pt-2 font-mono text-[10px] backdrop-blur">
<span className="flex items-center gap-1">
<Kbd></Kbd>
<Kbd></Kbd>
</span>
<span className="flex items-center gap-1">
<Kbd></Kbd>
</span>
<span className="flex items-center gap-1">
<Kbd>Ctrl</Kbd>
<Kbd>N</Kbd>
</span>
<span className="flex items-center gap-1">
<Kbd>Esc</Kbd>
</span>
</div>
</div>
)
}
@@ -0,0 +1,153 @@
import { describe, it, expect, beforeEach } from "vitest"
import { useSnapChatStore } from "./snapChatStore"
beforeEach(() => useSnapChatStore.getState().reset())
describe("snapChatStore", () => {
it("seed 로 세션 과거대화를 채운다", () => {
useSnapChatStore
.getState()
.seed("s1", [{ sessionId: "s1", role: "user", content: "안녕", createdAt: "" }])
const s = useSnapChatStore.getState()
expect(s.sessionId).toBe("s1")
expect(s.messages).toHaveLength(1)
expect(s.messages[0].content).toBe("안녕")
})
it("applyUsage: 마지막 assistant 에 전체토큰·시간 붙이고 세션 점유량 갱신", () => {
const st = useSnapChatStore.getState()
st.addUserMessage("q")
st.startAssistantMessage()
st.appendChunk("답변")
st.applyUsage({ used: 3200, limit: 128000, elapsedMs: 4100 })
const s = useSnapChatStore.getState()
expect(s.messages.at(-1)!.totalTokens).toBe(3200)
expect(s.messages.at(-1)!.elapsedMs).toBe(4100)
expect(s.sessionUsed).toBe(3200)
expect(s.sessionLimit).toBe(128000)
})
it("seed: 마지막 assistant 답변 토큰으로 세션 점유량 유도", () => {
useSnapChatStore.getState().seed("s1", [
{ sessionId: "s1", role: "user", content: "q", createdAt: "" },
{
sessionId: "s1",
role: "assistant",
content: "a",
createdAt: "",
inputTokens: 2700,
outputTokens: 500,
elapsedMs: 4100,
},
])
const s = useSnapChatStore.getState()
expect(s.messages.at(-1)!.totalTokens).toBe(3200) // input+output
expect(s.sessionUsed).toBe(3200)
})
it("user 메시지 + assistant placeholder + 청크 누적", () => {
const st = useSnapChatStore.getState()
st.addUserMessage("질문")
st.startAssistantMessage()
st.appendChunk("답")
st.appendChunk("변")
const msgs = useSnapChatStore.getState().messages
expect(msgs.map((m) => m.role)).toEqual(["user", "assistant"])
expect(msgs[1].content).toBe("답변")
})
it("stop 은 마지막 assistant 를 frozen 처리한다", () => {
const st = useSnapChatStore.getState()
st.addUserMessage("q")
st.startAssistantMessage()
st.setStreaming(true)
st.stop()
const last = useSnapChatStore.getState().messages.at(-1)!
expect(last.frozen).toBe(true)
expect(useSnapChatStore.getState().isStreaming).toBe(false)
})
it("dropEmptyAssistantTail: 토큰 0개면 꼬리 빈 assistant 버블 제거", () => {
const s = useSnapChatStore.getState()
s.reset()
s.addUserMessage("hi")
s.startAssistantMessage()
expect(useSnapChatStore.getState().messages).toHaveLength(2)
useSnapChatStore.getState().dropEmptyAssistantTail()
const msgs = useSnapChatStore.getState().messages
expect(msgs).toHaveLength(1)
expect(msgs[0].role).toBe("user")
})
it("dropEmptyAssistantTail: 내용 있으면 안 지움", () => {
const s = useSnapChatStore.getState()
s.reset()
s.addUserMessage("hi")
s.startAssistantMessage()
s.appendChunk("답")
useSnapChatStore.getState().dropEmptyAssistantTail()
expect(useSnapChatStore.getState().messages).toHaveLength(2)
})
it("startAssistantMessage 는 isRevealing 을 켠다", () => {
const s = useSnapChatStore.getState()
s.reset()
s.startAssistantMessage()
expect(useSnapChatStore.getState().isRevealing).toBe(true)
})
it("setRevealing 으로 끌 수 있다", () => {
const s = useSnapChatStore.getState()
s.reset()
s.setRevealing(true)
s.setRevealing(false)
expect(useSnapChatStore.getState().isRevealing).toBe(false)
})
it("stop 은 isStreaming/isRevealing 을 모두 끄고 마지막 assistant 를 frozen 처리", () => {
const s = useSnapChatStore.getState()
s.reset()
s.addUserMessage("hi")
s.startAssistantMessage()
s.appendChunk("부분")
s.setStreaming(true)
useSnapChatStore.getState().stop()
const st = useSnapChatStore.getState()
expect(st.isStreaming).toBe(false)
expect(st.isRevealing).toBe(false)
expect(st.messages.at(-1)!.frozen).toBe(true)
})
it("appendRecoveredAssistant: 완성 답변 꼬리 추가 + reveal 플래그 + 점유량 갱신", () => {
useSnapChatStore
.getState()
.seed("s1", [{ sessionId: "s1", role: "user", content: "q", createdAt: "" }])
useSnapChatStore.getState().appendRecoveredAssistant({
sessionId: "s1",
role: "assistant",
content: "복구된 답변",
createdAt: "",
inputTokens: 3000,
outputTokens: 200,
elapsedMs: 5000,
})
const s = useSnapChatStore.getState()
const last = s.messages.at(-1)!
expect(last.role).toBe("assistant")
expect(last.content).toBe("복구된 답변")
expect(last.reveal).toBe(true)
expect(last.totalTokens).toBe(3200)
expect(last.elapsedMs).toBe(5000)
expect(s.isRevealing).toBe(true) // 타자기 reveal 시작
expect(s.sessionUsed).toBe(3200)
})
it("dropEmptyAssistantTail 은 드롭 시 isRevealing 도 끈다", () => {
const s = useSnapChatStore.getState()
s.reset()
s.addUserMessage("hi")
s.startAssistantMessage() // isRevealing=true, 빈 assistant
useSnapChatStore.getState().dropEmptyAssistantTail()
expect(useSnapChatStore.getState().isRevealing).toBe(false)
})
})
@@ -0,0 +1,173 @@
import { create } from "zustand"
import { randomId } from "@/lib/utils/randomId"
import type { SnapMessage, SnapRole } from "../contract/types"
export interface SnapChatMessage {
id: string
role: SnapRole
content: string
/** stop 으로 스트림을 그 자리에서 동결하면 true. */
frozen?: boolean
/** 완성본이 통째로 도착(폴링 복구)해 0부터 타자기로 풀어야 하면 true. */
reveal?: boolean
/** 이 답변 호출의 전체 토큰(input+output). assistant 만. */
totalTokens?: number
/** 이 답변 소요시간(ms). 라이브는 SSE, 과거는 DB. user 는 없음. */
elapsedMs?: number
}
// 세션 컨텍스트 하드 한도 — 백엔드 settings.llm_context_limit 와 동기(reload 시 기본값).
// 라이브 usage 이벤트가 오면 그 값으로 덮어씀.
// ponytail: 프론트 상수 복제. 백엔드가 한도를 바꾸면 여기도 바꿔야 함. 세션 상세 API 에 실어주면 제거 가능.
const DEFAULT_TOKEN_LIMIT = 128_000
// SnapMessage(DB) → 전체토큰. input/output 둘 다 없으면 undefined.
function totalOf(m: SnapMessage): number | undefined {
if (m.inputTokens == null && m.outputTokens == null) return undefined
return (m.inputTokens ?? 0) + (m.outputTokens ?? 0)
}
interface SnapChatState {
sessionId: string | null
messages: SnapChatMessage[]
isStreaming: boolean
isRevealing: boolean
currentController: AbortController | null
/** 현재 세션 컨텍스트 점유 토큰(직전 답변 total) / 한도. 헤더 게이지용. */
sessionUsed: number
sessionLimit: number
/** 설명 모드 — 켜면 배경·원리까지. 매 전송에 실림. seed 로 안 지워져 새 대화↔세션 유지. */
explain: boolean
setExplain: (v: boolean) => void
seed: (sessionId: string, messages: SnapMessage[]) => void
addUserMessage: (content: string) => void
startAssistantMessage: () => void
/** 폴링 복구로 도착한 완성 답변을 꼬리에 붙이고 타자기 reveal 시작. */
appendRecoveredAssistant: (m: SnapMessage) => void
appendChunk: (chunk: string) => void
setStreaming: (v: boolean) => void
setRevealing: (v: boolean) => void
setController: (c: AbortController | null) => void
/** usage 이벤트 반영 — 세션 점유량 갱신 + 마지막 assistant 에 전체토큰·시간 부착. */
applyUsage: (u: { used?: number; limit?: number; elapsedMs?: number }) => void
stop: () => void
/** 전송 실패(토큰 0개 도착)로 꼬리에 남은 빈 assistant 버블 제거. */
dropEmptyAssistantTail: () => void
reset: () => void
getRetryQuery: () => string | null
}
export const useSnapChatStore = create<SnapChatState>((set, get) => ({
sessionId: null,
messages: [],
isStreaming: false,
isRevealing: false,
currentController: null,
sessionUsed: 0,
sessionLimit: DEFAULT_TOKEN_LIMIT,
explain: false,
setExplain: (v) => set({ explain: v }),
seed: (sessionId, messages) => {
// 다른 세션 열 때 진행 중이던 스트림을 끊는다 — 안 끊으면 이전 세션 토큰이 새 버블에 샌다.
get().currentController?.abort()
const mapped: SnapChatMessage[] = messages.map((m) => ({
id: randomId(),
role: m.role,
content: m.content,
totalTokens: totalOf(m),
elapsedMs: m.elapsedMs ?? undefined,
}))
// 세션 점유량 = 마지막 assistant 답변의 전체토큰(≈ 직전 호출 total). 없으면 0.
const lastAssistant = [...mapped].reverse().find((m) => m.role === "assistant")
set({
sessionId,
messages: mapped,
isStreaming: false,
isRevealing: false,
currentController: null,
sessionUsed: lastAssistant?.totalTokens ?? 0,
sessionLimit: DEFAULT_TOKEN_LIMIT,
})
},
addUserMessage: (content) =>
set((s) => ({
messages: [...s.messages, { id: randomId(), role: "user" as SnapRole, content }],
})),
startAssistantMessage: () =>
set((s) => ({
messages: [...s.messages, { id: randomId(), role: "assistant" as SnapRole, content: "" }],
isRevealing: true,
})),
appendRecoveredAssistant: (m) =>
set((s) => ({
messages: [
...s.messages,
{
id: randomId(),
role: "assistant" as SnapRole,
content: m.content,
totalTokens: totalOf(m),
elapsedMs: m.elapsedMs ?? undefined,
reveal: true,
},
],
isRevealing: true,
sessionUsed: totalOf(m) ?? s.sessionUsed,
})),
appendChunk: (chunk) => {
const last = get().messages.at(-1)
if (!last || last.role !== "assistant") return
set((s) => {
const next = [...s.messages]
next[next.length - 1] = { ...last, content: last.content + chunk }
return { messages: next }
})
},
setStreaming: (v) => set({ isStreaming: v }),
setRevealing: (v) => set({ isRevealing: v }),
setController: (c) => set({ currentController: c }),
applyUsage: (u) =>
set((s) => ({
messages: s.messages.map((m, i) =>
i === s.messages.length - 1 && m.role === "assistant"
? { ...m, totalTokens: u.used ?? m.totalTokens, elapsedMs: u.elapsedMs ?? m.elapsedMs }
: m
),
sessionUsed: u.used ?? s.sessionUsed,
sessionLimit: u.limit ?? s.sessionLimit,
})),
stop: () => {
if (!get().isStreaming && !get().isRevealing) return
get().currentController?.abort()
set((s) => ({
messages: s.messages.map((m, i) =>
i === s.messages.length - 1 && m.role === "assistant" ? { ...m, frozen: true } : m
),
isStreaming: false,
isRevealing: false,
currentController: null,
}))
},
dropEmptyAssistantTail: () => {
const last = get().messages.at(-1)
if (!last || last.role !== "assistant" || last.content !== "") return
set((s) => ({ messages: s.messages.slice(0, -1), isRevealing: false }))
},
reset: () => {
get().currentController?.abort()
set({
sessionId: null,
messages: [],
isStreaming: false,
isRevealing: false,
currentController: null,
sessionUsed: 0,
sessionLimit: DEFAULT_TOKEN_LIMIT,
})
},
getRetryQuery: () => {
const msgs = get().messages
for (let i = msgs.length - 1; i >= 0; i--) if (msgs[i].role === "user") return msgs[i].content
return null
},
}))
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it } from "vitest"
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks"
import { snippetsApi } from "./snippets.api"
describe("snippetsApi", () => {
afterEach(() => clearMocks())
it("list — snippets_list 응답을 Snippet[]로 돌려준다", async () => {
const data = [{ name: "A", desc: "", body: "b", category: "코드", usageCount: 0, lastUsed: 0 }]
mockIPC((command) => {
expect(command).toBe("snippets_list")
return data
})
await expect(snippetsApi.list()).resolves.toEqual(data)
})
it("recordUse — snippets_record_use에 name을 넘긴다", async () => {
const data = { name: "FOO", usageCount: 3, lastUsed: 123 }
mockIPC((command, payload) => {
expect(command).toBe("snippets_record_use")
expect(payload).toEqual({ name: "FOO" })
return data
})
await expect(snippetsApi.recordUse("FOO")).resolves.toEqual(data)
})
it("command 오류를 Error로 reject한다", async () => {
mockIPC(() => Promise.reject("디비 오류"))
await expect(snippetsApi.list()).rejects.toThrow("디비 오류")
})
it("create — snippets_create에 snippet을 넘기고 저장 결과를 돌려준다", async () => {
const input = { name: "FOO", desc: "d", body: "b", category: "코드" }
const data = { ...input, usageCount: 0, lastUsed: 0 }
mockIPC((command, payload) => {
expect(command).toBe("snippets_create")
expect(payload).toEqual({ snippet: input })
return data
})
await expect(snippetsApi.create(input)).resolves.toEqual(data)
})
it("create — 중복 이름 오류를 그대로 reject한다", async () => {
mockIPC(() => Promise.reject("이미 있는 이름임: FOO"))
await expect(
snippetsApi.create({ name: "FOO", desc: "", body: "b", category: "코드" })
).rejects.toThrow("이미 있는 이름임: FOO")
})
it("update — snippets_update에 snippet을 넘기고 갱신 결과를 돌려준다", async () => {
const input = { name: "FOO", desc: "새 설명", body: "새 본문", category: "기타" }
const data = { ...input, usageCount: 2, lastUsed: 100 }
mockIPC((command, payload) => {
expect(command).toBe("snippets_update")
expect(payload).toEqual({ snippet: input })
return data
})
await expect(snippetsApi.update(input)).resolves.toEqual(data)
})
it("remove — snippets_delete에 name을 넘기고 성공하면 undefined를 돌려준다", async () => {
mockIPC((command, payload) => {
expect(command).toBe("snippets_delete")
expect(payload).toEqual({ name: "FOO" })
return { name: "FOO" }
})
await expect(snippetsApi.remove("FOO")).resolves.toBeUndefined()
})
})
@@ -0,0 +1,15 @@
// feature 유일한 데이터 진입점(contracts/snippet-data-interface.md). 뒤는 브릿지(SQLite) — @/lib/api/client 안 씀(정당 편차).
import { request } from "@/lib/bridge/snippetBridge"
import type { Snippet, SnippetInput } from "../types"
export const snippetsApi = {
list: () => request<Snippet[]>("snippets.list"),
create: (input: SnippetInput) => request<Snippet>("snippets.create", { snippet: input }),
update: (input: SnippetInput) => request<Snippet>("snippets.update", { snippet: input }),
remove: (name: string) =>
request<{ name: string }>("snippets.delete", { name }).then(() => undefined),
recordUse: (name: string) =>
request<{ name: string; usageCount: number; lastUsed: number }>("snippets.recordUse", {
name,
}),
}
@@ -0,0 +1,30 @@
// category 칩 바 — US3 FR-020. "전체" + 존재하는 category. 키보드 우선(Ctrl+←/→는 페이지에서 처리),
// 클릭도 되지만 주된 조작은 아님. Raycast 결로 은은하게.
interface Props {
categories: string[] // "전체" 포함, 페이지에서 도출해 넘김
selected: string
onSelect: (category: string) => void
}
export function CategoryChips({ categories, selected, onSelect }: Props) {
return (
<div className="border-border scrollbar-hide flex flex-none items-center gap-1.5 overflow-x-auto border-b px-4 py-2">
{categories.map((c) => (
<button
key={c}
type="button"
tabIndex={-1}
onClick={() => onSelect(c)}
data-category-selected={c === selected ? "true" : undefined}
className={`flex-none rounded-full border px-2.5 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
c === selected
? "bg-accent text-accent-foreground border-accent"
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent/50"
}`}
>
{c}
</button>
))}
</div>
)
}
@@ -0,0 +1,24 @@
import { afterEach, expect, it, vi } from "vitest"
import { cleanup, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { EditDialog } from "./EditDialog"
afterEach(cleanup)
it("팔레트 재소환으로 편집기를 닫으면 삭제 확인도 사라짐", async () => {
const user = userEvent.setup()
const props = {
mode: "edit" as const,
snippet: { name: "A", desc: "", body: "first", category: "코드", usageCount: 0, lastUsed: 0 },
onClose: vi.fn(),
onSave: vi.fn(),
onDelete: vi.fn(),
isSaving: false,
}
const { rerender } = render(<EditDialog {...props} open />)
await user.click(screen.getByRole("button", { name: "삭제" }))
expect(screen.getByRole("alertdialog")).toBeInTheDocument()
rerender(<EditDialog {...props} open={false} snippet={undefined} />)
await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument())
expect(props.onDelete).not.toHaveBeenCalled()
})
@@ -0,0 +1,213 @@
// 생성/편집 공용 다이얼로그(T030). react-hook-form+zod를 사용하되
// shadcn Dialog(중앙 모달)로 — 팔레트 위에 뜨는 Raycast 결 유지.
import { useEffect, useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/shared/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog"
import { Button } from "@/shared/ui/button"
import { Input } from "@/shared/ui/input"
import { Textarea } from "@/shared/ui/textarea"
import { Label } from "@/shared/ui/label"
import type { Snippet, SnippetInput } from "../types"
const DEFAULT_CATEGORY = "코드"
const schema = z.object({
name: z.string().min(1, "이름을 입력해야 함"),
desc: z.string(),
body: z.string().min(1, "내용을 입력해야 함"),
category: z.string(),
})
type FormValues = z.infer<typeof schema>
interface EditDialogProps {
open: boolean
mode: "create" | "edit"
snippet?: Snippet
onClose: () => void
onSave: (input: SnippetInput) => void
onDelete?: (name: string) => void
isSaving: boolean
isDeleting?: boolean
}
export function EditDialog({
open,
mode,
snippet,
onClose,
onSave,
onDelete,
isSaving,
isDeleting,
}: EditDialogProps) {
const {
register,
handleSubmit,
reset,
setValue,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: "", desc: "", body: "", category: DEFAULT_CATEGORY },
})
const [confirmDelete, setConfirmDelete] = useState(false)
// 열릴 때마다 폼 동기화. 생성 모드면 클립보드로 body 프리필(FR-015) — 읽기 실패해도 그냥 빈 값.
useEffect(() => {
setConfirmDelete(false)
if (!open) return
if (mode === "edit" && snippet) {
reset({
name: snippet.name,
desc: snippet.desc,
body: snippet.body,
category: snippet.category,
})
return
}
reset({ name: "", desc: "", body: "", category: DEFAULT_CATEGORY })
// body 필드만 채움 — 통째 reset 쓰면 readText 느릴 때 그새 사용자가 친 name 등이 날아감(NEEDS-FIX #2).
navigator.clipboard
?.readText()
.then((text) => setValue("body", text))
.catch(() => {
/* 클립보드 접근 실패 — 빈 body 로 둠(FR-015 fallback) */
})
}, [open, mode, snippet, reset, setValue])
function onSubmit(data: FormValues) {
onSave({
name: data.name,
desc: data.desc,
body: data.body,
category: data.category || DEFAULT_CATEGORY,
})
}
return (
<>
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{mode === "create" ? "새 스니펫" : "스니펫 편집"}</DialogTitle>
<DialogDescription className="sr-only">
, .
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="snippet-name">
<span className="text-destructive">*</span>
</Label>
<Input
id="snippet-name"
placeholder="예: GIT_COMMIT"
readOnly={mode === "edit"}
className={mode === "edit" ? "bg-muted text-muted-foreground" : undefined}
{...register("name")}
/>
{errors.name && <p className="text-destructive text-xs">{errors.name.message}</p>}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="snippet-desc"></Label>
<Input id="snippet-desc" placeholder="짧은 설명" {...register("desc")} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="snippet-category"></Label>
<Input
id="snippet-category"
placeholder={DEFAULT_CATEGORY}
{...register("category")}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="snippet-body">
<span className="text-destructive">*</span>
</Label>
<Textarea
id="snippet-body"
rows={8}
className="font-mono text-xs"
{...register("body")}
/>
{errors.body && <p className="text-destructive text-xs">{errors.body.message}</p>}
</div>
<DialogFooter className="items-center sm:justify-between">
<div>
{mode === "edit" && onDelete && snippet && (
<Button
type="button"
variant="destructive"
onClick={() => setConfirmDelete(true)}
disabled={isDeleting}
>
</Button>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onClose}>
</Button>
<Button type="submit" disabled={isSaving}>
{mode === "create" ? "생성" : "저장"}
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog open={open && confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> ?</AlertDialogTitle>
<AlertDialogDescription>
{snippet && (
<span className="bg-muted mt-2 block rounded px-3 py-2 text-xs">
{snippet.name}
</span>
)}
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => snippet && onDelete?.(snippet.name)}
disabled={isDeleting}
className="bg-destructive hover:bg-destructive/90 text-white"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,12 @@
import type { Snippet } from "../types"
import { CodeBlock } from "@/features/snap/components/CodeBlock"
interface Props {
snippet: Snippet | undefined
}
/** 메타·액션은 목록과 하단에 맡기고 코드만 보여줌. */
export function PreviewPane({ snippet }: Props) {
if (!snippet) return null
return <CodeBlock code={snippet.body} lang="abap" plain />
}
@@ -0,0 +1,44 @@
// NEEDS-FIX #1 검증용 — 단일클릭=선택만(붙여넣기 X), 더블클릭=onEdit.
// userEvent.dblClick 은 실제 브라우저처럼 click 이벤트 2번 + dblclick 순으로 쏴서 회귀 방지에 유효
// (fireEvent.doubleClick 은 dblclick 만 단독 발생시켜 이 시나리오를 못 잡음).
import { describe, it, expect, vi } from "vitest"
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { SnippetRow } from "./SnippetRow"
import type { Snippet } from "../types"
const snippet: Snippet = {
name: "FOO",
desc: "설명",
body: "body",
category: "코드",
usageCount: 0,
lastUsed: 0,
}
describe("SnippetRow 클릭 동작", () => {
it("단일클릭 — onSelect만 호출, onEdit은 안 불림(붙여넣기 부작용 없음)", async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onEdit = vi.fn()
render(<SnippetRow snippet={snippet} onSelect={onSelect} onEdit={onEdit} />)
await user.click(screen.getByRole("button"))
expect(onSelect).toHaveBeenCalledTimes(1)
expect(onSelect).toHaveBeenCalledWith(snippet)
expect(onEdit).not.toHaveBeenCalled()
})
it("더블클릭 — onEdit 호출됨(선택은 부수적으로 여러 번 와도 상관없음)", async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
const onEdit = vi.fn()
render(<SnippetRow snippet={snippet} onSelect={onSelect} onEdit={onEdit} />)
await user.dblClick(screen.getByRole("button"))
expect(onEdit).toHaveBeenCalledTimes(1)
expect(onEdit).toHaveBeenCalledWith(snippet)
})
})
@@ -0,0 +1,34 @@
import type { Snippet } from "../types"
interface Props {
snippet: Snippet
selected?: boolean
/** 단일클릭 = 선택과 미리보기. 복사는 Enter 전용. */
onSelect: (snippet: Snippet) => void
/** F2·더블클릭 편집 진입(US2 FR-017). */
onEdit?: (snippet: Snippet) => void
}
/** 테두리 없는 결과 행. 직접 선택한 항목만 강조함. */
export function SnippetRow({ snippet, selected, onSelect, onEdit }: Props) {
return (
<button
type="button"
onClick={() => onSelect(snippet)}
onDoubleClick={() => onEdit?.(snippet)}
data-snippet-selected={selected ? "true" : undefined}
aria-pressed={selected ?? false}
title={snippet.desc || snippet.name}
className={`focus-visible:outline-ring flex w-full items-center gap-2 rounded-md px-3 py-2.5 text-left transition-colors focus-visible:outline-2 focus-visible:outline-offset-[-2px] ${
selected ? "bg-accent text-accent-foreground" : "hover:bg-muted/60"
}`}
>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">{snippet.name}</span>
{snippet.desc && (
<span className="text-muted-foreground truncate text-xs">{snippet.desc}</span>
)}
</span>
</button>
)
}
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest"
import { rankSnippets } from "./ranking"
import type { Snippet } from "../types"
function s(over: Partial<Snippet>): Snippet {
return { name: "", desc: "", body: "", category: "코드", usageCount: 0, lastUsed: 0, ...over }
}
describe("rankSnippets", () => {
it("usageCount 내림차순으로 먼저 정렬", () => {
const items = [s({ name: "A", usageCount: 1 }), s({ name: "B", usageCount: 5 })]
expect(rankSnippets(items).map((i) => i.name)).toEqual(["B", "A"])
})
it("usageCount 같으면 lastUsed 내림차순", () => {
const items = [
s({ name: "A", usageCount: 2, lastUsed: 100 }),
s({ name: "B", usageCount: 2, lastUsed: 200 }),
]
expect(rankSnippets(items).map((i) => i.name)).toEqual(["B", "A"])
})
it("usageCount·lastUsed 둘 다 같으면 name 오름차순", () => {
const items = [
s({ name: "ZEBRA", usageCount: 0, lastUsed: 0 }),
s({ name: "APPLE", usageCount: 0, lastUsed: 0 }),
]
expect(rankSnippets(items).map((i) => i.name)).toEqual(["APPLE", "ZEBRA"])
})
it("원본 배열을 변경하지 않음", () => {
const items = [s({ name: "B", usageCount: 1 }), s({ name: "A", usageCount: 5 })]
const original = [...items]
rankSnippets(items)
expect(items).toEqual(original)
})
})
@@ -0,0 +1,10 @@
// 랭킹 순수함수 — data-model.md §랭킹. FR-007. 정렬 키: count desc, lastUsed desc, name asc.
import type { Snippet } from "../types"
export function rankSnippets(snippets: Snippet[]): Snippet[] {
return [...snippets].sort((a, b) => {
if (a.usageCount !== b.usageCount) return b.usageCount - a.usageCount
if (a.lastUsed !== b.lastUsed) return b.lastUsed - a.lastUsed
return a.name.localeCompare(b.name)
})
}
@@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest"
import { searchSnippets } from "./search"
import type { Snippet } from "../types"
function s(over: Partial<Snippet>): Snippet {
return { name: "", desc: "", body: "", category: "코드", usageCount: 0, lastUsed: 0, ...over }
}
describe("searchSnippets", () => {
const items: Snippet[] = [
s({
name: "GIT_COMMIT",
desc: "커밋 메시지 템플릿",
body: "본문에만 있는 XYZKEYWORD",
category: "코드",
}),
s({ name: "DOCKER_RUN", desc: "도커 실행 커맨드", category: "코드" }),
s({ name: "MEMO", desc: "회의 메모 양식", category: "기타" }),
]
it("빈 쿼리면 전체를 돌려줌", () => {
expect(searchSnippets(items, "")).toHaveLength(3)
})
it("키워드 하나 — name+desc(대소문자 무시)에 포함되면 통과", () => {
const result = searchSnippets(items, "커밋")
expect(result.map((i) => i.name)).toEqual(["GIT_COMMIT"])
})
it("공백으로 나눈 키워드 전부(AND) 만족해야 함", () => {
const result = searchSnippets(items, "도커 커맨드")
expect(result.map((i) => i.name)).toEqual(["DOCKER_RUN"])
})
it("키워드 중 하나라도 안 맞으면 제외", () => {
const result = searchSnippets(items, "도커 없는말")
expect(result).toHaveLength(0)
})
it("대소문자 무시(영문 name 검색)", () => {
const result = searchSnippets(items, "docker")
expect(result.map((i) => i.name)).toEqual(["DOCKER_RUN"])
})
it("body는 검색 대상이 아님 — body에만 있는 키워드는 매칭 안 됨", () => {
const result = searchSnippets(items, "XYZKEYWORD")
expect(result).toHaveLength(0)
})
it("category 지정 시 그 분류로 먼저 제한", () => {
const result = searchSnippets(items, "", "기타")
expect(result.map((i) => i.name)).toEqual(["MEMO"])
})
it("category가 전체면 제한 없음", () => {
expect(searchSnippets(items, "", "전체")).toHaveLength(3)
})
})
@@ -0,0 +1,20 @@
// 검색 순수함수 — data-model.md §검색. FR-005/006 그대로.
import type { Snippet } from "../types"
/**
* category ( "전체" ) , (AND)
* name+desc ( ) . body . .
* (category ) .
*/
export function searchSnippets(snippets: Snippet[], query: string, category?: string): Snippet[] {
const inCategory =
!category || category === "전체" ? snippets : snippets.filter((s) => s.category === category)
const keywords = query.trim().toUpperCase().split(/\s+/).filter(Boolean)
if (keywords.length === 0) return inCategory
return inCategory.filter((s) => {
const haystack = `${s.name} ${s.desc}`.toUpperCase()
return keywords.every((k) => haystack.includes(k))
})
}
@@ -0,0 +1,64 @@
// react-query 배선. contracts/snippet-data-interface.md §react-query 계약을 따름.
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { snippetsApi } from "../api/snippets.api"
import type { SnippetInput } from "../types"
export const SNIPPETS_KEY = "snippets"
// 브릿지 실패는 항상 Error(reject 메시지에 이미 한글 사유가 담김) — HTTP ApiError 가 아니라 이 형태로 분기.
function toastBridgeError(err: unknown, fallback: string) {
toast.error(err instanceof Error ? err.message : fallback)
}
export function useSnippets() {
return useQuery({
queryKey: [SNIPPETS_KEY],
queryFn: () => snippetsApi.list(),
})
}
export function useCreateSnippet() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: SnippetInput) => snippetsApi.create(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [SNIPPETS_KEY] })
toast.success("스니펫 생성됨", { duration: 1000 })
},
onError: (err) => toastBridgeError(err, "스니펫 생성 실패함"),
})
}
export function useUpdateSnippet() {
const qc = useQueryClient()
return useMutation({
mutationFn: (input: SnippetInput) => snippetsApi.update(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [SNIPPETS_KEY] })
toast.success("저장됨")
},
onError: (err) => toastBridgeError(err, "저장 실패함"),
})
}
export function useDeleteSnippet() {
const qc = useQueryClient()
return useMutation({
mutationFn: (name: string) => snippetsApi.remove(name),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [SNIPPETS_KEY] })
toast.success("삭제됨")
},
onError: (err) => toastBridgeError(err, "삭제 실패함"),
})
}
/** 붙여넣기 성공 후 usage 기록 — 랭킹 갱신용이라 조용히(토스트 없음). */
export function useRecordUse() {
const qc = useQueryClient()
return useMutation({
mutationFn: (name: string) => snippetsApi.recordUse(name),
onSuccess: () => qc.invalidateQueries({ queryKey: [SNIPPETS_KEY] }),
})
}
@@ -0,0 +1,273 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import userEvent from "@testing-library/user-event"
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { toast } from "sonner"
import { hideWindow, isWebView, pasteToApp } from "@/lib/bridge/webviewBridge"
import SnippetPalettePage from "./SnippetPalettePage"
const { createSnippet, recordUse } = vi.hoisted(() => ({
createSnippet: vi.fn(),
recordUse: vi.fn(),
}))
vi.mock("@/lib/bridge/webviewBridge", () => ({
hideWindow: vi.fn(),
startWindowDrag: vi.fn(),
isWebView: vi.fn(() => true),
pasteToApp: vi.fn(),
}))
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock("../components/PreviewPane", () => ({
PreviewPane: ({ snippet }: { snippet?: { body: string } }) =>
snippet ? <pre>{snippet.body}</pre> : null,
}))
vi.mock("../components/EditDialog", () => ({
EditDialog: ({ open, onSave }: { open: boolean; onSave: (input: object) => void }) =>
open ? (
<div role="dialog">
<button onClick={() => onSave({ name: "NEW", desc: "", body: "body", category: "코드" })}>
</button>
</div>
) : null,
}))
vi.mock("../hooks/useSnippets", () => ({
useSnippets: () => ({
data: [
{ name: "A", desc: "", body: "first", category: "코드", usageCount: 0, lastUsed: 0 },
{
name: "B",
desc: "",
body: " 한글\r\n본문\t",
category: "코드",
usageCount: 0,
lastUsed: 0,
},
],
isLoading: false,
}),
useRecordUse: () => ({ mutate: recordUse }),
useCreateSnippet: () => ({ mutate: createSnippet, isPending: false }),
useUpdateSnippet: () => ({}),
useDeleteSnippet: () => ({}),
}))
beforeEach(() => {
userEvent.setup()
vi.mocked(isWebView).mockReturnValue(true)
Element.prototype.scrollIntoView = vi.fn()
})
afterEach(() => {
vi.useRealTimers()
cleanup()
vi.restoreAllMocks()
vi.clearAllMocks()
})
describe("새 스니펫 단축키", () => {
it("Ctrl+N으로 생성창을 열고 기존 Ctrl+2는 무시함", () => {
render(<SnippetPalettePage />)
fireEvent.keyDown(window, { key: "2", ctrlKey: true })
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
fireEvent.keyDown(window, { key: "n", ctrlKey: true })
expect(screen.getByRole("dialog")).toHaveTextContent("새 스니펫")
})
it("생성 성공 뒤 입력창을 1초 유지한 다음 기본 검색 화면으로 돌아감", () => {
vi.useFakeTimers()
createSnippet.mockImplementation((_input, options) => options.onSuccess())
render(<SnippetPalettePage />)
const search = screen.getByRole("textbox")
fireEvent.change(search, { target: { value: "기존 검색" } })
fireEvent.keyDown(window, { key: "n", ctrlKey: true })
fireEvent.click(screen.getByRole("button", { name: "테스트 생성" }))
expect(screen.getByRole("dialog")).toBeInTheDocument()
act(() => vi.advanceTimersByTime(999))
expect(screen.getByRole("dialog")).toBeInTheDocument()
act(() => vi.advanceTimersByTime(1))
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
expect(search).toHaveValue("")
})
})
describe("스니펫 Enter 복사", () => {
it("선택한 본문을 원문 그대로 복사하고 완료 후에만 사용 기록·창 숨김", async () => {
let finish!: () => void
const writeText = vi.fn(
() =>
new Promise<void>((resolve) => {
finish = resolve
})
)
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText)
render(<SnippetPalettePage />)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "B" } })
fireEvent.click(screen.getByText("B"))
fireEvent.keyDown(window, { key: "Enter" })
expect(writeText).toHaveBeenCalledWith(" 한글\r\n본문\t")
expect(hideWindow).not.toHaveBeenCalled()
expect(recordUse).not.toHaveBeenCalled()
fireEvent.keyDown(window, { key: "Enter", repeat: true })
expect(writeText).toHaveBeenCalledTimes(1)
finish()
await waitFor(() => expect(recordUse).toHaveBeenCalledWith("B"))
await waitFor(() => expect(hideWindow).toHaveBeenCalledTimes(1), { timeout: 2000 })
expect(recordUse).toHaveBeenCalledWith("B")
})
it("복사 실패 시 창과 사용 기록을 유지하고 오류 표시", async () => {
const writeText = vi.fn().mockRejectedValue(new Error("denied"))
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText)
render(<SnippetPalettePage />)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "A" } })
fireEvent.keyDown(window, { key: "ArrowDown" })
fireEvent.keyDown(window, { key: "Enter", isComposing: true })
expect(writeText).not.toHaveBeenCalled()
fireEvent.keyDown(window, { key: "Enter" })
await waitFor(() => expect(toast.error).toHaveBeenCalled())
expect(hideWindow).not.toHaveBeenCalled()
expect(recordUse).not.toHaveBeenCalled()
})
it("하단 복사는 클릭과 Enter 모두 원문을 복사하고 팔레트를 열린 채로 둠", async () => {
const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "B" } })
await user.click(screen.getByRole("button", { name: "B" }))
const copy = screen.getByRole("button", { name: "복사" })
await user.click(copy)
expect(writeText).toHaveBeenCalledWith(" 한글\r\n본문\t")
await user.keyboard("{Enter}")
expect(writeText).toHaveBeenCalledTimes(2)
expect(recordUse).not.toHaveBeenCalled()
expect(hideWindow).not.toHaveBeenCalled()
expect(screen.getByLabelText("코드 미리보기")).toBeInTheDocument()
})
})
describe("Ctrl Enter 앱에 붙여넣기", () => {
it("선택한 원문을 붙여넣고 일반 복사를 실행하지 않으며 키 반복을 무시함", async () => {
const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />)
await user.type(screen.getByRole("textbox"), "B")
await user.click(screen.getByRole("button", { name: "B" }))
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter", ctrlKey: true })
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter", ctrlKey: true, repeat: true })
expect(pasteToApp).toHaveBeenCalledTimes(1)
expect(pasteToApp).toHaveBeenCalledWith(" 한글\r\n본문\t")
expect(writeText).not.toHaveBeenCalled()
expect(hideWindow).not.toHaveBeenCalled()
expect(recordUse).not.toHaveBeenCalled()
})
it("복사 버튼에 포커스가 있어도 Ctrl Enter는 붙여넣고 일반 Enter는 복사함", async () => {
const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />)
await user.type(screen.getByRole("textbox"), "B")
await user.click(screen.getByRole("button", { name: "B" }))
screen.getByRole("button", { name: "복사" }).focus()
await user.keyboard("{Control>}{Enter}{/Control}")
expect(pasteToApp).toHaveBeenCalledTimes(1)
expect(pasteToApp).toHaveBeenCalledWith(" 한글\r\n본문\t")
expect(writeText).not.toHaveBeenCalled()
expect(hideWindow).not.toHaveBeenCalled()
await user.keyboard("{Enter}")
expect(writeText).toHaveBeenCalledTimes(1)
expect(writeText).toHaveBeenCalledWith(" 한글\r\n본문\t")
expect(pasteToApp).toHaveBeenCalledTimes(1)
expect(hideWindow).not.toHaveBeenCalled()
expect(recordUse).not.toHaveBeenCalled()
})
it("미선택·조합 중·다른 modifier·편집 중에는 붙여넣지 않음", () => {
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />)
const input = screen.getByRole("textbox")
fireEvent.change(input, { target: { value: "B" } })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true })
expect(pasteToApp).not.toHaveBeenCalled()
fireEvent.keyDown(input, { key: "ArrowDown" })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true, isComposing: true })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true, shiftKey: true })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true, altKey: true })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true, metaKey: true })
fireEvent.keyDown(input, { key: "F2" })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true })
expect(pasteToApp).not.toHaveBeenCalled()
expect(writeText).not.toHaveBeenCalled()
expect(hideWindow).not.toHaveBeenCalled()
})
it("브라우저의 Ctrl Enter는 복사나 붙여넣기로 바뀌지 않음", () => {
vi.mocked(isWebView).mockReturnValue(false)
const writeText = vi.spyOn(navigator.clipboard, "writeText")
render(<SnippetPalettePage />)
const input = screen.getByRole("textbox")
fireEvent.change(input, { target: { value: "B" } })
fireEvent.keyDown(input, { key: "ArrowDown" })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true })
expect(pasteToApp).not.toHaveBeenCalled()
expect(writeText).not.toHaveBeenCalled()
})
})
describe("검색 후 선택할 때만 미리보기", () => {
it("빈 검색 → 결과 → 직접 선택 → 검색 변경과 지우기 순서로 화면이 접힘", () => {
render(<SnippetPalettePage />)
const input = screen.getByRole("textbox")
expect(screen.queryByText("A")).not.toBeInTheDocument()
expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument()
fireEvent.keyDown(window, { key: "ArrowDown" })
expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument()
fireEvent.change(input, { target: { value: "A" } })
expect(screen.getByText("A")).toBeInTheDocument()
expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument()
fireEvent.keyDown(window, { key: "ArrowDown" })
expect(screen.getByLabelText("코드 미리보기")).toHaveTextContent("first")
fireEvent.change(input, { target: { value: "B" } })
expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument()
fireEvent.click(screen.getByText("B"))
expect(screen.getByLabelText("코드 미리보기")).toHaveTextContent("한글")
fireEvent.change(input, { target: { value: " " } })
expect(screen.queryByText("B")).not.toBeInTheDocument()
expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument()
})
it("검색 결과만 보일 때 Enter는 복사하지 않고 재소환은 검색창으로 돌아감", () => {
const writeText = vi.spyOn(navigator.clipboard, "writeText")
render(<SnippetPalettePage />)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "A" } })
fireEvent.keyDown(window, { key: "Enter" })
expect(writeText).not.toHaveBeenCalled()
fireEvent.keyDown(window, { key: "ArrowUp" })
expect(screen.getByLabelText("코드 미리보기")).toHaveTextContent("first")
fireEvent(window, new CustomEvent("bridge:navigate", { detail: { path: "/snippet" } }))
return waitFor(() => {
expect(screen.getByRole("textbox")).toHaveValue("")
expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument()
})
})
it("마우스로 결과를 선택한 뒤 Esc를 누르면 검색창에서 바로 다시 입력할 수 있음", async () => {
const user = userEvent.setup()
render(<SnippetPalettePage />)
const input = screen.getByRole("textbox")
await waitFor(() => expect(input).toHaveFocus())
await user.type(input, "A")
await user.click(screen.getByRole("button", { name: "A" }))
await user.keyboard("{Escape}")
expect(input).toHaveValue("")
expect(input).toHaveFocus()
await user.keyboard("B")
expect(screen.getByText("B")).toBeInTheDocument()
})
})
@@ -0,0 +1,407 @@
import { hostKind, send } from "@/lib/bridge/transport"
// 검색 → 결과 → 직접 선택한 코드 순서로 화면과 데스크톱 창을 펼침.
import { useEffect, useMemo, useRef, useState } from "react"
import { Check, Search } from "lucide-react"
import { hideWindow, isWebView, pasteToApp, startWindowDrag } from "@/lib/bridge/webviewBridge"
import { toast } from "sonner"
import { useEscapeKey } from "@/features/snap/hooks/useEscapeKey"
import { CodeActions } from "@/features/snap/components/CodeBlock"
import { searchSnippets } from "../core/search"
import { rankSnippets } from "../core/ranking"
import {
useSnippets,
useRecordUse,
useCreateSnippet,
useUpdateSnippet,
useDeleteSnippet,
} from "../hooks/useSnippets"
import { SnippetRow } from "../components/SnippetRow"
import { PreviewPane } from "../components/PreviewPane"
import { EditDialog } from "../components/EditDialog"
import { CategoryChips } from "../components/CategoryChips"
import type { Snippet, SnippetInput } from "../types"
const ALL_CATEGORY = "전체"
export default function SnippetPalettePage() {
const [q, setQ] = useState("")
const { data: snippets = [], isLoading, error } = useSnippets()
const recordUse = useRecordUse()
const createSnippet = useCreateSnippet()
const updateSnippet = useUpdateSnippet()
const deleteSnippet = useDeleteSnippet()
// 생성/편집 다이얼로그 — null 이면 닫힘. 열려있는 동안엔 팔레트 전역 단축키 비활성(아래 keydown 가드).
const [dialogState, setDialogState] = useState<{
mode: "create" | "edit"
snippet?: Snippet
} | null>(null)
const openCreate = () => setDialogState({ mode: "create" })
const openEdit = (snippet: Snippet) => setDialogState({ mode: "edit", snippet })
const closeDialog = () => setDialogState(null)
const createDoneTimerRef = useRef<number | null>(null)
function returnToSearch() {
setQ("")
setCategory(ALL_CATEGORY)
setSelection(null)
closeDialog()
requestAnimationFrame(() => inputRef.current?.focus())
}
function saveSnippet(input: SnippetInput) {
if (dialogState?.mode === "create") {
createSnippet.mutate(input, {
// 성공 toast가 입력창 위에서 1초 보인 뒤 기본 검색 화면으로 돌아감.
onSuccess: () => {
createDoneTimerRef.current = window.setTimeout(returnToSearch, 1000)
},
})
return
}
updateSnippet.mutate(input, { onSuccess: closeDialog })
}
function removeSnippet(name: string) {
deleteSnippet.mutate(name, { onSuccess: closeDialog })
}
// category 칩(US3 FR-020) — 목록에서 존재하는 category 도출 + 맨 앞 "전체".
const categories = useMemo(() => {
const found = Array.from(new Set(snippets.map((s) => s.category))).sort()
return [ALL_CATEGORY, ...found]
}, [snippets])
const [category, setCategory] = useState(ALL_CATEGORY)
// 선택 중이던 category의 스니펫이 다 사라지면(삭제 등) "전체"로 복귀.
useEffect(() => {
if (!categories.includes(category)) setCategory(ALL_CATEGORY)
}, [categories, category])
// 검색·랭킹은 로컬 순수함수라 서버 왕복 없음 — debounce 불필요, 매 타이핑 즉시 반영.
const hasQuery = q.trim().length > 0
const results = useMemo(
() => (hasQuery ? rankSnippets(searchSnippets(snippets, q, category)) : []),
[snippets, q, category, hasQuery]
)
const [selection, setSelection] = useState<{
name: string
query: string
category: string
} | null>(null)
const selected =
selection?.query === q && selection.category === category
? results.findIndex((snippet) => snippet.name === selection.name)
: -1
const preview = results[selected]
const stage = dialogState ? "editor" : preview ? "preview" : hasQuery ? "results" : "search"
useEffect(() => {
if (hostKind() === "tauri") send({ type: "window.snippetLayout", stage })
}, [stage])
const listRef = useRef<HTMLDivElement>(null)
// 리스트/프리뷰 분할 비율(%) — 가운데 핸들 드래그(또는 ←/→)로 조절, localStorage 저장. 기본 3:7.
const splitRef = useRef<HTMLDivElement>(null)
const draggingRef = useRef(false)
const [listPct, setListPct] = useState(() => {
const saved = Number(localStorage.getItem("snippet.splitPct"))
return saved >= 20 && saved <= 70 ? saved : 30 // 기본 리스트 30%(프리뷰 70% = 3:7)
})
useEffect(() => {
localStorage.setItem("snippet.splitPct", String(Math.round(listPct)))
}, [listPct])
useEffect(() => {
const onMove = (e: MouseEvent) => {
if (!draggingRef.current || !splitRef.current) return
const rect = splitRef.current.getBoundingClientRect()
const pct = ((e.clientX - rect.left) / rect.width) * 100
setListPct(Math.min(70, Math.max(20, pct))) // 20~70%로 클램프
}
const onUp = () => {
draggingRef.current = false
document.body.style.userSelect = ""
}
window.addEventListener("mousemove", onMove)
window.addEventListener("mouseup", onUp)
return () => {
window.removeEventListener("mousemove", onMove)
window.removeEventListener("mouseup", onUp)
document.body.style.userSelect = ""
}
}, [])
useEffect(() => {
listRef.current
?.querySelector<HTMLElement>('[data-snippet-selected="true"]')
?.scrollIntoView({ block: "nearest" })
}, [selected])
// 단계적 Esc: 검색어 있으면 비우고, 없으면 팔레트(런처 창) 숨김(FR-013).
// 다이얼로그 떠있는 동안엔 Radix 자체 Escape 처리(닫기)에 맡기고 팔레트 쪽은 아무것도 안 함.
useEscapeKey(() => {
if (dialogState) return
if (q) {
setQ("")
setSelection(null)
inputRef.current?.focus()
} else hideWindow()
})
// 뜨자마자 검색창 포커스 + 재소환에도 다시 잡음.
// rAF로 webview/DOM 안정화 후 focus(마운트·네비 직후 즉시 focus가 씹히는 것 대비).
// window focus(창 재표시) + bridge:navigate(Ctrl+Shift+7 소환, 같은 route여도 매번) 둘 다 청취.
const inputRef = useRef<HTMLInputElement>(null)
useEffect(
() => () => {
if (createDoneTimerRef.current !== null) window.clearTimeout(createDoneTimerRef.current)
},
[]
)
useEffect(() => {
const focus = () => requestAnimationFrame(() => inputRef.current?.focus())
focus()
const onSummon = (e: Event) => {
if ((e as CustomEvent<{ path?: string }>).detail?.path === "/snippet") {
setQ("")
setCategory(ALL_CATEGORY)
setSelection(null)
setDialogState(null)
focus()
}
}
window.addEventListener("focus", focus)
window.addEventListener("bridge:navigate", onSummon)
return () => {
window.removeEventListener("focus", focus)
window.removeEventListener("bridge:navigate", onSummon)
}
}, [])
const copyingRef = useRef(false)
async function choose(snippet: Snippet) {
if (copyingRef.current) return
copyingRef.current = true
try {
await navigator.clipboard.writeText(snippet.body)
recordUse.mutate(snippet.name)
toast.success("복사됨!", {
duration: 1000,
icon: (
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-green-600 text-white">
<Check className="size-3.5" strokeWidth={3} aria-hidden="true" />
</span>
),
})
await new Promise((resolve) => window.setTimeout(resolve, 1000))
hideWindow()
} catch {
toast.error("클립보드에 복사하지 못했어. 다시 시도해줘.")
} finally {
copyingRef.current = false
}
}
// 전역 키 핸들러: ↑↓ 이동 + Enter 복사 + Ctrl+Enter 붙여넣기 + F2 편집 + Ctrl+N 생성 + Ctrl+←/→ category 이동.
// 최신 목록/선택/다이얼로그/category 상태는 ref 로 읽어 리스너 1회만 등록.
const stateRef = useRef({ results, selected, dialogOpen: false, categories, category, q })
stateRef.current = {
results,
selected,
dialogOpen: dialogState !== null,
categories,
category,
q,
}
function selectIndex(index: number) {
const { results, q, category } = stateRef.current
const item = results[index]
setSelection(item ? { name: item.name, query: q, category } : null)
}
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.isComposing) return // IME 조합 중엔 Enter로 복사 안 함
const { results, selected, dialogOpen, categories, category } = stateRef.current
if (dialogOpen) return // 다이얼로그 입력 중엔 팔레트 전역 단축키(↑↓/Enter/F2 등) 비활성 — 폼이 처리
if (e.key === "ArrowDown") {
if (!results.length) return
e.preventDefault()
selectIndex(Math.min(selected + 1, results.length - 1))
} else if (e.key === "ArrowUp") {
if (!results.length) return
e.preventDefault()
selectIndex(selected < 0 ? results.length - 1 : Math.max(selected - 1, 0))
} else if (e.key === "Enter" && e.ctrlKey) {
// 버튼의 기본 Enter보다 먼저 처리하고, 브라우저·다른 조합도 일반 복사로 빠지지 않게 막음.
e.preventDefault()
if (!isWebView() || e.repeat || e.shiftKey || e.altKey || e.metaKey) return
const item = results[selected]
if (item) pasteToApp(item.body)
} else if (e.key === "Enter") {
// 실제 버튼의 Enter는 해당 버튼을 실행함. 검색창·선택 행에서만 즉시 복사.
if (
e.target instanceof HTMLElement &&
e.target.closest("button") &&
!e.target.closest('[data-snippet-selected="true"]')
)
return
e.preventDefault()
const item = results[selected]
if (item && !e.repeat) void choose(item)
} else if (e.key === "F2") {
// 선택된 행 편집(FR-017).
e.preventDefault()
const item = results[selected]
if (item) openEdit(item)
} else if (
e.ctrlKey &&
!e.altKey &&
!e.shiftKey &&
!e.metaKey &&
(e.key === "n" || e.key === "N")
) {
// 현재 화면의 새 항목 단축키: 스니펫 생성(FR-015).
e.preventDefault()
openCreate()
} else if (e.ctrlKey && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
// category 칩 좌우 이동, 끝에서 clamp(FR-020).
e.preventDefault()
const idx = categories.indexOf(category)
const nextIdx =
e.key === "ArrowLeft" ? Math.max(idx - 1, 0) : Math.min(idx + 1, categories.length - 1)
setSelection(null)
setCategory(categories[nextIdx])
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="bg-background text-foreground flex h-screen flex-col">
{/* 브랜드 헤더 — snap(Chat Everywhere)과 동일 + 창 드래그 영역(프레임리스 제목표시줄 대체) */}
{hostKind() !== "tauri" && (
<header
role="presentation"
onMouseDown={startWindowDrag}
className="border-border flex h-9 flex-none items-center gap-2 border-b px-4 select-none"
>
<span className="font-mono text-[11px] font-semibold tracking-[0.12em]">
Snippet Everywhere
</span>
</header>
)}
{/* 상단 검색 입력 */}
<div className="border-border flex h-14 flex-none items-center gap-3 border-b px-4">
<Search className="text-muted-foreground size-5 flex-none" />
<input
ref={inputRef}
type="text"
aria-label="스니펫 검색"
value={q}
onChange={(e) => {
setQ(e.target.value)
setSelection(null)
}}
placeholder="스니펫 검색…"
className="placeholder:text-muted-foreground w-full bg-transparent text-base outline-none"
/>
</div>
{/* category 칩 바 — 2개 이상(전체 포함)일 때만 보여줌 */}
{hasQuery && categories.length > 1 && (
<CategoryChips
categories={categories}
selected={category}
onSelect={(next) => {
setCategory(next)
setSelection(null)
}}
/>
)}
{/* 본문: 결과 리스트(좌) + 프리뷰(우) */}
{hasQuery && (
<div ref={splitRef} className="flex min-h-0 flex-1">
<div
ref={listRef}
style={{ width: preview ? `${listPct}%` : "100%" }}
className="scrollbar-hide min-w-0 flex-none space-y-1 overflow-y-auto p-2"
>
{isLoading && (
<div className="text-muted-foreground py-8 text-center text-xs"> </div>
)}
{error && (
<div role="alert" className="text-destructive py-8 text-center text-xs">
: {error.message}
</div>
)}
{!isLoading && !error && snippets.length === 0 && (
<div className="text-muted-foreground flex flex-col items-center gap-1 py-10 text-center text-xs">
<span> </span>
<span> </span>
</div>
)}
{!isLoading && snippets.length > 0 && results.length === 0 && (
<div className="text-muted-foreground py-8 text-center text-xs"> </div>
)}
{results.map((r, i) => (
<SnippetRow
key={r.name}
snippet={r}
selected={i === selected}
onSelect={() => selectIndex(i)}
onEdit={openEdit}
/>
))}
</div>
{/* 드래그 핸들(button=네이티브 인터랙티브) — 드래그 또는 포커스 후 ←/→ 로 비율 조절 */}
{preview && (
<>
<button
type="button"
aria-label="미리보기 크기 조절 (드래그 또는 ←/→)"
onMouseDown={() => {
draggingRef.current = true
document.body.style.userSelect = "none"
}}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") setListPct((p) => Math.max(20, p - 2))
else if (e.key === "ArrowRight") setListPct((p) => Math.min(70, p + 2))
}}
className="bg-border/60 hover:bg-accent focus-visible:bg-accent w-1.5 flex-none cursor-col-resize p-0 outline-none"
/>
<aside aria-label="코드 미리보기" className="min-h-0 min-w-0 flex-1 overflow-hidden">
<PreviewPane snippet={preview} />
</aside>
</>
)}
</div>
)}
{hasQuery && (
<footer className="border-border text-muted-foreground flex min-h-9 shrink-0 flex-wrap items-center justify-between gap-x-4 gap-y-1 border-t px-4 py-1 text-xs">
<span role="status">{isLoading ? "검색 준비 중" : `${results.length}개 결과`}</span>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<span>
{preview ? "Enter 복사 후 닫기 · F2 편집" : "↑ ↓ 선택해서 미리보기"} · Esc
</span>
{preview && isWebView() && <span>Ctrl+Enter </span>}
{preview && <CodeActions key={preview.name} code={preview.body} />}
</div>
</footer>
)}
<EditDialog
open={dialogState !== null}
mode={dialogState?.mode ?? "create"}
snippet={dialogState?.snippet}
onClose={closeDialog}
onSave={saveSnippet}
onDelete={removeSnippet}
isSaving={createSnippet.isPending || updateSnippet.isPending}
isDeleting={deleteSnippet.isPending}
/>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More