Initial Commit
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">" })[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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// 로컬 엔티티 — 서버 DTO 아님(뒤가 C# 브릿지/SQLite). src/types/api.ts 에 안 넣음.
|
||||
// data-model.md §프론트 타입 그대로.
|
||||
|
||||
export interface Snippet {
|
||||
name: string
|
||||
desc: string
|
||||
body: string
|
||||
category: string
|
||||
usageCount: number // usage.count (없으면 0)
|
||||
lastUsed: number // usage.last_used unix초 (없으면 0)
|
||||
}
|
||||
|
||||
export interface SnippetInput {
|
||||
// 생성/수정 입력
|
||||
name: string
|
||||
desc: string
|
||||
body: string
|
||||
category: string
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
||||
import axios from "axios"
|
||||
import MockAdapter from "axios-mock-adapter"
|
||||
import { apiClient, apiGet, apiPost, apiList } from "./client"
|
||||
import { ApiError } from "./errors"
|
||||
import { useSessionExpiryStore } from "@/features/auth/store/sessionExpiryStore"
|
||||
import { setAccessToken } from "@/lib/auth/tokenProvider"
|
||||
|
||||
const BASE = "http://localhost:8001/api/v1"
|
||||
|
||||
function envelope<T>(data: T, meta: unknown = null, counts: number | null = null) {
|
||||
return {
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
code: null,
|
||||
message: null,
|
||||
data,
|
||||
counts,
|
||||
errors: [] as string[],
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
meta,
|
||||
}
|
||||
}
|
||||
|
||||
function errorEnvelope(opts: {
|
||||
statusCode: number
|
||||
message: string
|
||||
code?: string
|
||||
errors?: string[]
|
||||
}) {
|
||||
return {
|
||||
success: false,
|
||||
statusCode: opts.statusCode,
|
||||
code: opts.code ?? null,
|
||||
message: opts.message,
|
||||
data: null,
|
||||
counts: null,
|
||||
errors: opts.errors ?? [opts.message],
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
meta: null,
|
||||
}
|
||||
}
|
||||
|
||||
let clientMock: MockAdapter
|
||||
let globalMock: MockAdapter
|
||||
|
||||
beforeEach(() => {
|
||||
clientMock = new MockAdapter(apiClient)
|
||||
// refresh는 글로벌 axios.post로 호출 (인터셉터 우회 위해)
|
||||
globalMock = new MockAdapter(axios)
|
||||
useSessionExpiryStore.setState({ open: false, queue: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clientMock.restore()
|
||||
globalMock.restore()
|
||||
})
|
||||
|
||||
describe("apiGet / apiPost / unwrap", () => {
|
||||
it("성공 envelope이면 data만 반환", async () => {
|
||||
clientMock.onGet("/users/me").reply(200, envelope({ id: "u1", email: "x@x.com" }))
|
||||
const result = await apiGet<{ id: string; email: string }>("/users/me")
|
||||
expect(result).toEqual({ id: "u1", email: "x@x.com" })
|
||||
})
|
||||
|
||||
it("withCredentials=true 가 인스턴스 default로 박힘", async () => {
|
||||
expect(apiClient.defaults.withCredentials).toBe(true)
|
||||
})
|
||||
|
||||
it("baseURL 이 env 값으로 설정됨", () => {
|
||||
expect(apiClient.defaults.baseURL).toBe(BASE)
|
||||
})
|
||||
|
||||
it("apiPost: body 직렬화 + 응답 unwrap", async () => {
|
||||
clientMock.onPost("/users", { email: "x@x.com" }).reply(200, envelope({ id: 1 }))
|
||||
const result = await apiPost<{ id: number }>("/users", { email: "x@x.com" })
|
||||
expect(result).toEqual({ id: 1 })
|
||||
})
|
||||
|
||||
it("응답 envelope success=false 면 ApiError reject (메시지·code·errors 추출)", async () => {
|
||||
clientMock.onGet("/missing").reply(
|
||||
404,
|
||||
errorEnvelope({
|
||||
statusCode: 404,
|
||||
message: "메모를 찾을 수 없음",
|
||||
code: "MEMO_NOT_FOUND",
|
||||
})
|
||||
)
|
||||
await expect(apiGet("/missing")).rejects.toMatchObject({
|
||||
name: "ApiError",
|
||||
status: 404,
|
||||
message: "메모를 찾을 수 없음",
|
||||
code: "MEMO_NOT_FOUND",
|
||||
})
|
||||
})
|
||||
|
||||
it("200 응답에 envelope.success=false 박혀와도 ApiError reject", async () => {
|
||||
clientMock
|
||||
.onGet("/weird")
|
||||
.reply(200, errorEnvelope({ statusCode: 400, message: "validation 실패" }))
|
||||
await expect(apiGet("/weird")).rejects.toBeInstanceOf(ApiError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("apiList", () => {
|
||||
it("envelope에서 items / meta / counts 추출", async () => {
|
||||
const meta = {
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
totalItems: 25,
|
||||
totalPages: 3,
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
}
|
||||
clientMock.onGet("/users").reply(200, envelope([{ id: "a" }, { id: "b" }], meta, 2))
|
||||
|
||||
const result = await apiList<{ id: string }>("/users")
|
||||
expect(result.items).toEqual([{ id: "a" }, { id: "b" }])
|
||||
expect(result.meta).toEqual(meta)
|
||||
expect(result.counts).toBe(2)
|
||||
})
|
||||
|
||||
it("data가 null이면 items 빈 배열로 fallback", async () => {
|
||||
clientMock.onGet("/users").reply(200, envelope(null))
|
||||
const result = await apiList<{ id: string }>("/users")
|
||||
expect(result.items).toEqual([])
|
||||
expect(result.counts).toBe(0)
|
||||
})
|
||||
|
||||
it("query params 전달", async () => {
|
||||
clientMock.onGet("/users").reply((config) => {
|
||||
expect(config.params).toEqual({ page: 2, search: "abc" })
|
||||
return [200, envelope([], null, 0)]
|
||||
})
|
||||
await apiList<{ id: string }>("/users", { params: { page: 2, search: "abc" } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("401 인터셉터 — refresh 성공", () => {
|
||||
it("401 → refresh 호출 → 원 요청 재시도", async () => {
|
||||
clientMock
|
||||
.onGet("/users/me")
|
||||
.replyOnce(401, errorEnvelope({ statusCode: 401, message: "expired" }))
|
||||
.onGet("/users/me")
|
||||
.replyOnce(200, envelope({ id: "u1" }))
|
||||
|
||||
globalMock.onPost(`${BASE}/auth/refresh`).reply(200, envelope({ token: "new" }))
|
||||
|
||||
const result = await apiGet<{ id: string }>("/users/me")
|
||||
expect(result).toEqual({ id: "u1" })
|
||||
expect(globalMock.history.post.some((r) => r.url?.endsWith("/auth/refresh"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("401 인터셉터 — refresh 실패", () => {
|
||||
it("refresh가 401이면 sessionExpiryStore에 큐 push + open=true", async () => {
|
||||
clientMock.onGet("/users/me").reply(401, errorEnvelope({ statusCode: 401, message: "expired" }))
|
||||
globalMock
|
||||
.onPost(`${BASE}/auth/refresh`)
|
||||
.reply(401, errorEnvelope({ statusCode: 401, message: "bad refresh" }))
|
||||
|
||||
// pushFailure는 retry 함수를 큐에 보관 → resolve/reject는 closeAndFlush 시점.
|
||||
// 여기선 promise를 띄워두기만.
|
||||
const promise = apiGet("/users/me")
|
||||
// 인터셉터 처리 시간 확보
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
const state = useSessionExpiryStore.getState()
|
||||
expect(state.open).toBe(true)
|
||||
expect(state.queue.length).toBe(1)
|
||||
|
||||
// dangling promise 정리 — cancel하면 큐가 폐기됨. 결과는 cancel로 reject되지 않으니 catch만.
|
||||
state.cancel()
|
||||
promise.catch(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
describe("401 인터셉터 — 인증 초기화", () => {
|
||||
it("refresh 실패를 세션 만료 큐에 넣지 않고 호출자에게 반환", async () => {
|
||||
clientMock.onGet("/users/me").reply(401, errorEnvelope({ statusCode: 401, message: "no auth" }))
|
||||
globalMock
|
||||
.onPost(`${BASE}/auth/refresh`)
|
||||
.reply(401, errorEnvelope({ statusCode: 401, message: "no refresh" }))
|
||||
|
||||
await expect(apiGet("/users/me", { __skipSessionExpiry: true })).rejects.toBeInstanceOf(
|
||||
ApiError
|
||||
)
|
||||
expect(useSessionExpiryStore.getState().open).toBe(false)
|
||||
expect(useSessionExpiryStore.getState().queue).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("__skipAuth — public 엔드포인트 401 시 모달 안 띄움", () => {
|
||||
it("logout 같은 skipAuth 요청은 401에서도 sessionExpiryStore 안 건드림", async () => {
|
||||
clientMock
|
||||
.onPost("/auth/logout")
|
||||
.reply(401, errorEnvelope({ statusCode: 401, message: "no session" }))
|
||||
|
||||
await expect(apiPost("/auth/logout", undefined, { __skipAuth: true })).rejects.toBeInstanceOf(
|
||||
ApiError
|
||||
)
|
||||
|
||||
expect(useSessionExpiryStore.getState().open).toBe(false)
|
||||
expect(useSessionExpiryStore.getState().queue.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("apiClient Bearer 주입", () => {
|
||||
afterEach(() => {
|
||||
setAccessToken(null)
|
||||
delete apiClient.defaults.adapter
|
||||
})
|
||||
|
||||
it("토큰 있으면 Authorization: Bearer 헤더를 붙인다", async () => {
|
||||
setAccessToken("tok123")
|
||||
let seen: unknown
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
seen = config.headers.Authorization
|
||||
return {
|
||||
data: { success: true, data: null },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config,
|
||||
} as never
|
||||
}
|
||||
await apiClient.get("/ping")
|
||||
expect(seen).toBe("Bearer tok123")
|
||||
})
|
||||
|
||||
it("토큰 없으면 Authorization 를 안 붙인다(쿠키 모드)", async () => {
|
||||
setAccessToken(null)
|
||||
let seen: unknown = "sentinel"
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
seen = config.headers.Authorization
|
||||
return {
|
||||
data: { success: true, data: null },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config,
|
||||
} as never
|
||||
}
|
||||
await apiClient.get("/ping")
|
||||
expect(seen).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import axios, { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from "axios"
|
||||
import { env } from "@/config/env"
|
||||
import { useSessionExpiryStore } from "@/features/auth/store/sessionExpiryStore"
|
||||
import { getAccessToken } from "@/lib/auth/tokenProvider"
|
||||
import { ApiError } from "./errors"
|
||||
import type { CommonResponse, Meta } from "@/types/api"
|
||||
|
||||
/**
|
||||
* 단일 axios 인스턴스. 모든 인증 보호 API는 이걸 통해 호출.
|
||||
*
|
||||
* - `withCredentials: true` 로 httpOnly 쿠키(`accessToken`/`refreshToken`/`accessTokenExp`) 자동 전송
|
||||
* - 응답 인터셉터에서 envelope 검사 (`success === false` → ApiError reject)
|
||||
* - 401 인터셉터: refresh → 재시도 → 실패 시 sessionExpiryStore 큐에 push (모달 노출)
|
||||
* - public 엔드포인트(`__skipAuth`)는 401 흐름 우회
|
||||
*
|
||||
* 호출자는 보통 helper(`apiGet`/`apiPost`/`apiPatch`/`apiDelete`/`apiList`) 사용.
|
||||
* envelope 직접 다뤄야 하면 `apiClient.get<CommonResponse<T>>(...)` 처럼 raw 호출.
|
||||
*/
|
||||
export const apiClient = axios.create({
|
||||
baseURL: env.apiBaseUrl,
|
||||
withCredentials: true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
|
||||
interface AuthMeta {
|
||||
__isRetry?: boolean
|
||||
__skipAuth?: boolean
|
||||
__skipSessionExpiry?: boolean
|
||||
}
|
||||
|
||||
type RequestConfig = InternalAxiosRequestConfig & AuthMeta
|
||||
export type CallerConfig = AxiosRequestConfig & AuthMeta
|
||||
|
||||
// .NET 웹뷰 호스트가 토큰을 주입하면 Bearer 로, 아니면(=오늘) 쿠키로.
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = getAccessToken()
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
let inflightRefresh: Promise<boolean> | null = null
|
||||
|
||||
async function performRefresh(): Promise<boolean> {
|
||||
if (inflightRefresh) return inflightRefresh
|
||||
inflightRefresh = (async () => {
|
||||
try {
|
||||
// baseURL 그대로 사용. validateStatus로 인터셉터 reject 우회 (무한루프 방지)
|
||||
const res = await axios.post(`${env.apiBaseUrl}/auth/refresh`, undefined, {
|
||||
withCredentials: true,
|
||||
validateStatus: () => true,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
const body = res.data as CommonResponse<unknown> | undefined
|
||||
return res.status >= 200 && res.status < 300 && body?.success === true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
inflightRefresh = null
|
||||
}
|
||||
})()
|
||||
return inflightRefresh
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
const env = response.data as CommonResponse<unknown> | undefined
|
||||
if (env && typeof env === "object" && "success" in env && env.success === false) {
|
||||
throw new ApiError(
|
||||
env.statusCode ?? response.status,
|
||||
env.message ?? "API error",
|
||||
env.errors ?? [],
|
||||
env.code ?? null
|
||||
)
|
||||
}
|
||||
return response
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
const status = error.response?.status
|
||||
const config = error.config as RequestConfig | undefined
|
||||
|
||||
if (status === 401 && config && !config.__isRetry && !config.__skipAuth) {
|
||||
const ok = await performRefresh()
|
||||
if (ok) {
|
||||
const retryConfig: RequestConfig = { ...config, __isRetry: true }
|
||||
return apiClient.request(retryConfig)
|
||||
}
|
||||
// 앱 시작 때 저장된 user를 검증하는 요청은 모달에 가두지 않고 호출자에게 실패를 돌려준다.
|
||||
if (!config.__skipSessionExpiry) {
|
||||
return useSessionExpiryStore.getState().pushFailure(() => {
|
||||
const retryConfig: RequestConfig = { ...config, __isRetry: true }
|
||||
return apiClient.request(retryConfig)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const env = error.response?.data as CommonResponse<unknown> | undefined
|
||||
if (env && typeof env === "object" && "success" in env && env.success === false) {
|
||||
throw new ApiError(
|
||||
env.statusCode ?? status ?? 0,
|
||||
env.message ?? error.message,
|
||||
env.errors ?? [],
|
||||
env.code ?? null
|
||||
)
|
||||
}
|
||||
throw new ApiError(status ?? 0, error.message)
|
||||
}
|
||||
)
|
||||
|
||||
// ---- envelope 헬퍼 (caller가 unwrapped 값만 받도록) ----
|
||||
|
||||
export async function apiGet<T>(path: string, config?: CallerConfig): Promise<T> {
|
||||
const res = await apiClient.get<CommonResponse<T>>(path, config)
|
||||
return res.data.data as T
|
||||
}
|
||||
|
||||
export async function apiPost<T>(path: string, body?: unknown, config?: CallerConfig): Promise<T> {
|
||||
const res = await apiClient.post<CommonResponse<T>>(path, body, config)
|
||||
return res.data.data as T
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(path: string, body?: unknown, config?: CallerConfig): Promise<T> {
|
||||
const res = await apiClient.patch<CommonResponse<T>>(path, body, config)
|
||||
return res.data.data as T
|
||||
}
|
||||
|
||||
export async function apiPut<T>(path: string, body?: unknown, config?: CallerConfig): Promise<T> {
|
||||
const res = await apiClient.put<CommonResponse<T>>(path, body, config)
|
||||
return res.data.data as T
|
||||
}
|
||||
|
||||
export async function apiDelete<T = void>(path: string, config?: CallerConfig): Promise<T> {
|
||||
const res = await apiClient.delete<CommonResponse<T>>(path, config)
|
||||
return res.data.data as T
|
||||
}
|
||||
|
||||
export interface ListResult<T> {
|
||||
items: T[]
|
||||
meta: Meta | null
|
||||
counts: number
|
||||
}
|
||||
|
||||
export async function apiList<T>(path: string, config?: CallerConfig): Promise<ListResult<T>> {
|
||||
const res = await apiClient.get<CommonResponse<T[]>>(path, config)
|
||||
return {
|
||||
items: res.data.data ?? [],
|
||||
meta: res.data.meta,
|
||||
counts: res.data.counts ?? 0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { ApiError } from "./errors"
|
||||
|
||||
describe("ApiError", () => {
|
||||
it("status / message / errors / code 보유", () => {
|
||||
const err = new ApiError(401, "invalid token", ["invalid token"], "INVALID_CREDENTIALS")
|
||||
expect(err.status).toBe(401)
|
||||
expect(err.message).toBe("invalid token")
|
||||
expect(err.errors).toEqual(["invalid token"])
|
||||
expect(err.code).toBe("INVALID_CREDENTIALS")
|
||||
})
|
||||
|
||||
it("errors 기본값 빈 배열, code 기본값 null", () => {
|
||||
const err = new ApiError(500, "server error")
|
||||
expect(err.errors).toEqual([])
|
||||
expect(err.code).toBeNull()
|
||||
})
|
||||
|
||||
it("ApiError instance 가드", () => {
|
||||
const err = new ApiError(400, "bad")
|
||||
expect(err).toBeInstanceOf(ApiError)
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 백엔드 envelope `{ success: false, statusCode, message, errors[], code }` 에 1:1 대응.
|
||||
* axios 응답 인터셉터에서 만들어지고 caller가 instanceof로 분기.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
errors: string[]
|
||||
code: string | null
|
||||
|
||||
constructor(status: number, message: string, errors: string[] = [], code: string | null = null) {
|
||||
super(message)
|
||||
this.name = "ApiError"
|
||||
this.status = status
|
||||
this.errors = errors
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
|
||||
const mockLoginPopup = vi.fn()
|
||||
const mockAcquireTokenSilent = vi.fn()
|
||||
const mockInitialize = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock("@azure/msal-browser", () => ({
|
||||
PublicClientApplication: vi.fn().mockImplementation(() => ({
|
||||
initialize: mockInitialize,
|
||||
loginPopup: mockLoginPopup,
|
||||
acquireTokenSilent: mockAcquireTokenSilent,
|
||||
getAllAccounts: vi.fn().mockReturnValue([]),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock("@/features/auth/api/auth.api", () => ({
|
||||
authApi: {
|
||||
getEntraConfig: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("getEntraConfig", () => {
|
||||
it("authApi.getEntraConfig 200 → config 반환 + 캐시", async () => {
|
||||
const cfg = { clientId: "c", authority: "https://a", tenantId: "t" }
|
||||
const { authApi } = await import("@/features/auth/api/auth.api")
|
||||
;(authApi.getEntraConfig as any).mockResolvedValue(cfg)
|
||||
|
||||
const { getEntraConfig } = await import("./msal")
|
||||
expect(await getEntraConfig()).toEqual(cfg)
|
||||
expect(await getEntraConfig()).toEqual(cfg)
|
||||
expect(authApi.getEntraConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("authApi.getEntraConfig null → null 반환", async () => {
|
||||
const { authApi } = await import("@/features/auth/api/auth.api")
|
||||
;(authApi.getEntraConfig as any).mockResolvedValue(null)
|
||||
const { getEntraConfig } = await import("./msal")
|
||||
expect(await getEntraConfig()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("loginWithMicrosoft", () => {
|
||||
it("정상 흐름 → idToken + graphAccessToken 반환", async () => {
|
||||
const cfg = { clientId: "c", authority: "https://a", tenantId: "t" }
|
||||
const { authApi } = await import("@/features/auth/api/auth.api")
|
||||
;(authApi.getEntraConfig as any).mockResolvedValue(cfg)
|
||||
mockLoginPopup.mockResolvedValue({ idToken: "ID_TOKEN", account: { homeAccountId: "h1" } })
|
||||
mockAcquireTokenSilent.mockResolvedValue({ accessToken: "GRAPH_TOKEN" })
|
||||
const { loginWithMicrosoft } = await import("./msal")
|
||||
const result = await loginWithMicrosoft()
|
||||
expect(result).toEqual({ idToken: "ID_TOKEN", graphAccessToken: "GRAPH_TOKEN" })
|
||||
})
|
||||
|
||||
it("acquireTokenSilent 실패 → graphAccessToken=null (swallow)", async () => {
|
||||
const cfg = { clientId: "c", authority: "https://a", tenantId: "t" }
|
||||
const { authApi } = await import("@/features/auth/api/auth.api")
|
||||
;(authApi.getEntraConfig as any).mockResolvedValue(cfg)
|
||||
mockLoginPopup.mockResolvedValue({ idToken: "ID_TOKEN", account: { homeAccountId: "h1" } })
|
||||
mockAcquireTokenSilent.mockRejectedValue(new Error("silent fail"))
|
||||
const { loginWithMicrosoft } = await import("./msal")
|
||||
const result = await loginWithMicrosoft()
|
||||
expect(result).toEqual({ idToken: "ID_TOKEN", graphAccessToken: null })
|
||||
})
|
||||
|
||||
it("loginPopup 실패 → throw", async () => {
|
||||
const cfg = { clientId: "c", authority: "https://a", tenantId: "t" }
|
||||
const { authApi } = await import("@/features/auth/api/auth.api")
|
||||
;(authApi.getEntraConfig as any).mockResolvedValue(cfg)
|
||||
const err: any = new Error("user_cancelled")
|
||||
err.errorCode = "user_cancelled"
|
||||
mockLoginPopup.mockRejectedValue(err)
|
||||
const { loginWithMicrosoft } = await import("./msal")
|
||||
await expect(loginWithMicrosoft()).rejects.toThrow("user_cancelled")
|
||||
})
|
||||
|
||||
it("config null → ENTRA_NOT_CONFIGURED throw", async () => {
|
||||
const { authApi } = await import("@/features/auth/api/auth.api")
|
||||
;(authApi.getEntraConfig as any).mockResolvedValue(null)
|
||||
const { loginWithMicrosoft } = await import("./msal")
|
||||
await expect(loginWithMicrosoft()).rejects.toThrow("ENTRA_NOT_CONFIGURED")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { PublicClientApplication, type AuthenticationResult } from "@azure/msal-browser"
|
||||
import { authApi } from "@/features/auth/api/auth.api"
|
||||
import { env } from "@/config/env"
|
||||
import type { EntraConfigResponse } from "@/types/api"
|
||||
|
||||
let _msalApp: PublicClientApplication | null = null
|
||||
let _config: EntraConfigResponse | null = null
|
||||
let _configFetched = false
|
||||
|
||||
export async function getEntraConfig(): Promise<EntraConfigResponse | null> {
|
||||
if (_configFetched) return _config
|
||||
_config = await authApi.getEntraConfig()
|
||||
_configFetched = true
|
||||
return _config
|
||||
}
|
||||
|
||||
async function getMsalApp(): Promise<PublicClientApplication> {
|
||||
if (_msalApp) return _msalApp
|
||||
const cfg = await getEntraConfig()
|
||||
if (!cfg) throw new Error("ENTRA_NOT_CONFIGURED")
|
||||
_msalApp = new PublicClientApplication({
|
||||
auth: {
|
||||
clientId: cfg.clientId,
|
||||
authority: cfg.authority,
|
||||
redirectUri: window.location.origin,
|
||||
},
|
||||
cache: { cacheLocation: "sessionStorage" },
|
||||
})
|
||||
await _msalApp.initialize()
|
||||
return _msalApp
|
||||
}
|
||||
|
||||
export interface EntraTokens {
|
||||
idToken: string
|
||||
graphAccessToken: string | null
|
||||
}
|
||||
|
||||
export async function loginWithMicrosoft(): Promise<EntraTokens> {
|
||||
const app = await getMsalApp()
|
||||
const result: AuthenticationResult = await app.loginPopup({
|
||||
scopes: [env.entraGraphScope, "openid", "profile", "email"],
|
||||
// 브라우저에 MS 계정이 이미 로그인돼 있어도 항상 계정 선택/추가 창을 표시.
|
||||
prompt: "select_account",
|
||||
})
|
||||
|
||||
let graphAccessToken: string | null = null
|
||||
try {
|
||||
const silent = await app.acquireTokenSilent({
|
||||
account: result.account!,
|
||||
scopes: [env.entraGraphScope],
|
||||
})
|
||||
graphAccessToken = silent.accessToken
|
||||
} catch (e) {
|
||||
console.warn("[entra] acquireTokenSilent failed, fallback to App Permission", e)
|
||||
}
|
||||
|
||||
return { idToken: result.idToken, graphAccessToken }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect, afterEach } from "vitest"
|
||||
import { getAccessToken, setAccessToken } from "./tokenProvider"
|
||||
|
||||
afterEach(() => setAccessToken(null))
|
||||
|
||||
describe("tokenProvider", () => {
|
||||
it("기본값은 null (쿠키 모드)", () => {
|
||||
expect(getAccessToken()).toBeNull()
|
||||
})
|
||||
it("set 하면 그 토큰을 돌려준다", () => {
|
||||
setAccessToken("abc")
|
||||
expect(getAccessToken()).toBe("abc")
|
||||
})
|
||||
it("null 로 다시 초기화 가능", () => {
|
||||
setAccessToken("abc")
|
||||
setAccessToken(null)
|
||||
expect(getAccessToken()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
// .NET 웹뷰 호스트가 주입할 accessToken 의 단일 소스.
|
||||
// 브라우저(오늘)에선 기본 null → 쿠키 인증. 나중에 .NET 이 setAccessToken 으로 채우면
|
||||
// client.ts / sse.ts 가 Authorization: Bearer 로 전환한다.
|
||||
let accessToken: string | null = null
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return accessToken
|
||||
}
|
||||
|
||||
export function setAccessToken(token: string | null): void {
|
||||
accessToken = token
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks"
|
||||
import { emit } from "@tauri-apps/api/event"
|
||||
import {
|
||||
consumePendingCaptureImage,
|
||||
getLastPasteTarget,
|
||||
initBridgeNavigate,
|
||||
setBridgeNavigate,
|
||||
} from "./bridgeNavigate"
|
||||
|
||||
describe("bridgeNavigate", () => {
|
||||
afterEach(() => {
|
||||
setBridgeNavigate(null)
|
||||
clearMocks()
|
||||
})
|
||||
|
||||
it("Tauri bridge payload를 기존 navigate·paste.target·capture.image 분기로 보낸다", async () => {
|
||||
mockIPC(() => undefined, { shouldMockEvents: true })
|
||||
const navigate = vi.fn()
|
||||
const onNavigate = vi.fn()
|
||||
const onPasteTarget = vi.fn()
|
||||
const onCapture = vi.fn()
|
||||
setBridgeNavigate(navigate)
|
||||
window.addEventListener("bridge:navigate", onNavigate)
|
||||
window.addEventListener("bridge:pasteTarget", onPasteTarget)
|
||||
window.addEventListener("bridge:captureImage", onCapture)
|
||||
initBridgeNavigate()
|
||||
initBridgeNavigate()
|
||||
|
||||
await emit("bridge", { type: "navigate", path: "/snippet" })
|
||||
await emit("bridge", { type: "paste.target", name: "메모장", app: "notepad" })
|
||||
await emit("bridge", { type: "capture.image", dataUrl: "data:image/png;base64,abc" })
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith("/snippet")
|
||||
expect(onNavigate).toHaveBeenCalledTimes(1)
|
||||
expect(getLastPasteTarget()).toEqual({ name: "메모장", app: "notepad" })
|
||||
expect(onPasteTarget).toHaveBeenCalledTimes(1)
|
||||
expect(onCapture).toHaveBeenCalledTimes(1)
|
||||
expect(consumePendingCaptureImage()).toBe("data:image/png;base64,abc")
|
||||
expect(consumePendingCaptureImage()).toBe("")
|
||||
|
||||
window.removeEventListener("bridge:navigate", onNavigate)
|
||||
window.removeEventListener("bridge:pasteTarget", onPasteTarget)
|
||||
window.removeEventListener("bridge:captureImage", onCapture)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { listen } from "./transport"
|
||||
|
||||
type NavigateFn = (path: string) => void
|
||||
|
||||
export interface PasteTarget {
|
||||
name: string
|
||||
app: string
|
||||
}
|
||||
|
||||
let navigateFn: NavigateFn | null = null
|
||||
let lastPasteTarget: PasteTarget = { name: "", app: "" }
|
||||
let pendingCaptureImage = ""
|
||||
|
||||
/** 앱 루트에서 useNavigate() 감싼 콜백을 등록함. */
|
||||
export function setBridgeNavigate(fn: NavigateFn | null) {
|
||||
navigateFn = fn
|
||||
}
|
||||
|
||||
/** 마지막 붙여넣기 대상 스냅샷을 돌려줌. */
|
||||
export function getLastPasteTarget(): PasteTarget {
|
||||
return lastPasteTarget
|
||||
}
|
||||
|
||||
/** 마지막 캡처 이미지를 한 번만 소비함. */
|
||||
export function consumePendingCaptureImage(): string {
|
||||
const dataUrl = pendingCaptureImage
|
||||
pendingCaptureImage = ""
|
||||
return dataUrl
|
||||
}
|
||||
|
||||
function handleBridgeMessage(data: {
|
||||
type: string
|
||||
path?: string
|
||||
name?: string
|
||||
app?: string
|
||||
dataUrl?: string
|
||||
}) {
|
||||
if (data.type === "paste.target") {
|
||||
lastPasteTarget = { name: data.name ?? "", app: data.app ?? "" }
|
||||
window.dispatchEvent(new CustomEvent("bridge:pasteTarget", { detail: lastPasteTarget }))
|
||||
return
|
||||
}
|
||||
|
||||
if (data.type === "capture.image") {
|
||||
pendingCaptureImage = data.dataUrl ?? ""
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("bridge:captureImage", { detail: { dataUrl: pendingCaptureImage } })
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.type !== "navigate" || !data.path) return
|
||||
navigateFn?.(data.path)
|
||||
window.dispatchEvent(new CustomEvent("bridge:navigate", { detail: { path: data.path } }))
|
||||
}
|
||||
|
||||
/** 현재 데스크톱 transport의 push 메시지를 앱 이벤트로 바꿈. */
|
||||
export function initBridgeNavigate() {
|
||||
listen(handleBridgeMessage)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks"
|
||||
import { emit } from "@tauri-apps/api/event"
|
||||
import { hideWindow, reportRoute } from "./webviewBridge"
|
||||
import { initBridgeNavigate, setBridgeNavigate } from "./bridgeNavigate"
|
||||
|
||||
afterEach(() => {
|
||||
clearMocks()
|
||||
setBridgeNavigate(null)
|
||||
delete (window as unknown as { chrome?: unknown }).chrome
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("Tauri 단축키 연결", () => {
|
||||
it("현재 대화 위치 보고와 Esc 숨김을 Rust로 보냄", () => {
|
||||
const ipc = vi.fn()
|
||||
mockIPC(ipc)
|
||||
reportRoute("/snap/session/123")
|
||||
hideWindow()
|
||||
expect(ipc).toHaveBeenCalledWith("report_route", { path: "/snap/session/123" })
|
||||
expect(ipc).toHaveBeenCalledWith("window_hide", {})
|
||||
})
|
||||
|
||||
it("화면 이동과 같은 스니펫 재소환 신호를 매번 전달하고 중복 구독하지 않음", async () => {
|
||||
mockIPC(() => {}, { shouldMockEvents: true })
|
||||
const addEventListener = vi.fn()
|
||||
;(window as unknown as { chrome?: unknown }).chrome = {
|
||||
webview: { postMessage: vi.fn(), addEventListener },
|
||||
}
|
||||
const navigate = vi.fn()
|
||||
const summon = vi.fn()
|
||||
window.addEventListener("bridge:navigate", summon)
|
||||
setBridgeNavigate(navigate)
|
||||
initBridgeNavigate()
|
||||
initBridgeNavigate()
|
||||
// listen 등록 Promise가 완료된 뒤 네이티브 푸시를 보냄.
|
||||
await Promise.resolve()
|
||||
await emit("bridge", { type: "navigate", path: "/snippet" })
|
||||
await emit("bridge", { type: "navigate", path: "/snippet" })
|
||||
await emit("bridge", { type: "navigate", path: "/snap/session/123" })
|
||||
expect(navigate.mock.calls).toEqual([["/snippet"], ["/snippet"], ["/snap/session/123"]])
|
||||
expect(summon).toHaveBeenCalledTimes(3)
|
||||
expect(addEventListener).not.toHaveBeenCalled()
|
||||
window.removeEventListener("bridge:navigate", summon)
|
||||
})
|
||||
|
||||
it("Windows Tauri의 chrome.webview를 닷넷으로 오인하지 않음", () => {
|
||||
const ipc = vi.fn()
|
||||
mockIPC(ipc)
|
||||
const postMessage = vi.fn()
|
||||
;(window as unknown as { chrome?: unknown }).chrome = { webview: { postMessage } }
|
||||
hideWindow()
|
||||
expect(postMessage).not.toHaveBeenCalled()
|
||||
expect(ipc).toHaveBeenCalledWith("window_hide", {})
|
||||
})
|
||||
|
||||
it("Tauri가 없는 닷넷에서는 기존 메시지 통로를 사용", () => {
|
||||
const postMessage = vi.fn()
|
||||
;(window as unknown as { chrome?: unknown }).chrome = { webview: { postMessage } }
|
||||
hideWindow()
|
||||
expect(postMessage).toHaveBeenCalledWith({ type: "window.hide" })
|
||||
})
|
||||
|
||||
it("일반 브라우저에서는 창 제어를 보내지 않음", () => {
|
||||
mockIPC(() => {})
|
||||
clearMocks()
|
||||
expect(hideWindow()).toBe(false)
|
||||
expect(reportRoute("/snap")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest"
|
||||
import { request } from "./snippetBridge"
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks"
|
||||
|
||||
type Listener = (e: MessageEvent) => void
|
||||
|
||||
function mockWebview() {
|
||||
const postMessage = vi.fn()
|
||||
let listener: Listener | undefined
|
||||
;(window as unknown as { chrome?: unknown }).chrome = {
|
||||
webview: {
|
||||
postMessage,
|
||||
addEventListener: (type: string, cb: Listener) => {
|
||||
if (type === "message") listener = cb
|
||||
},
|
||||
},
|
||||
}
|
||||
return {
|
||||
postMessage,
|
||||
emit: (data: unknown) => listener?.({ data } as MessageEvent),
|
||||
}
|
||||
}
|
||||
|
||||
describe("snippetBridge.request", () => {
|
||||
afterEach(() => {
|
||||
clearMocks()
|
||||
delete (window as unknown as { chrome?: unknown }).chrome
|
||||
})
|
||||
|
||||
it("Windows Tauri에서는 Rust 목록 응답을 반환하고 닷넷 통로는 쓰지 않음", async () => {
|
||||
const wv = mockWebview()
|
||||
const rows = [{ name: "A", body: "한글 본문", category: "코드", usageCount: 3 }]
|
||||
const ipc = vi.fn().mockResolvedValue(rows)
|
||||
mockIPC(ipc)
|
||||
await expect(request("snippets.list")).resolves.toEqual(rows)
|
||||
expect(ipc).toHaveBeenCalledWith("snippets_list", {})
|
||||
expect(wv.postMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("Tauri 실패 문자열을 Error로 변환하고 사용기록 명령 이름을 연결", async () => {
|
||||
const ipc = vi.fn().mockRejectedValue("DB 열기 실패")
|
||||
mockIPC(ipc)
|
||||
await expect(request("snippets.recordUse", { name: "A" })).rejects.toThrow("DB 열기 실패")
|
||||
expect(ipc).toHaveBeenCalledWith("snippets_record_use", { name: "A" })
|
||||
})
|
||||
|
||||
it("매칭되는 snippets.result(ok:true)가 오면 resolve 됨", async () => {
|
||||
const wv = mockWebview()
|
||||
const promise = request("snippets.list")
|
||||
|
||||
expect(wv.postMessage).toHaveBeenCalledTimes(1)
|
||||
const sent = wv.postMessage.mock.calls[0][0] as { type: string; reqId: string }
|
||||
expect(sent.type).toBe("snippets.list")
|
||||
expect(sent.reqId).toBeTruthy()
|
||||
|
||||
wv.emit({ type: "snippets.result", reqId: sent.reqId, ok: true, data: [{ name: "a" }] })
|
||||
|
||||
await expect(promise).resolves.toEqual([{ name: "a" }])
|
||||
})
|
||||
|
||||
it("ok:false 면 에러 메시지로 reject 됨", async () => {
|
||||
const wv = mockWebview()
|
||||
const promise = request("snippets.delete", { name: "x" })
|
||||
const sent = wv.postMessage.mock.calls[0][0] as { reqId: string }
|
||||
|
||||
wv.emit({ type: "snippets.result", reqId: sent.reqId, ok: false, error: "중복된 이름" })
|
||||
|
||||
await expect(promise).rejects.toThrow("중복된 이름")
|
||||
})
|
||||
|
||||
it("reqId가 다른 응답은 무시하고 상관없는 type도 무시함", async () => {
|
||||
const wv = mockWebview()
|
||||
const promise = request("snippets.list")
|
||||
const sent = wv.postMessage.mock.calls[0][0] as { reqId: string }
|
||||
|
||||
wv.emit({ type: "navigate", path: "/snippet" }) // 다른 리스너 몫 — 무시돼야 함
|
||||
wv.emit({ type: "snippets.result", reqId: "다른reqId", ok: true, data: [] }) // 매칭 안 됨 — 무시
|
||||
wv.emit({ type: "snippets.result", reqId: sent.reqId, ok: true, data: "ok" })
|
||||
|
||||
await expect(promise).resolves.toBe("ok")
|
||||
})
|
||||
|
||||
it("웹뷰 밖(순수 브라우저)이면 hang 없이 reject 됨", async () => {
|
||||
await expect(request("snippets.list")).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
// 소비자 import 경로는 유지하고 호스트 선택·왕복 처리는 transport 한 곳에서 맡음.
|
||||
export { request } from "./transport"
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks"
|
||||
import { hostKind, listen, request, send } from "./transport"
|
||||
import { toast } from "sonner"
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }))
|
||||
|
||||
type WebViewListener = (event: MessageEvent) => void
|
||||
|
||||
interface HostWindow extends Window {
|
||||
chrome?: {
|
||||
webview: {
|
||||
postMessage: (message: unknown) => void
|
||||
addEventListener: (type: string, callback: WebViewListener) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jsdom Window에 데스크톱 호스트가 런타임 주입하는 필드만 보탠 테스트 경계임.
|
||||
const hostWindow = window as HostWindow
|
||||
|
||||
function mockWebView2() {
|
||||
const postMessage = vi.fn<(message: unknown) => void>()
|
||||
let listener: WebViewListener | undefined
|
||||
hostWindow.chrome = {
|
||||
webview: {
|
||||
postMessage,
|
||||
addEventListener: (type, callback) => {
|
||||
if (type === "message") listener = callback
|
||||
},
|
||||
},
|
||||
}
|
||||
return {
|
||||
postMessage,
|
||||
emit: (data: unknown) => listener?.({ data } as MessageEvent),
|
||||
}
|
||||
}
|
||||
|
||||
describe("bridge transport", () => {
|
||||
afterEach(() => {
|
||||
clearMocks()
|
||||
delete hostWindow.chrome
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("Tauri invoke가 있으면 WebView2보다 먼저 고른다", () => {
|
||||
mockWebView2()
|
||||
mockIPC(() => undefined)
|
||||
|
||||
expect(hostKind()).toBe("tauri")
|
||||
})
|
||||
|
||||
it("chrome.webview만 있으면 WebView2를 고른다", () => {
|
||||
mockWebView2()
|
||||
|
||||
expect(hostKind()).toBe("webview2")
|
||||
})
|
||||
|
||||
it("데스크톱 통로가 없으면 browser를 고른다", () => {
|
||||
expect(hostKind()).toBe("browser")
|
||||
})
|
||||
|
||||
it("browser 단방향 전송은 no-op이고 응답 요청은 바로 거부한다", async () => {
|
||||
expect(send({ type: "window.hide" })).toBe(false)
|
||||
await expect(request("snippets.list")).rejects.toThrow("데스크톱 전용")
|
||||
})
|
||||
|
||||
it("Tauri 메시지 이름을 command 이름으로 바꾸고 payload를 넘긴다", async () => {
|
||||
const calls: Array<{ command: string; payload?: unknown }> = []
|
||||
mockIPC((command, payload) => {
|
||||
calls.push({ command, payload })
|
||||
return { name: "FOO", usageCount: 1, lastUsed: 1 }
|
||||
})
|
||||
|
||||
await request("snippets.recordUse", { name: "FOO" })
|
||||
send({ type: "route.changed", path: "/snap" })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(calls).toContainEqual({ command: "snippets_record_use", payload: { name: "FOO" } })
|
||||
expect(calls).toContainEqual({ command: "report_route", payload: { path: "/snap" } })
|
||||
})
|
||||
|
||||
it("Tauri invoke 오류 문자열을 Error로 올린다", async () => {
|
||||
mockIPC(() => Promise.reject("아직 안 됨"))
|
||||
|
||||
await expect(request("snippets.list")).rejects.toEqual(new Error("아직 안 됨"))
|
||||
})
|
||||
|
||||
it("Tauri 창 제어 실패는 사용자에게 오류를 표시한다", async () => {
|
||||
mockIPC(() => Promise.reject("창 숨김 실패"))
|
||||
|
||||
expect(send({ type: "window.hide" })).toBe(true)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith("데스크톱 요청 실패: 창 숨김 실패")
|
||||
)
|
||||
})
|
||||
|
||||
it("WebView2 요청은 reqId가 맞는 응답만 돌려준다", async () => {
|
||||
const webview = mockWebView2()
|
||||
const promise = request<{ name: string }>("snippets.create", {
|
||||
snippet: { name: "FOO" },
|
||||
})
|
||||
const sent = webview.postMessage.mock.calls[0]?.[0]
|
||||
if (!sent || typeof sent !== "object" || !("reqId" in sent) || typeof sent.reqId !== "string") {
|
||||
throw new Error("WebView2 요청에 reqId가 없음")
|
||||
}
|
||||
|
||||
webview.emit({ type: "snippets.result", reqId: "다른 값", ok: true, data: {} })
|
||||
webview.emit({
|
||||
type: "snippets.result",
|
||||
reqId: sent.reqId,
|
||||
ok: true,
|
||||
data: { name: "FOO" },
|
||||
})
|
||||
|
||||
await expect(promise).resolves.toEqual({ name: "FOO" })
|
||||
})
|
||||
|
||||
it("WebView2와 browser listener는 같은 payload 표면을 쓴다", () => {
|
||||
const webview = mockWebView2()
|
||||
const handler = vi.fn()
|
||||
listen(handler)
|
||||
|
||||
webview.emit({ type: "navigate", path: "/snippet" })
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ type: "navigate", path: "/snippet" })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { listen as listenTauri } from "@tauri-apps/api/event"
|
||||
import { z } from "zod"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export type HostKind = "tauri" | "webview2" | "browser"
|
||||
|
||||
export type HostMessage = { type: string } & Record<string, unknown>
|
||||
|
||||
type WebView2 = {
|
||||
postMessage: (message: unknown) => void
|
||||
addEventListener: (type: "message", callback: (event: MessageEvent<unknown>) => void) => void
|
||||
}
|
||||
|
||||
interface HostWindow extends Window {
|
||||
__TAURI_INTERNALS__?: { invoke?: unknown }
|
||||
chrome?: { webview?: WebView2 }
|
||||
}
|
||||
|
||||
const BridgeMessageSchema = z
|
||||
.object({
|
||||
type: z.string(),
|
||||
path: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
app: z.string().optional(),
|
||||
dataUrl: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
export type BridgeMessage = z.infer<typeof BridgeMessageSchema>
|
||||
type HostListener = (message: BridgeMessage) => void
|
||||
const SnippetsResultSchema = z
|
||||
.object({
|
||||
type: z.literal("snippets.result"),
|
||||
reqId: z.string(),
|
||||
ok: z.boolean(),
|
||||
data: z.unknown().optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
// Tauri와 WebView2가 런타임 주입하는 필드만 보탠 경계 타입임.
|
||||
const hostWindow = window as HostWindow
|
||||
|
||||
let requestSequence = 0
|
||||
const pendingRequests = new Map<
|
||||
string,
|
||||
{ resolve: (data: unknown) => void; reject: (error: Error) => void }
|
||||
>()
|
||||
let requestListenerTarget: WebView2 | undefined
|
||||
const tauriHandlers = new Set<HostListener>()
|
||||
const webView2Handlers = new WeakMap<WebView2, Set<HostListener>>()
|
||||
let tauriListenerStarted = false
|
||||
|
||||
export function hostKind(): HostKind {
|
||||
if (typeof hostWindow.__TAURI_INTERNALS__?.invoke === "function") return "tauri"
|
||||
if (hostWindow.chrome?.webview) return "webview2"
|
||||
return "browser"
|
||||
}
|
||||
|
||||
function webView2(): WebView2 | undefined {
|
||||
return hostWindow.chrome?.webview
|
||||
}
|
||||
|
||||
function commandName(type: string): string {
|
||||
if (type === "route.changed") return "report_route"
|
||||
return type.replaceAll(".", "_").replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
|
||||
}
|
||||
|
||||
function commandPayload(message: HostMessage): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = { ...message }
|
||||
delete payload.type
|
||||
return payload
|
||||
}
|
||||
|
||||
function toError(error: unknown): Error {
|
||||
if (error instanceof Error) return error
|
||||
if (typeof error === "string") return new Error(error)
|
||||
return new Error("데스크톱 요청에 실패함")
|
||||
}
|
||||
|
||||
function ensureWebView2RequestListener(webview: WebView2) {
|
||||
if (requestListenerTarget === webview) return
|
||||
requestListenerTarget = webview
|
||||
webview.addEventListener("message", (event) => {
|
||||
const parsed = SnippetsResultSchema.safeParse(event.data)
|
||||
if (!parsed.success) return
|
||||
|
||||
const entry = pendingRequests.get(parsed.data.reqId)
|
||||
if (!entry) return
|
||||
pendingRequests.delete(parsed.data.reqId)
|
||||
if (parsed.data.ok) entry.resolve(parsed.data.data)
|
||||
else entry.reject(new Error(parsed.data.error ?? "알 수 없는 오류"))
|
||||
})
|
||||
}
|
||||
|
||||
export function send(message: HostMessage): boolean {
|
||||
const kind = hostKind()
|
||||
if (kind === "tauri") {
|
||||
void invoke(commandName(message.type), commandPayload(message)).catch((error) => {
|
||||
toast.error(`데스크톱 요청 실패: ${toError(error).message}`)
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (kind === "webview2") {
|
||||
webView2()?.postMessage(message)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function request<T = unknown>(
|
||||
type: string,
|
||||
payload: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
const kind = hostKind()
|
||||
if (kind === "tauri") {
|
||||
try {
|
||||
return await invoke<T>(commandName(type), payload)
|
||||
} catch (error) {
|
||||
throw toError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const webview = webView2()
|
||||
if (kind === "webview2" && webview) {
|
||||
ensureWebView2RequestListener(webview)
|
||||
const reqId = String(++requestSequence)
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
pendingRequests.set(reqId, {
|
||||
resolve: resolve as (data: unknown) => void,
|
||||
reject,
|
||||
})
|
||||
webview.postMessage({ type, reqId, ...payload })
|
||||
})
|
||||
}
|
||||
|
||||
throw new Error("desktop-only: 데스크톱 전용 기능임")
|
||||
}
|
||||
|
||||
export function listen(handler: HostListener): void {
|
||||
const kind = hostKind()
|
||||
if (kind === "tauri") {
|
||||
tauriHandlers.add(handler)
|
||||
if (tauriListenerStarted) return
|
||||
tauriListenerStarted = true
|
||||
void listenTauri<unknown>("bridge", (event) => {
|
||||
const parsed = BridgeMessageSchema.safeParse(event.payload)
|
||||
if (!parsed.success) return
|
||||
for (const currentHandler of tauriHandlers) currentHandler(parsed.data)
|
||||
}).catch((error) => {
|
||||
tauriListenerStarted = false
|
||||
toast.error(`단축키 화면 이동 연결 실패: ${toError(error).message}`)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (kind !== "webview2") return
|
||||
const webview = webView2()
|
||||
if (!webview) return
|
||||
|
||||
let handlers = webView2Handlers.get(webview)
|
||||
if (!handlers) {
|
||||
handlers = new Set()
|
||||
webView2Handlers.set(webview, handlers)
|
||||
webview.addEventListener("message", (event) => {
|
||||
const parsed = BridgeMessageSchema.safeParse(event.data)
|
||||
if (!parsed.success) return
|
||||
for (const currentHandler of webView2Handlers.get(webview) ?? []) {
|
||||
currentHandler(parsed.data)
|
||||
}
|
||||
})
|
||||
}
|
||||
handlers.add(handler)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { hostKind, send } from "./transport"
|
||||
|
||||
/** 데스크톱 호스트 안에서 실행 중이면 true. 기존 소비자 이름은 유지함. */
|
||||
export function isWebView(): boolean {
|
||||
return hostKind() !== "browser"
|
||||
}
|
||||
|
||||
/** 데스크톱 창 숨김(트레이로 내림). */
|
||||
export const hideWindow = () => send({ type: "window.hide" })
|
||||
|
||||
/** 코드를 소환 직전 앱에 붙여넣음. */
|
||||
export const pasteToApp = (text: string) => send({ type: "paste.code", text })
|
||||
|
||||
/** 현재 React route를 데스크톱 호스트에 알림. */
|
||||
export const reportRoute = (path: string) => send({ type: "route.changed", path })
|
||||
|
||||
/** 프레임리스 창을 native 창 이동으로 끌 수 있게 함. */
|
||||
export const startWindowDrag = () => send({ type: "window.drag" })
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function useDebounce<T>(value: T, delay = 300): T {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(value), delay)
|
||||
return () => clearTimeout(t)
|
||||
}, [value, delay])
|
||||
|
||||
return debounced
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { QueryClient } from "@tanstack/react-query"
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: (failureCount, error: unknown) => {
|
||||
// 401·403·404는 재시도 안 함
|
||||
const status = (error as { status?: number })?.status
|
||||
if (status && [401, 403, 404].includes(status)) return false
|
||||
return failureCount < 2
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,447 @@
|
||||
# `lib/streaming` — LLM 스트리밍 풀스택 템플릿
|
||||
|
||||
LLM SSE 스트리밍을 **백엔드 contract → 프론트 어댑터 → typewriter 렌더링 → 중단 버튼** 한 묶음으로 묶은 재사용 모듈. 폴더 통째 복사하면 다음 프로젝트에 그대로 이식 가능.
|
||||
|
||||
## 한눈에
|
||||
|
||||
```tsx
|
||||
import {
|
||||
streamLLM, // 표준 event 어댑터
|
||||
StreamingText, // typewriter UI
|
||||
useStreamSession, // stop 버튼용 hook
|
||||
} from "@/lib/streaming"
|
||||
|
||||
function ChatBox() {
|
||||
const { run, stop, isRunning } = useStreamSession()
|
||||
const [text, setText] = useState("")
|
||||
|
||||
const submit = (q: string) => {
|
||||
setText("")
|
||||
void run((signal) =>
|
||||
streamLLM({
|
||||
path: "/chat/stream",
|
||||
body: { messages: [{ role: "user", content: q }] },
|
||||
signal,
|
||||
handlers: {
|
||||
onToken: (delta) => setText((t) => t + delta),
|
||||
onDone: () => {},
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StreamingText text={text} isStreaming={isRunning} />
|
||||
{isRunning ? (
|
||||
<button onClick={stop}>중단</button>
|
||||
) : (
|
||||
<button onClick={() => submit(input)}>보내기</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
이게 다임. 아래는 모듈별 자세한 설명.
|
||||
|
||||
---
|
||||
|
||||
## 패턴 모음 — 다음 프로젝트에 그대로 베껴 쓰기
|
||||
|
||||
### 패턴 A — 단일 응답 (한 번에 한 텍스트)
|
||||
|
||||
위 "한눈에" 코드. `useState<string>("")`에 토큰 누적, `<StreamingText text={...} isStreaming={isRunning} />`. 가장 단순.
|
||||
|
||||
### 패턴 B — 메시지 이력 chat (user/assistant 누적)
|
||||
|
||||
채팅창처럼 메시지가 쌓이는데, **진행 중인 마지막 assistant 메시지에만 typewriter** 적용해야 함. 과거 메시지는 mount될 때 즉시 다 보여야 함.
|
||||
|
||||
핵심 트릭 — store에는 메시지별 `isStreaming` 플래그를 안 두고, **전역 `isStreaming` 하나만** 두고 List에서 마지막 메시지에만 prop으로 넘김:
|
||||
|
||||
```ts
|
||||
// store
|
||||
interface ChatState {
|
||||
messages: Array<{ id: string; role: "user" | "assistant"; content: string }>
|
||||
isStreaming: boolean // 전역 하나
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// MessageList.tsx
|
||||
const { messages, isStreaming } = useChatStore()
|
||||
const lastIdx = messages.length - 1
|
||||
const lastIsStreaming = isStreaming && lastIdx >= 0 && messages[lastIdx].role === "assistant"
|
||||
|
||||
return messages.map((m, i) => (
|
||||
<MessageBubble key={m.id} message={m} isStreaming={lastIsStreaming && i === lastIdx} />
|
||||
))
|
||||
```
|
||||
|
||||
```tsx
|
||||
// MessageBubble.tsx — assistant 분기
|
||||
<StreamingText text={message.content} isStreaming={isStreaming} />
|
||||
```
|
||||
|
||||
`useSmoothedText`는 `active=false`로 mount되면 `useState` 초기값으로 즉시 full 텍스트 표시 — 과거 메시지가 다시 타이핑되는 일 없음. 이 동작에 의존해서 per-message 플래그 없이 깔끔히 분리됨.
|
||||
|
||||
이 프로젝트의 `2_frontend/src/features/chat/` 가 이 패턴 그대로.
|
||||
|
||||
### 패턴 C — 사전 결과 + LLM 요약 (result event 사용)
|
||||
|
||||
조회 결과(표 등) 먼저 보여주고 그 위에 LLM이 요약 다는 케이스. 백엔드는 `result` event 한 번 + `token` event N번. 프론트는 `onResult`/`onToken` 둘 다 핸들링.
|
||||
|
||||
```ts
|
||||
await streamLLM<{ items: Foo[] }>({
|
||||
path: "/sap/lookup/stream",
|
||||
body: { tableName },
|
||||
signal,
|
||||
handlers: {
|
||||
onResult: ({ items }) => store.setItems(items), // 표 데이터
|
||||
onToken: (delta) => store.appendSummary(delta), // 요약 누적
|
||||
onDone: () => store.finish(),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
UI 렌더 순서: 결과표 → `<StreamingText text={summary} isStreaming={...} />`.
|
||||
|
||||
### 어느 패턴 쓰지
|
||||
|
||||
| 케이스 | 패턴 |
|
||||
| -------------------------------------- | ---- |
|
||||
| 검색창 한 번 누르면 LLM 답변 한 덩어리 | A |
|
||||
| ChatGPT 같은 대화창 (메시지 이력) | B |
|
||||
| 도구 결과 + LLM 코멘트 | C |
|
||||
|
||||
세 패턴 모두 **같은 backend contract**(`event: result/token/done/error`)를 씀. 백엔드가 보내는 event 종류만 다름:
|
||||
|
||||
- A/B: `token` × N + `done`
|
||||
- C: `result` × 1 + `token` × N + `done`
|
||||
|
||||
---
|
||||
|
||||
## 1. typewriter 렌더링 — `StreamingText` / `useSmoothedText`
|
||||
|
||||
LLM 토큰은 네트워크 청크에 묶여 들쭉날쭉 도착함. `setText(prev + delta)` 식으로 그대로 그리면 뭉텅이로 한 번에 훅 뿌려져서 어색함. 이 모듈은:
|
||||
|
||||
- 받은 텍스트를 **버퍼**에 두고 화면엔 일정 페이스로 한 글자씩 흘림 (typewriter)
|
||||
- 토큰이 뭉텅이로 도착하면 살짝 **가속**해서 따라잡음 (한 글자씩은 유지)
|
||||
- 백엔드가 `done` 보내도 **받은 만큼은 마저 다 찍은 뒤** typewriter 종료
|
||||
- 흘러나온 텍스트는 **react-markdown + remark-gfm** 으로 렌더 — `**굵게**`, 리스트, 표, 코드블록, 링크, 체크리스트 등 GFM 전부 지원
|
||||
- Tailwind 클래스는 `StreamingText.tsx` 의 `MD_COMPONENTS` 매핑으로 주입 — 챗 버블 톤에 맞게 단순한 스타일만 입힘
|
||||
|
||||
### 빠르게 쓰기
|
||||
|
||||
```tsx
|
||||
<StreamingText
|
||||
text={message.summary} // 누적 텍스트 (토큰 누적값)
|
||||
isStreaming={message.isStreaming}
|
||||
/>
|
||||
```
|
||||
|
||||
### 페이스 튜닝
|
||||
|
||||
기본값(`baseCps: 8` ≈ 125ms당 1글자)이 사람 편한 속도. 빠르게 하고 싶으면:
|
||||
|
||||
```tsx
|
||||
<StreamingText text={...} isStreaming={...} cps={{ baseCps: 16, maxCps: 80 }} />
|
||||
```
|
||||
|
||||
| `baseCps` | 1글자 간격 | 느낌 |
|
||||
| --------- | ---------- | ------------- |
|
||||
| 6 | 167ms | 매우 또박또박 |
|
||||
| 8 | 125ms | 기본 — 편함 |
|
||||
| 12 | 83ms | 살짝 빠릿 |
|
||||
| 20 | 50ms | ChatGPT 비슷 |
|
||||
| 30+ | 33ms 이하 | 빠름 |
|
||||
|
||||
### hook 단독 사용
|
||||
|
||||
자체 렌더러 짤 때:
|
||||
|
||||
```tsx
|
||||
import { useSmoothedText, Caret } from "@/lib/streaming"
|
||||
|
||||
function MyBubble({ text, streaming }) {
|
||||
const { text: shown, revealing } = useSmoothedText(text, streaming)
|
||||
return (
|
||||
<p>
|
||||
{shown}
|
||||
{revealing && <Caret />}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 마크다운 렌더링 커스터마이즈
|
||||
|
||||
태그별 스타일은 `StreamingText.tsx` 의 `MD_COMPONENTS` 객체에서 직접 수정. 예: 링크에 다른 색 입히고 싶으면 `a` 키를 고치면 됨. 새 태그가 필요하면 그 키를 추가.
|
||||
|
||||
### 핵심 트릭 — 왜 부드럽나
|
||||
|
||||
소수점 accumulator 패턴:
|
||||
|
||||
```ts
|
||||
accumulator += (cps * dt) / 1000 // 매 프레임 누적
|
||||
const reveal = Math.floor(accumulator)
|
||||
accumulator -= reveal
|
||||
```
|
||||
|
||||
`Math.max(1, Math.round(...))` 같이 **억지로 매 프레임 1글자** 강제하면 BASE_CPS 설정값과 무관하게 60 cps로 돌아감(들쭉날쭉). accumulator 패턴은 진짜로 일정 간격 유지됨.
|
||||
|
||||
또 하나: 스트리밍 끝나도(`isStreaming: false`) hook은 그걸 안 봄. RAF는 **`displayed.length >= full.length`** 만 보고 돌아서, 받은 글자 다 찍어야 비로소 멈춤. 그래서 끝에서 훅 뿌리는 일이 없음.
|
||||
|
||||
---
|
||||
|
||||
## 2. SSE 어댑터 — `streamLLM`
|
||||
|
||||
표준 event contract(`event: result/token/done/error`)를 타입 있는 핸들러로 매핑. 매번 `JSON.parse(e.data)` + `if (e.event === ...)` 보일러플레이트 안 짜도 됨.
|
||||
|
||||
### 사용 예
|
||||
|
||||
```ts
|
||||
import { streamLLM } from "@/lib/streaming"
|
||||
|
||||
await streamLLM<{ items: Foo[] }>({
|
||||
path: "/chat/sap/cds-view-finder/stream",
|
||||
body: { tableName: "MARA" },
|
||||
signal: ctrl.signal,
|
||||
handlers: {
|
||||
onResult: ({ items }) => store.setItems(items),
|
||||
onToken: (delta) => store.appendToken(delta),
|
||||
onDone: () => store.finish(),
|
||||
onError: (e) => store.fail(e.message),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Event contract (백엔드 ↔ 프론트 약속)
|
||||
|
||||
| event | data 모양 | 횟수 | 의미 |
|
||||
| -------- | -------------------------------- | ---- | ------------------------------- |
|
||||
| `result` | 임의의 JSON 객체 | 0~1 | 토큰 시작 전 사전 데이터 (선택) |
|
||||
| `token` | `{"delta":"..."}` | 0~N | 토큰 한 조각 |
|
||||
| `done` | `{}` | 1 | 정상 종료 |
|
||||
| `error` | `{"message":"...","code":"..."}` | 0~1 | 오류 종료 |
|
||||
|
||||
- `result`는 옵셔널 — 단순 챗 스트림은 token만 보내도 됨
|
||||
- malformed JSON 한 토큰은 무시하고 다음으로 진행 (회복 친화)
|
||||
- 알 수 없는 event 타입은 무시 (확장 친화)
|
||||
|
||||
### 저수준 — `streamSSE`
|
||||
|
||||
generic SSE가 필요하면 (다른 contract):
|
||||
|
||||
```ts
|
||||
import { streamSSE } from "@/lib/streaming"
|
||||
|
||||
await streamSSE({
|
||||
path: "/some/sse",
|
||||
body: {...},
|
||||
onEvent: (e) => console.log(e.event, e.data),
|
||||
})
|
||||
```
|
||||
|
||||
`@microsoft/fetch-event-source` 위에 401 자동 refresh + abort 지원만 추가한 얇은 wrapper.
|
||||
|
||||
---
|
||||
|
||||
## 3. AbortController 통합 — `useStreamSession` / `isAbortError`
|
||||
|
||||
스트리밍 중간에 사용자가 "중단" 누를 수 있어야 함. `AbortController` 표준 + 이 모듈의 헬퍼로 깔끔히 처리.
|
||||
|
||||
### 컴포넌트 로컬 — `useStreamSession` hook
|
||||
|
||||
가장 흔한 케이스. controller 생성·관리·언마운트 시 자동 abort까지 다 해줌:
|
||||
|
||||
```tsx
|
||||
const { run, stop, isRunning } = useStreamSession()
|
||||
|
||||
const submit = (q: string) =>
|
||||
void run(async (signal) => {
|
||||
await streamLLM({ path: "...", body: {...}, signal, handlers: {...} })
|
||||
})
|
||||
|
||||
return isRunning ? (
|
||||
<button onClick={stop}>중단</button>
|
||||
) : (
|
||||
<button onClick={() => submit(input)}>보내기</button>
|
||||
)
|
||||
```
|
||||
|
||||
- 새 `run()` 호출 시 이전 진행 중인 스트림 자동 취소 (사용자가 새 쿼리 보낸 경우)
|
||||
- 컴포넌트 언마운트 시 진행 중인 스트림 자동 abort (메모리/네트워크 누수 방지)
|
||||
- `run()` 반환값: `{ ok: true, value }` 또는 `{ ok: false, aborted, error }` — abort vs 진짜 에러 구분
|
||||
|
||||
### 글로벌 store 패턴 (zustand 등)
|
||||
|
||||
store에 currentController를 들고 다니는 경우. 이 모듈은 `isAbortError(e)` 헬퍼만 제공하고 store 구조는 직접 짜는 게 자연스러움:
|
||||
|
||||
```ts
|
||||
import { isAbortError } from "@/lib/streaming"
|
||||
|
||||
interface State {
|
||||
currentController: AbortController | null
|
||||
ask: (q: string) => Promise<void>
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
const useStore = create<State>((set, get) => ({
|
||||
currentController: null,
|
||||
ask: async (q) => {
|
||||
const ctrl = new AbortController()
|
||||
set({ currentController: ctrl })
|
||||
try {
|
||||
await streamLLM({ path: "...", body: { q }, signal: ctrl.signal, handlers: {...} })
|
||||
} catch (e) {
|
||||
// abort는 정상 종료처럼 처리 (에러 버블 만들지 않음)
|
||||
if (ctrl.signal.aborted || isAbortError(e)) return
|
||||
// 진짜 에러
|
||||
throw e
|
||||
} finally {
|
||||
if (get().currentController === ctrl) set({ currentController: null })
|
||||
}
|
||||
},
|
||||
stop: () => {
|
||||
get().currentController?.abort()
|
||||
set({ currentController: null })
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
핵심 포인트:
|
||||
|
||||
- abort는 **에러가 아님** — `signal.aborted || isAbortError(e)` 감지해서 catch에서 silently return
|
||||
- finally에서 controller가 여전히 "내 거"인지 확인 후 null화 (race condition 방지)
|
||||
- stop()은 abort + state 정리 한꺼번에
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드 SSE 어댑터 (Python · FastAPI · langchain)
|
||||
|
||||
프론트 contract와 짝이 되는 Python 헬퍼. 다음 프로젝트에 옮길 때 같이 복사.
|
||||
|
||||
### 이벤트 빌더 (`services/llm/sse.py`)
|
||||
|
||||
```python
|
||||
"""SSE 이벤트 직렬화 헬퍼.
|
||||
|
||||
sse_starlette.EventSourceResponse가 받는 dict({"event": str, "data": str})로 빌드.
|
||||
data는 항상 JSON 문자열. 한글은 ensure_ascii=False로 보존.
|
||||
"""
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _encode(payload: dict[str, Any]) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def sse_token(delta: str) -> dict[str, str]:
|
||||
"""LLM 토큰 한 조각."""
|
||||
return {"event": "token", "data": _encode({"delta": delta})}
|
||||
|
||||
|
||||
def sse_result(payload: dict[str, Any]) -> dict[str, str]:
|
||||
"""토큰 스트림 직전에 한 번 보내는 사전 데이터 (선택)."""
|
||||
return {"event": "result", "data": _encode(payload)}
|
||||
|
||||
|
||||
def sse_done() -> dict[str, str]:
|
||||
"""정상 종료 시그널."""
|
||||
return {"event": "done", "data": "{}"}
|
||||
|
||||
|
||||
def sse_error(message: str, code: str | None = None) -> dict[str, str]:
|
||||
"""오류 종료 시그널."""
|
||||
return {"event": "error", "data": _encode({"message": message, "code": code})}
|
||||
```
|
||||
|
||||
### langchain `astream_events` → 토큰 yield
|
||||
|
||||
```python
|
||||
"""langchain Runnable의 astream_events(version="v2")에서 chat 모델 stream chunk만 추출."""
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
async def stream_chat_tokens(chain, input: dict) -> AsyncIterator[str]:
|
||||
async for event in chain.astream_events(input, version="v2"):
|
||||
if event["event"] != "on_chat_model_stream":
|
||||
continue
|
||||
chunk = event["data"].get("chunk")
|
||||
if chunk is None:
|
||||
continue
|
||||
content = getattr(chunk, "content", "")
|
||||
if content:
|
||||
yield content
|
||||
```
|
||||
|
||||
### FastAPI 라우트 예시
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/chat/stream")
|
||||
async def chat_stream(req: ChatRequest):
|
||||
async def gen():
|
||||
try:
|
||||
chain = build_chain(req.system_prompt, req.messages)
|
||||
async for delta in stream_chat_tokens(chain, {}):
|
||||
yield sse_token(delta)
|
||||
yield sse_done()
|
||||
except Exception as e:
|
||||
yield sse_error(str(e), code=type(e).__name__)
|
||||
|
||||
return EventSourceResponse(gen())
|
||||
```
|
||||
|
||||
`result` 이벤트가 필요한 경우(예: SAP 조회 결과 + LLM 요약):
|
||||
|
||||
```python
|
||||
async def gen():
|
||||
items = await fetch_items(req.table_name)
|
||||
yield sse_result({"items": [it.model_dump() for it in items]})
|
||||
chain = build_summary_chain(items, req.table_name)
|
||||
async for delta in stream_chat_tokens(chain, {}):
|
||||
yield sse_token(delta)
|
||||
yield sse_done()
|
||||
```
|
||||
|
||||
### 의존성
|
||||
|
||||
```toml
|
||||
# pyproject.toml
|
||||
sse-starlette = "^2.0"
|
||||
langchain-openai = "^0.2"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다음 프로젝트 이식 체크리스트
|
||||
|
||||
1. **프론트**: `2_frontend/src/lib/streaming/` 폴더 통째 복사
|
||||
- import alias `@/lib/streaming` 또는 상대 경로 맞춤
|
||||
- React 18+, Tailwind 3+ (`animate-pulse`, `list-disc`, `text-muted-foreground` 등)
|
||||
- `npm i @microsoft/fetch-event-source react-markdown remark-gfm` 한 줄
|
||||
- `streamSSE`가 `env.apiBaseUrl`과 `/auth/refresh` 엔드포인트를 가정 — 프로젝트에 맞게 수정 또는 그대로 사용
|
||||
2. **백엔드**: 위 §4 헬퍼 3개를 `services/llm/sse.py`로 복사
|
||||
- `sse-starlette`, `langchain-openai` 설치
|
||||
- 라우트는 프로젝트 컨벤션 따름
|
||||
3. **contract**: `event: result/token/done/error` 표준 유지 — 양쪽 다 이 모듈을 쓰면 자동으로 호환
|
||||
|
||||
## 외부 의존성
|
||||
|
||||
| 모듈 | 의존 |
|
||||
| ---------------------------------- | ------------------------------------------------------ |
|
||||
| `useSmoothedText` | React 18+ |
|
||||
| `StreamingText` | React 18+, Tailwind 3+, `react-markdown`, `remark-gfm` |
|
||||
| `streamSSE`, `streamLLM` | `@microsoft/fetch-event-source`, `@/config/env` |
|
||||
| `useStreamSession`, `isAbortError` | React 18+ (hook), 없음 (helper) |
|
||||
|
||||
`useSmoothedText`만 따로 쓰려면 React만 있으면 됨 — 마크다운 렌더가 필요 없는 곳에선 typewriter 결과 텍스트를 직접 그려도 됨.
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { StoppedNotice } from "./StoppedNotice"
|
||||
|
||||
describe("StoppedNotice", () => {
|
||||
it("기본 메시지 + 재시도 버튼 렌더", () => {
|
||||
render(<StoppedNotice onRetry={() => {}} />)
|
||||
expect(screen.getByText(/응답이 중단되었습니다./)).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: /재시도/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("재시도 클릭 시 onRetry 호출", async () => {
|
||||
const onRetry = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<StoppedNotice onRetry={onRetry} />)
|
||||
await user.click(screen.getByRole("button", { name: /재시도/ }))
|
||||
expect(onRetry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("disabled=true: 재시도 버튼 비활성", () => {
|
||||
render(<StoppedNotice onRetry={() => {}} disabled />)
|
||||
expect(screen.getByRole("button", { name: /재시도/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it("onRetry 미전달: 재시도 버튼 미렌더, 안내 텍스트만", () => {
|
||||
render(<StoppedNotice />)
|
||||
expect(screen.getByText(/응답이 중단되었습니다./)).toBeInTheDocument()
|
||||
expect(screen.queryByRole("button", { name: /재시도/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("커스텀 message prop 노출", () => {
|
||||
render(<StoppedNotice message="요청을 멈췄어요." />)
|
||||
expect(screen.getByText(/요청을 멈췄어요./)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/응답이 중단되었습니다./)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Info } from "lucide-react"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
export interface StoppedNoticeProps {
|
||||
/** 재시도 클릭 콜백. 없으면 재시도 버튼 자체가 렌더되지 않음. */
|
||||
onRetry?: () => void
|
||||
/** 다른 요청이 진행 중일 때 등 재시도를 막아야 할 때 true. */
|
||||
disabled?: boolean
|
||||
/** 안내 문구 커스터마이즈. 기본 "응답이 중단되었습니다.". */
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function StoppedNotice({ onRetry, disabled, message }: StoppedNoticeProps) {
|
||||
return (
|
||||
<div className="bg-muted/40 text-muted-foreground flex w-full max-w-[90%] items-center justify-between gap-3 rounded-lg border px-3 py-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="size-4 shrink-0" aria-hidden />
|
||||
<span>{message ?? "응답이 중단되었습니다."}</span>
|
||||
</div>
|
||||
{onRetry && (
|
||||
<Button type="button" variant="outline" size="sm" disabled={disabled} onClick={onRetry}>
|
||||
재시도
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useMemo, useRef } from "react"
|
||||
import Markdown, { type Components } from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { useSmoothedText, type SmoothedTextOptions } from "./useSmoothedText"
|
||||
|
||||
/* ---------- 마크다운 렌더러 ---------- */
|
||||
|
||||
// react-markdown 의 components prop — 각 HTML 태그에 Tailwind 클래스를 입혀
|
||||
// chat 버블 디자인과 톤을 맞춤. GFM(테이블·취소선·체크리스트)도 함께 처리.
|
||||
const MD_COMPONENTS: Components = {
|
||||
p: ({ children }) => <p className="whitespace-pre-wrap">{children}</p>,
|
||||
ul: ({ children }) => (
|
||||
<ul className="marker:text-muted-foreground list-disc space-y-1 pl-5">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="marker:text-muted-foreground list-decimal space-y-1 pl-5">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li>{children}</li>,
|
||||
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
del: ({ children }) => <del className="line-through opacity-70">{children}</del>,
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2 hover:no-underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// 인라인 code 와 블록 code 모두 통과. pre 가 따로 래핑하니까 여기선 항상 인라인 스타일.
|
||||
// 블록 안 code 는 pre 의 폰트/배경을 상속받게 className 만 보존.
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = /language-(\w+)/.test(className ?? "")
|
||||
if (isBlock) {
|
||||
return <code className={className}>{children}</code>
|
||||
}
|
||||
return (
|
||||
<code className="bg-foreground/10 rounded px-1 py-0.5 font-mono text-[0.9em]">
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-foreground/5 overflow-x-auto rounded-md p-3 font-mono text-xs leading-relaxed">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-muted-foreground/40 text-muted-foreground border-l-2 pl-3 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
h1: ({ children }) => <h1 className="text-base font-semibold">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-sm font-semibold">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-semibold">{children}</h3>,
|
||||
hr: () => <hr className="border-foreground/10" />,
|
||||
// GFM 테이블
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => <thead className="border-foreground/20 border-b">{children}</thead>,
|
||||
th: ({ children }) => <th className="px-2 py-1 text-left font-semibold">{children}</th>,
|
||||
td: ({ children }) => <td className="border-foreground/10 border-t px-2 py-1">{children}</td>,
|
||||
}
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm]
|
||||
|
||||
/* ---------- 컴포넌트 ---------- */
|
||||
|
||||
export interface StreamingTextProps {
|
||||
/** 누적된 전체 텍스트 (LLM이 보낸 만큼) */
|
||||
text: string
|
||||
/** 스트림 진행 중 여부 — typewriter 페이스 + 캐럿 표시 결정 */
|
||||
isStreaming: boolean
|
||||
/** typewriter 페이스 튜닝 (선택) */
|
||||
cps?: SmoothedTextOptions
|
||||
/** true면 typewriter 건너뛰고 받은 토큰을 즉시 노출 (가장 빠른 출력). */
|
||||
instant?: boolean
|
||||
/** 컨테이너 className 오버라이드 */
|
||||
className?: string
|
||||
/** true면 typewriter를 그 자리에 동결 — 더 안 풀고 full로도 안 스냅. STOP 후 "딱 여기서 멈춤" 용도. */
|
||||
frozen?: boolean
|
||||
/** typewriter가 다 풀렸거나 frozen으로 멈췄을 때 한 번 호출. 호출처에서 isLoading 종료에 씀. */
|
||||
onRevealEnd?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM 응답을 typewriter로 한 글자씩 흘리며, react-markdown 으로 렌더.
|
||||
*
|
||||
* GFM(테이블·취소선·체크리스트) 지원. Tailwind 클래스는 MD_COMPONENTS 로 주입.
|
||||
*
|
||||
* 사용 예:
|
||||
* ```tsx
|
||||
* <StreamingText text={message.summary} isStreaming={message.isStreaming} />
|
||||
* ```
|
||||
*/
|
||||
// instant 모드용 cps — 한 프레임에 전체를 풀어버릴 만큼 크게. reveal/캐럿/onRevealEnd
|
||||
// 로직은 useSmoothedText의 것을 그대로 재사용(분기 없이 단일 경로 유지)하고 속도만 사실상 즉시로 만듦.
|
||||
const INSTANT_CPS = 1e9
|
||||
|
||||
export function StreamingText({
|
||||
text,
|
||||
isStreaming,
|
||||
cps,
|
||||
instant = false,
|
||||
className = "space-y-2 text-sm leading-relaxed",
|
||||
frozen,
|
||||
onRevealEnd,
|
||||
}: StreamingTextProps) {
|
||||
const smoothOpts = useMemo(
|
||||
() => (instant ? { baseCps: INSTANT_CPS, maxCps: INSTANT_CPS, frozen } : { ...cps, frozen }),
|
||||
[instant, cps, frozen]
|
||||
)
|
||||
const { text: shown, revealing } = useSmoothedText(text, isStreaming, smoothOpts)
|
||||
|
||||
// revealing이 true → false로 떨어지는 단 한 순간에 콜백 호출.
|
||||
// 마운트 직후부터 revealing=false인 경우(과거 메시지)에도 한 번 통지 — 호출 측이 activeStreamId 같은 걸로
|
||||
// 가드해서 무관 메시지 신호를 무시하면 됨.
|
||||
const lastRevealingRef = useRef<boolean | null>(null)
|
||||
useEffect(() => {
|
||||
const prev = lastRevealingRef.current
|
||||
lastRevealingRef.current = revealing
|
||||
if (!revealing && prev !== false) {
|
||||
onRevealEnd?.()
|
||||
}
|
||||
}, [revealing, onRevealEnd])
|
||||
|
||||
if (shown.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Markdown remarkPlugins={REMARK_PLUGINS} components={MD_COMPONENTS}>
|
||||
{shown}
|
||||
</Markdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user