Initial Commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user