feat(app): 활성 창 캡쳐·캡쳐 목적지 선택·단축키 Ctrl+Alt 재배치 + UX 손질

- Ctrl+Alt+A/Z 맨 앞 창 통째 캡쳐(DWM 확장 프레임 경계), S/X 드래그 캡쳐. A/S 새 대화, Z/X 마지막 대화
- 캡쳐 후 창을 확실히 앞으로(AttachThreadInput), capture.image 에 목적지 path 실어 그 Composer 만 소비
- 드래그 선택영역 원본 밝기·더블버퍼(번쩍임) — code-assistant-v2 복사본에서 이식
- 단축키 Ctrl+Alt+Q/W: Eclipse·ADT·SAP GUI 바인딩 목록 대조해 비어 있는 조합으로
- 버튼 cursor:pointer(Tailwind v4), 가운데 스피너, 스니펫→챗 버튼, 스니펫 검색창 높이 84→88

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-17 16:34:48 +09:00
co-authored by Claude Fable 5.1
parent 2869985139
commit 47d31dbe06
27 changed files with 607 additions and 142 deletions
@@ -1,3 +1,4 @@
import { MemoryRouter } from "react-router-dom"
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"
@@ -67,7 +68,11 @@ afterEach(() => {
describe("새 스니펫 단축키", () => {
it("Ctrl+N으로 생성창을 열고 기존 Ctrl+2는 무시함", () => {
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
fireEvent.keyDown(window, { key: "2", ctrlKey: true })
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
@@ -79,7 +84,11 @@ describe("새 스니펫 단축키", () => {
it("생성 성공 뒤 입력창을 1초 유지한 다음 기본 검색 화면으로 돌아감", () => {
vi.useFakeTimers()
createSnippet.mockImplementation((_input, options) => options.onSuccess())
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
const search = screen.getByRole("textbox")
fireEvent.change(search, { target: { value: "기존 검색" } })
fireEvent.keyDown(window, { key: "n", ctrlKey: true })
@@ -104,7 +113,11 @@ describe("스니펫 Enter 복사", () => {
})
)
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText)
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "B" } })
fireEvent.click(screen.getByText("B"))
fireEvent.keyDown(window, { key: "Enter" })
@@ -122,7 +135,11 @@ describe("스니펫 Enter 복사", () => {
it("복사 실패 시 창과 사용 기록을 유지하고 오류 표시", async () => {
const writeText = vi.fn().mockRejectedValue(new Error("denied"))
vi.spyOn(navigator.clipboard, "writeText").mockImplementation(writeText)
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "A" } })
fireEvent.keyDown(window, { key: "ArrowDown" })
fireEvent.keyDown(window, { key: "Enter", isComposing: true })
@@ -136,7 +153,11 @@ describe("스니펫 Enter 복사", () => {
it("하단 복사는 클릭과 Enter 모두 원문을 복사하고 팔레트를 열린 채로 둠", async () => {
const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "B" } })
await user.click(screen.getByRole("button", { name: "B" }))
const copy = screen.getByRole("button", { name: "복사" })
@@ -154,7 +175,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
it("선택한 원문을 붙여넣고 일반 복사를 실행하지 않으며 키 반복을 무시함", async () => {
const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
await user.type(screen.getByRole("textbox"), "B")
await user.click(screen.getByRole("button", { name: "B" }))
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter", ctrlKey: true })
@@ -169,7 +194,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
it("복사 버튼에 포커스가 있어도 Ctrl Enter는 붙여넣고 일반 Enter는 복사함", async () => {
const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
await user.type(screen.getByRole("textbox"), "B")
await user.click(screen.getByRole("button", { name: "B" }))
screen.getByRole("button", { name: "복사" }).focus()
@@ -188,7 +217,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
it("미선택·조합 중·다른 modifier·편집 중에는 붙여넣지 않음", () => {
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue()
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
const input = screen.getByRole("textbox")
fireEvent.change(input, { target: { value: "B" } })
fireEvent.keyDown(input, { key: "Enter", ctrlKey: true })
@@ -208,7 +241,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
it("브라우저의 Ctrl Enter는 복사나 붙여넣기로 바뀌지 않음", () => {
vi.mocked(isWebView).mockReturnValue(false)
const writeText = vi.spyOn(navigator.clipboard, "writeText")
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
const input = screen.getByRole("textbox")
fireEvent.change(input, { target: { value: "B" } })
fireEvent.keyDown(input, { key: "ArrowDown" })
@@ -220,7 +257,11 @@ describe("Ctrl Enter 앱에 붙여넣기", () => {
describe("검색 후 선택할 때만 미리보기", () => {
it("빈 검색 → 결과 → 직접 선택 → 검색 변경과 지우기 순서로 화면이 접힘", () => {
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
const input = screen.getByRole("textbox")
expect(screen.queryByText("A")).not.toBeInTheDocument()
expect(screen.queryByLabelText("코드 미리보기")).not.toBeInTheDocument()
@@ -244,7 +285,11 @@ describe("검색 후 선택할 때만 미리보기", () => {
it("검색 결과만 보일 때 Enter는 복사하지 않고 재소환은 검색창으로 돌아감", () => {
const writeText = vi.spyOn(navigator.clipboard, "writeText")
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
fireEvent.change(screen.getByRole("textbox"), { target: { value: "A" } })
fireEvent.keyDown(window, { key: "Enter" })
expect(writeText).not.toHaveBeenCalled()
@@ -259,7 +304,11 @@ describe("검색 후 선택할 때만 미리보기", () => {
it("마우스로 결과를 선택한 뒤 Esc를 누르면 검색창에서 바로 다시 입력할 수 있음", async () => {
const user = userEvent.setup()
render(<SnippetPalettePage />)
render(
<MemoryRouter>
<SnippetPalettePage />
</MemoryRouter>
)
const input = screen.getByRole("textbox")
await waitFor(() => expect(input).toHaveFocus())
await user.type(input, "A")
@@ -2,7 +2,10 @@ import { hostKind, send } from "@/lib/bridge/transport"
import { LogoMark } from "@/shared/components/LogoMark"
// 검색 → 결과 → 직접 선택한 코드 순서로 화면과 데스크톱 창을 펼침.
import { useEffect, useMemo, useRef, useState } from "react"
import { Check, Search } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { Check, MessageSquare, Search } from "lucide-react"
import { PATHS } from "@/config/routes"
import { Kbd } from "@/shared/components/Kbd"
import { hideWindow, isWebView, pasteToApp, startWindowDrag } from "@/lib/bridge/webviewBridge"
import { toast } from "sonner"
import { useEscapeKey } from "@/features/snap/hooks/useEscapeKey"
@@ -25,6 +28,7 @@ import type { Snippet, SnippetInput } from "../types"
const ALL_CATEGORY = "전체"
export default function SnippetPalettePage() {
const navigate = useNavigate()
const [q, setQ] = useState("")
const { data: snippets = [], isLoading, error } = useSnippets()
const recordUse = useRecordUse()
@@ -307,6 +311,14 @@ export default function SnippetPalettePage() {
placeholder="스니펫 검색…"
className="placeholder:text-muted-foreground w-full bg-transparent text-base outline-none"
/>
{/* 챗으로 전환 — 같은 창에서 route 만 바뀜(Ctrl+Alt+W 와 동일) */}
<button
type="button"
onClick={() => navigate(PATHS.SNAP)}
className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring inline-flex h-7 flex-none items-center gap-1 rounded-md px-2 text-[11.5px] whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<MessageSquare className="size-3.5" aria-hidden="true" /><Kbd>Ctrl+Alt+W</Kbd>
</button>
</div>
{/* category 칩 바 — 2개 이상(전체 포함)일 때만 보여줌 */}