Initial Commit

This commit is contained in:
2026-09-16 17:22:14 +09:00
commit 858ee9e9da
335 changed files with 123898 additions and 0 deletions
@@ -0,0 +1,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>
)
}
+19
View File
@@ -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
}