Skip to content
5 changes: 3 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions apps/viewer/__tests__/grab-encode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
encodeGrab,
grabLabel,
grabLabelParts,
plainSnippet,
} from "../browser/grab/encode.ts";
import type { Snippet } from "../browser/grab/snippet.ts";

Expand Down Expand Up @@ -112,6 +113,32 @@ describe("encodeGrab", () => {
});
});

describe("plainSnippet", () => {
// ⌥⏎ 단순 복사 — 편집기에 바로 붙여넣을 수 있어야 하므로 펜스·헤더가
// 없고, 문자 슬라이스가 이미 적용된 lines가 그대로 나간다.
test("side: 코드 줄만, 펜스·헤더 없음", () => {
expect(plainSnippet(sideSnip)).toBe("if (a) return;\nconst b = 1;");
});

// mixed의 +/- 마커도 제외 — 맥락(헤더)이 빠진 텍스트에 마커는 노이즈다.
test("mixed: 마커 없이 텍스트만", () => {
expect(
plainSnippet({
kind: "mixed",
oldStart: 2,
oldEnd: 2,
newStart: 2,
newEnd: 3,
rows: [
{ marker: "-", text: "l2-old", oldNo: 2, newNo: null },
{ marker: "+", text: "l2-new", oldNo: null, newNo: 2 },
{ marker: " ", text: "l3", oldNo: 3, newNo: 3 },
],
}),
).toBe("l2-old\nl2-new\nl3");
});
});

describe("grabLabelParts", () => {
// 조각의 kind가 곧 색이다 — 여기가 틀리면 팝오버가 엉뚱한 색을 칠한다.
test("side: 파일 / 범위 / 구분자 / side 네 조각", () => {
Expand Down
73 changes: 65 additions & 8 deletions apps/viewer/__tests__/grab-popover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const openDefault = () =>
label: labelParts("main.ts", ":84-98"),
labelTitle: "apps/viewer/browser/main.ts",
buildOutput: (prompt) => `OUT[${prompt}]`,
buildPlainOutput: () => "PLAIN",
placement: { left: 10, top: 20 },
});
const input = () =>
Expand Down Expand Up @@ -157,10 +158,40 @@ describe("createGrabPopover", () => {
expect(writes).toEqual(["OUT[정리해줘]"]);
expect(copied).toBe(1);
expect(popover.element.textContent).toContain("Copied");
jest.advanceTimersByTime(1200);
jest.advanceTimersByTime(400);
expect(popover.isOpen()).toBe(false);
jest.useRealTimers();
});
// ⌥⏎ 단순 복사 — 프롬프트가 차 있어도 무시하고 잡은 코드 텍스트만 나간다.
// Enter 계열은 개행 기본 동작이 있으므로 취소도 확인한다(Shift+Enter만 예외).
test("Alt+Enter는 buildPlainOutput을 복사하고 기본 개행을 막는다", async () => {
openDefault();
input().value = "무시될 프롬프트";
const notCancelled = pressEnter({ altKey: true });
await flush();
expect(notCancelled).toBe(false);
expect(writes).toEqual(["PLAIN"]);
expect(copied).toBe(1);
});

test("Alt+Enter도 Copied 상태 후 400ms 자동 닫힘", async () => {
jest.useFakeTimers();
openDefault();
pressEnter({ altKey: true });
await flush();
expect(popover.element.textContent).toContain("Copied");
jest.advanceTimersByTime(400);
expect(popover.isOpen()).toBe(false);
jest.useRealTimers();
});

test("IME 조합 중 Alt+Enter는 무시", () => {
openDefault();
pressEnter({ altKey: true, isComposing: true });
expect(writes).toEqual([]);
expect(popover.isOpen()).toBe(true);
});

test("IME 조합 중 Enter(isComposing)는 무시", () => {
openDefault();
input().value = "한글";
Expand Down Expand Up @@ -254,7 +285,7 @@ describe("createGrabPopover", () => {
// 복사 성공 직후엔 아직 열려 있다 — onCopied만 발화하고 onClosed는 아직.
expect(copied).toBe(1);
expect(closed).toBe(0);
jest.advanceTimersByTime(1200);
jest.advanceTimersByTime(400);
expect(closed).toBe(1);
jest.useRealTimers();
});
Expand All @@ -273,6 +304,7 @@ describe("createGrabPopover", () => {
label: labelParts("x", ""),
labelTitle: "src/x.ts",
buildOutput: () => "y",
buildPlainOutput: () => "plain",
placement: { left: 0, top: 0 },
});
expect(() => p.close()).not.toThrow();
Expand All @@ -289,6 +321,7 @@ describe("상태 전용 슬롯 + 접근성", () => {
label: labelParts(),
labelTitle: "src/a.ts",
buildOutput: () => "out",
buildPlainOutput: () => "plain",
placement: { left: 0, top: 0 },
});
const hint = pop.element.querySelector(".grab-hint") as HTMLElement;
Expand All @@ -297,21 +330,39 @@ describe("상태 전용 슬롯 + 접근성", () => {
expect(send.dataset.state).toBe("idle");
});

// 단축키 고지는 **화면을 차지하지 않는** 두 채널이 맡는다: 버튼 hover(title)와
// aria-keyshortcuts. placeholder는 한 마디만 해야 한다 — 여기에 안내를 실으면
// 입력창을 꽉 채워 창에서 가장 눈에 띄는 요소가 되어버린다.
test("placeholder는 한 마디만 하고, 단축키는 title·aria가 고지한다", () => {
// 단축키 고지는 채널이 맡는다: 하단 .grab-keys 각주(⌥⏎만), 버튼
// hover(title), aria-keyshortcuts. placeholder는 한 마디만 해야 한다 —
// 여기에 안내를 실으면 입력창을 꽉 채워 창에서 가장 눈에 띄는 요소가 된다.
test("placeholder는 한 마디만 하고, 단축키는 각주·title·aria가 고지한다", () => {
const { popover: pop } = makePopover();
const box = pop.element.querySelector("textarea") as HTMLTextAreaElement;
expect(box.placeholder).toBe("Prompt…");
expect(box.getAttribute("aria-keyshortcuts")).toBe(
"Enter Shift+Enter Escape",
"Enter Shift+Enter Alt+Enter Escape",
);
// 표기(글자/글리프)가 아니라 **동작이 다 고지되는가**를 본다 —
// 표기(글자/글리프)가 아니라 **동작이 다 고지되는가**를 본다 —
// 문구를 다듬어도 안내가 통째로 빠지는 회귀만 잡히면 된다.
const send = pop.element.querySelector(".grab-send") as HTMLElement;
expect(send.title).toContain("⏎");
expect(send.title).toContain("new line");
expect(send.title).toContain("plain code");
});

// 하단 각주는 ⌥⏎의 발견 가능성 채널 — 상태 라이브 리전과 달리 항상 보인다.
test("하단에 ⌥⏎ 단축키 각주가 렌더된다", () => {
const { popover: pop } = makePopover();
pop.open({
label: labelParts(),
labelTitle: "src/a.ts",
buildOutput: () => "out",
buildPlainOutput: () => "plain",
placement: { left: 0, top: 0 },
});
const keys = pop.element.querySelector(".grab-keys") as HTMLElement;
expect(keys.textContent).toBe("⌥⏎ Copy code only");
// 키 글리프와 설명이 갈라져 있다 — 키 쪽만 밝은 톤으로 칠한다.
const key = keys.querySelector(".grab-keys-k") as HTMLElement;
expect(key.textContent).toBe("⌥⏎");
});

test("복사 실패는 힌트에 표시되고 팝오버는 열린 채 남는다", async () => {
Expand All @@ -320,6 +371,7 @@ describe("상태 전용 슬롯 + 접근성", () => {
label: labelParts(),
labelTitle: "src/a.ts",
buildOutput: () => "out",
buildPlainOutput: () => "plain",
placement: { left: 0, top: 0 },
});
const box = pop.element.querySelector("textarea") as HTMLTextAreaElement;
Expand All @@ -339,6 +391,7 @@ describe("상태 전용 슬롯 + 접근성", () => {
label: labelParts(),
labelTitle: "src/a.ts",
buildOutput: () => "out",
buildPlainOutput: () => "plain",
placement: { left: 0, top: 0 },
};
pop.open(opts);
Expand Down Expand Up @@ -369,6 +422,7 @@ describe("상태 전용 슬롯 + 접근성", () => {
],
labelTitle: "src/a.ts",
buildOutput: () => "out",
buildPlainOutput: () => "plain",
placement: { left: 0, top: 0 },
});
const label = pop.element.querySelector(".grab-label") as HTMLElement;
Expand All @@ -389,6 +443,7 @@ describe("상태 전용 슬롯 + 접근성", () => {
const base = {
labelTitle: "src/a.ts",
buildOutput: () => "out",
buildPlainOutput: () => "plain",
placement: { left: 0, top: 0 },
};
pop.open({ ...base, label: labelParts("a.ts", ":1-2") });
Expand All @@ -404,6 +459,7 @@ describe("상태 전용 슬롯 + 접근성", () => {
label: labelParts(),
labelTitle: "src/deep/a.ts",
buildOutput: () => "out",
buildPlainOutput: () => "plain",
placement: { left: 0, top: 0 },
});
const label = pop.element.querySelector(".grab-label");
Expand Down Expand Up @@ -434,6 +490,7 @@ describe("보내기 버튼 — 입력 영역 안, 배경 없음", () => {
label: labelParts(),
labelTitle: "src/a.ts",
buildOutput: (p: string) => `out:${p}`,
buildPlainOutput: () => "plain",
placement: { left: 0, top: 0 },
};

Expand Down
8 changes: 8 additions & 0 deletions apps/viewer/browser/grab/encode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ export const grabLabel = (path: string, snippet: Snippet): string =>
.map((part) => part.text)
.join("");

// ⌥⏎ 단순 복사용 — 잡은 줄의 코드 텍스트만 나간다. 펜스·File:/Lines: 머리말·
// 프롬프트 전부 없이 편집기에 바로 붙여넣을 수 있는 형태. mixed의 +/- 마커도
// 싣지 않는다 — 맥락(헤더)이 빠진 텍스트에 마커만 남으면 노이즈다.
export const plainSnippet = (snippet: Snippet): string =>
snippet.kind === "side"
? snippet.lines.join("\n")
: snippet.rows.map((r) => r.text).join("\n");

export const encodeGrab = (input: EncodeInput): string => {
const { snippet } = input;
const fileLine = input.prevPath
Expand Down
54 changes: 45 additions & 9 deletions apps/viewer/browser/grab/popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
// findBar.ts와 동일하게 리스너는 생성 시 등록, destroy()에서 해제(happy-dom
// 전역 window 누적 방지).
//
// 구조는 라벨 한 줄 + 입력 영역 둘뿐이고, 보내기 버튼은 입력 영역 **안**에 산다:
// 구조는 라벨 한 줄 + 입력 영역 + 단축키 각주 한 줄 뿐이고, 보내기 버튼은
// 입력 영역 **안**에 산다:
//
// ┌ #grab-popover ────────────────┐ 8px ← 떠 있는 패널(앱에서 유일)
// │ popover.ts:53 · new side │
// │ ┌ .grab-field ──────────────┐ │ 6px ← --vd-radius, 표준 컨트롤
// │ │ textarea [.grab-send]│ │ 4px ← 컨테이너 안쪽 버튼
// │ └───────────────────────────┘ │
// │ ⌥⏎ Copy code only │ 6px ← .grab-keys 각주(--vd-fg-muted)
// └───────────────────────────────┘
//
// radius 8/6/4는 임의값이 아니라 앱에 이미 있는 세 값이다(툴바 세그먼트
Expand All @@ -33,6 +35,8 @@ export interface GrabOpenOptions {
label: GrabLabelPart[];
labelTitle: string; // 전체 경로 — 라벨은 basename만 보여주고 ellipsis로 잘린다
buildOutput(prompt: string): string;
// ⌥⏎가 복사할 "단순 코드" 출력 — 프롬프트·머리말 없이 잡은 코드만.
buildPlainOutput(): string;
placement: Placement;
}

Expand All @@ -47,8 +51,13 @@ export interface GrabPopover {
const HINT_COPIED = "Copied";
const HINT_FAILED = "Copy failed";
const SEND_LABEL = "Copy to clipboard";
const SEND_TITLE = "Copy (⏎) · Shift+⏎ for new line";
const AUTO_CLOSE_MS = 1200;
const SEND_TITLE = "Copy (⏎) · Shift+⏎ for new line · ⌥⏎ for plain code";
const KEYS_KEY = "⌥⏎";
const KEYS_TEXT = "Copy code only";
// 복사 성공 확인("Copied" + 초록 체크)의 체류 시간. 예전엔 copyButton의
// RESET_MS(1200)와 맞췄지만, 제출 후 팝오버가 빨리 사라지는 게 요청돼서
// 확인으로서의 최소한만 남긴다.
const AUTO_CLOSE_MS = 400;

// 버튼 아이콘 셋. 세 개를 전부 DOM에 두고 data-state로 CSS가 하나만 보여준다 —
// 상태마다 innerHTML을 갈아끼우면 매번 파서를 태우고 재측정을 유발한다.
Expand Down Expand Up @@ -90,11 +99,11 @@ export const createGrabPopover = (deps: GrabPopoverDeps): GrabPopover => {
input.className = "grab-input";
// placeholder는 한 마디만 한다. 예전엔 여기에 "(⏎ copy · shift + ⏎ new
// line)"까지 실어 입력창을 꽉 채웠는데, 그게 창에서 가장 눈에 띄는 요소라
// 다른 걸 아무리 고쳐도 "안 바뀐 것처럼" 보였다. 단축키 고지는 이제 화면을
// 차지하지 않는 두 채널이 맡는다 — 버튼 hover(title) aria-keyshortcuts.
// 다른 걸 아무리 고쳐도 "안 바뀐 것처럼" 보였다. 단축키 고지는 하단
// .grab-keys 각주(⌥⏎만)와 버튼 hover(title), aria-keyshortcuts가 맡는다.
input.placeholder = "Prompt…";
input.setAttribute("aria-label", "Grab prompt");
input.setAttribute("aria-keyshortcuts", "Enter Shift+Enter Escape");
input.setAttribute("aria-keyshortcuts", "Enter Shift+Enter Alt+Enter Escape");

// 보내기 버튼. 배경색이 없는 게 계약이다 — 색만 바뀐다(회색 → 액센트 →
// 초록/빨강). 그래서 어느 상태에서도 창 크기가 변하지 않는다.
Expand All @@ -112,6 +121,15 @@ export const createGrabPopover = (deps: GrabPopoverDeps): GrabPopover => {

field.append(input, send);

// 하단 단축키 각주 — ⌥⏎의 발견 가능성 채널. 항상 보이는 고정 텍스트라
// 상태를 알리는 sr-only 라이브 리전(.grab-hint)과 역할이 다르다.
const keys = doc.createElement("div");
keys.className = "grab-keys";
const keysKey = doc.createElement("span");
keysKey.className = "grab-keys-k";
keysKey.textContent = KEYS_KEY;
keys.append(keysKey, ` ${KEYS_TEXT}`);

// 상태 전용 라이브 리전. **시각적으로는 버튼이 상태를 지므로** 이 노드는
// 스크린리더 전용(sr-only)이다. 예전처럼 눈에 보이는 줄로 두면 복사할
// 때마다 창이 한 줄 자라 커서 아래 코드가 밀렸다(실측: 66 → 84px).
Expand All @@ -133,10 +151,11 @@ export const createGrabPopover = (deps: GrabPopoverDeps): GrabPopover => {
hint.textContent = "";
};

element.append(label, field, hint);
element.append(label, field, keys, hint);

let opened = false;
let buildOutput: ((prompt: string) => string) | null = null;
let buildPlainOutput: (() => string) | null = null;
let autoCloseTimer: ReturnType<typeof setTimeout> | null = null;

const clearAutoCloseTimer = (): void => {
Expand Down Expand Up @@ -167,6 +186,7 @@ export const createGrabPopover = (deps: GrabPopoverDeps): GrabPopover => {
// options.buildOutput을 그대로 tear-off하지 않고 래핑 호출한다
// (oxlint unbound-method — 인터페이스 메서드 시그니처의 this 바인딩 경고).
buildOutput = (prompt) => options.buildOutput(prompt);
buildPlainOutput = () => options.buildPlainOutput();
element.style.left = `${options.placement.left}px`;
element.style.top = `${options.placement.top}px`;
input.value = "";
Expand Down Expand Up @@ -195,11 +215,20 @@ export const createGrabPopover = (deps: GrabPopoverDeps): GrabPopover => {
console.warn(err);
};

const submit = (): void => {
const output = buildOutput?.(input.value) ?? "";
const writeOutput = (output: string): void => {
deps.writeText(output).then(onCopySuccess, onCopyFailure);
};

const submit = (): void => {
writeOutput(buildOutput?.(input.value) ?? "");
};

// ⌥⏎ — 프롬프트·머리말 없이 잡은 코드 텍스트만 복사한다. 성공/실패와
// 자동 닫힘은 제출과 같은 경로를 쓴다.
const submitPlain = (): void => {
writeOutput(buildPlainOutput?.() ?? "");
};

// 프롬프트가 비었는지에 따라 버튼 강조만 바뀐다. 비활성화하지는 않는다 —
// 빈 프롬프트로도 참조+스니펫은 복사되므로 막으면 기능이 줄어든다.
input.addEventListener("input", () => {
Expand All @@ -209,6 +238,13 @@ export const createGrabPopover = (deps: GrabPopoverDeps): GrabPopover => {
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
if (event.isComposing || event.keyCode === 229) return;
// ⌥⏎는 단순 복사 — Enter 계열은 전부 개행 기본 동작이 있으므로 여기서
// 막는다(Shift+Enter만 예외로 살려 둔다).
if (event.altKey) {
event.preventDefault();
submitPlain();
return;
}
// Shift+Enter는 개행 — preventDefault를 부르지 않고 그냥 빠져나가
// textarea의 기본 동작에 맡긴다. 새 키 분기를 만들지 않는다.
if (event.shiftKey) return;
Expand Down
14 changes: 10 additions & 4 deletions apps/viewer/browser/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
encodeGrab,
type GrabFileStatus,
grabLabelParts,
plainSnippet,
} from "./grab/encode.ts";
import {
createGrabHighlighter,
Expand Down Expand Up @@ -391,10 +392,11 @@
// 없어 content-box라 실제 렌더 폭은 340 + 패딩 16 + 테두리 2다. 340으로 두면
// 우측 클램프(viewport.width - size.width - MARGIN)가 18px 관대해져 화면
// 오른쪽 끝에서 드래그할 때 팝오버가 그만큼 잘린다(실측 358×69).
// height는 입력창이 max-height(96px)까지 자라고 상태 줄까지 뜬 최대 상태를
// 기준으로 잡는다 — 배치는 open() 때 한 번만 계산되므로, 자란 뒤 재배치가
// 없어서 과소 선언하면 화면 아래쪽에서 카드가 뷰포트를 벗어난다.
const POPOVER_SIZE = { width: 358, height: 190 };
// height는 입력창이 max-height(90px)까지 자라고 하단 단축키 각주(.grab-keys,
// 15px + gap 6px)까지 뜬 최대 상태를 기준으로 잡는다 — 배치는 open() 때 한 번만
// 계산되므로, 자란 뒤 재배치가 없어서 과소 선언하면 화면 아래쪽에서 카드가
// 뷰포트를 벗어난다.
const POPOVER_SIZE = { width: 358, height: 211 };
const viewport = (): { width: number; height: number } => ({
width: window.innerWidth,
height: window.innerHeight,
Expand Down Expand Up @@ -523,6 +525,7 @@
label: grabLabelParts(fileId, snippet),
labelTitle: fileId,
buildOutput: (prompt) => encodeGrab({ ...input, prompt }),
buildPlainOutput: () => plainSnippet(snippet),
};
};

Expand Down Expand Up @@ -627,6 +630,9 @@
// diff-grab: GitHub식 거터 라인 선택 + "+" 버튼 (스펙 §경로 A).
// renderGutterUtility는 금지 — onGutterUtilityClick과 병용 시 엔진 throw.
enableLineSelection: true,
// 라인넘버 드래그는 끈다 — 드래그는 코드 텍스트 선택(그랩)의 제스처다.
// 클릭 한 줄 선택·shift클릭 확장·"+" 클릭은 그대로(엔진 pendingLineSelect).
enableLineSelectionDrag: false,
enableGutterUtility: true,
onGutterUtilityClick: (range: SelectedLineRange, context) => {
const snap = buildGrabSnapshot(context.item.id, normalizeRange(range));
Expand Down Expand Up @@ -1651,10 +1657,10 @@
const versionValue = document.getElementById("version-value");
if (versionValue) {
void fetch("/api/ping")
.then((res) => {
const v = res.headers.get("x-diffdeck-version");
if (v) versionValue.textContent = `v${v}`;
})

Check warning on line 1663 in apps/viewer/browser/main.ts

View workflow job for this annotation

GitHub Actions / lint

promise(always-return)

Each then() should return a value or throw
.catch(() => {
// 부가 정보다 — 못 읽어도 메뉴의 나머지는 그대로 동작한다.
});
Expand Down
Loading
Loading