diff --git a/packages/app/src/context/zoom-keybind.test.ts b/packages/app/src/context/zoom-keybind.test.ts new file mode 100644 index 000000000..ad6296591 --- /dev/null +++ b/packages/app/src/context/zoom-keybind.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test" +import { webZoom, setWebZoom } from "../utils/web-zoom" + +// harmoniqs/amicode#266 — Cmd/Ctrl +/- zoom did nothing in the webview. +// +// The zoom commands, their CSS-zoom implementation, and the platform signal +// all existed and were correct. Only the wiring was wrong: the chords were +// registered with the command registry's exact (normalized-key, modifier- +// mask) lookup with no fallback, and only "mod+=" / "mod+-" were +// registered — which misses what a user actually presses: +// Ctrl+Plus on a US layout IS Ctrl+Shift+"=", which arrives as key "+" +// (normalized "plus") WITH the shift bit — wrong key AND wrong mask. +// The numpad's "+" arrives unshifted. On DE/FR/Nordic layouts "=" is +// itself a shifted key, so even the canonical chord carries shift. +// +// So the bare chord worked only on a US layout with the main-row "=", which +// is why this survived: it works for whoever tries it that one way. +// +// V1 (this file): the registry no longer owns zoom. web-zoom.ts (imported +// below — module scope registers the capture listener on document) grabs the +// raw chords by the key the layout PRODUCES — the same physical key yields +// one of "="/"+"/"-"/"_" on every layout — so the coverage matrix below +// asserts every variant that can arrive, plus the negatives that must never +// fire. + +function chord(key: string, opts: { ctrl?: boolean; meta?: boolean; shift?: boolean } = {}) { + // cancelable: real keydown events are cancelable; without it preventDefault + // (and defaultPrevented) are no-ops per spec. + return new KeyboardEvent("keydown", { + key, + ctrlKey: opts.ctrl ?? false, + metaKey: opts.meta ?? false, + shiftKey: opts.shift ?? false, + cancelable: true, + }) +} + +function press(ev: KeyboardEvent) { + document.dispatchEvent(ev) + return webZoom() +} + +describe("zoom raw chord capture (amicode#266)", () => { + test("zoom in: main-row =, US Ctrl+Plus, numpad +, and shifted-= layouts", () => { + for (const [key, shift] of [ + ["=", false], + ["+", true], // US Ctrl+Plus: shift+"=" surfaces as "+" with the shift bit + ["+", false], // numpad plus + ["=", true], // layouts where "=" itself requires shift (DE/FR/Nordic) + ] as const) { + setWebZoom(1) + const zoom = press(chord(key, { ctrl: true, shift })) + expect(zoom).toBe(1.1) + } + }) + + test("zoom out: main-row - and the shifted underscore", () => { + for (const [key, shift] of [ + ["-", false], + ["_", true], + ] as const) { + setWebZoom(1) + const zoom = press(chord(key, { ctrl: true, shift })) + expect(zoom).toBe(0.9) + } + }) + + test("zoom reset: a single unambiguous chord", () => { + setWebZoom(1.5) + expect(press(chord("0", { ctrl: true }))).toBe(1) + }) + + test("Cmd (meta) drives the same chords", () => { + for (const [key, expected] of [ + ["=", 1.1], + ["-", 0.9], + ["0", 1], + ] as const) { + setWebZoom(1) + expect(press(chord(key, { meta: true }))).toBe(expected) + } + }) + + test("nothing fires without a modifier", () => { + for (const key of ["=", "+", "-", "_", "0"]) { + setWebZoom(1) + expect(press(chord(key))).toBe(1) + expect(press(chord(key, { shift: true }))).toBe(1) + } + }) + + test("other Ctrl chords are untouched", () => { + for (const [key, shift] of [ + ["w", false], + ["p", false], + ["p", true], + ["a", false], + ["1", false], + ["ArrowUp", false], + ] as const) { + setWebZoom(1) + expect(press(chord(key, { ctrl: true, shift }))).toBe(1) + } + }) + + test("matched chords are consumed, unmatched chords are not", () => { + const matched = chord("=", { ctrl: true }) + document.dispatchEvent(matched) + expect(matched.defaultPrevented).toBe(true) + + const unmatched = chord("p", { ctrl: true }) + document.dispatchEvent(unmatched) + expect(unmatched.defaultPrevented).toBe(false) + }) +}) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 3df70e090..e10956ec0 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -16,7 +16,6 @@ import { useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" import { VaultPanel } from "@/components/vault-panel" -import { webZoomIn, webZoomOut, webZoomReset } from "@/utils/web-zoom" import { useServerSync } from "@/context/server-sync" import { Persist, persisted } from "@/utils/persist" import { base64Encode } from "@opencode-ai/core/util/encode" @@ -225,50 +224,12 @@ export default function LegacyLayout(props: ParentProps) { makeEventListener(window, "blur", stop) makeEventListener(window, "blur", blur) makeEventListener(document, "visibilitychange", hide) - - // Zoom keyboard shortcuts - always active - const handleZoomKey = (e: KeyboardEvent) => { - // Require Cmd (Mac) or Ctrl (Windows/Linux) - if (!e.ctrlKey && !e.metaKey) return - - // Don't trigger if user is typing in an input - const target = e.target as HTMLElement - if (target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable) { - return - } - - // Use code for physical key detection (works with Shift) - // Equal key produces + when Shift is held - const code = e.code - const key = e.key - - // Debug logging - console.log("[Zoom] Keydown:", { code, key, shift: e.shiftKey, meta: e.metaKey, ctrl: e.ctrlKey }) - - // Plus/Equal: Zoom in (works with Cmd+Shift+Plus or Cmd+Equal) - if (code === "Equal" || code === "NumpadAdd" || key === "+" || (key === "=" && e.shiftKey)) { - e.preventDefault() - e.stopImmediatePropagation() - console.log("[Zoom] Triggering zoom in") - webZoomIn() - } else if (code === "Minus" || code === "NumpadSubtract" || key === "-" || key === "_") { - e.preventDefault() - e.stopImmediatePropagation() - console.log("[Zoom] Triggering zoom out") - webZoomOut() - } else if (code === "Digit0" || code === "Numpad0" || key === "0") { - e.preventDefault() - e.stopImmediatePropagation() - console.log("[Zoom] Triggering zoom reset") - webZoomReset() - } - } - - // Use window with capture phase for maximum reliability - window.addEventListener("keydown", handleZoomKey, true) - onCleanup(() => window.removeEventListener("keydown", handleZoomKey, true)) + + // Zoom keyboard handling lives in web-zoom.ts: each app document (main + // frame and split panes) captures the zoom chords itself and applies its + // own CSS zoom (amicode#266). It must NOT be a command here — the registry's + // exact key matching is layout-sensitive, and inside the webview the + // chords never reach this document from the host anyway. }) const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) @@ -957,34 +918,12 @@ export default function LegacyLayout(props: ParentProps) { keybind: "mod+comma", onSelect: () => openSettings(), }, - // web host only: the desktop build zooms through Electron (menu roles - // were retired for the action path), and registering the same chords - // here would double-fire against its renderer keydown handler - ...(platform.platform === "web" - ? [ - { - id: "view.zoomIn", - title: language.t("amicode.zoomIn"), - category: language.t("command.category.view"), - keybind: "mod+=", - onSelect: () => webZoomIn(), - }, - { - id: "view.zoomOut", - title: language.t("amicode.zoomOut"), - category: language.t("command.category.view"), - keybind: "mod+-", - onSelect: () => webZoomOut(), - }, - { - id: "view.zoomReset", - title: language.t("amicode.zoomReset"), - category: language.t("command.category.view"), - keybind: "mod+0", - onSelect: () => webZoomReset(), - }, - ] - : []), + // Zoom (Cmd/Ctrl+=/-/0) is deliberately NOT a command: web-zoom.ts + // captures the chords at each document and applies the in-app CSS zoom + // directly (harmoniqs/amicode#266) — the registry's exact key matching + // cannot express the layout variants of "+", and inside the webview the + // chords never reach this document from the host anyway. The desktop + // build keeps zooming through Electron. ...(platform.platform === "desktop" && platform.exportDebugLogs ? [ { diff --git a/packages/app/src/utils/web-zoom.test.ts b/packages/app/src/utils/web-zoom.test.ts new file mode 100644 index 000000000..a8295668b --- /dev/null +++ b/packages/app/src/utils/web-zoom.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import { webZoom, webZoomIn, webZoomOut, webZoomReset } from "./web-zoom" + +// Zoom routing (amicode#266): ONE zoom owner — the app document itself. The +// workbench can never see keydowns from inside the webview documents +// (cross-origin iframe), so there is no bridge, no "framed" branch, and no +// host interception to route around: webZoomIn/Out/Reset always apply the +// in-app CSS zoom, and the module-scope capture listener (registered on +// import, below) applies it from the raw chords in every document that loads +// the bundle — the main frame and each split pane. + +describe("web-zoom signal (amicode#266)", () => { + test("zoom in/out/reset always move the in-app signal", () => { + const before = webZoom() + webZoomIn() + expect(webZoom()).toBe(Math.round((before + 0.1) * 100) / 100) + webZoomOut() + expect(webZoom()).toBe(Math.round(before * 100) / 100) + webZoomReset() + expect(webZoom()).toBe(1) + }) + + test("the raw chord listener is registered on the document", () => { + webZoomReset() + const fired = new KeyboardEvent("keydown", { key: "=", ctrlKey: true, cancelable: true }) + document.dispatchEvent(fired) + expect(fired.defaultPrevented).toBe(true) + expect(webZoom()).toBe(1.1) + }) +}) diff --git a/packages/app/src/utils/web-zoom.ts b/packages/app/src/utils/web-zoom.ts index ed2f28e09..a5d5ea66f 100644 --- a/packages/app/src/utils/web-zoom.ts +++ b/packages/app/src/utils/web-zoom.ts @@ -1,10 +1,19 @@ -// amicode: in-app zoom for the WEB host (the amicode VS Code webview and the -// plain browser at :3002). The desktop build zooms through Electron -// (webview-zoom.ts); the web build had NO zoom at all — Cmd+=/-/0 fell -// through to the host, which zooms the whole editor window (or the browser -// tab), never the app. This module owns a CSS zoom on the document root, -// persisted per-window, and feeds the same platform.webviewZoom signal the -// titlebar and terminal already watch. +// amicode: in-app zoom for the WEB host (the plain browser at :3002 and the +// amicode VS Code webview). The desktop build zooms through Electron (menu +// roles); the web build had NO zoom at all — Cmd+=/-/0 fell through to the +// host, which zooms the whole editor window (or the browser tab), never the +// app. This module owns a CSS zoom on the document root, persisted +// per-window, and feeds the same platform.webviewZoom signal the titlebar +// and terminal already watch. +// +// One zoom owner (harmoniqs/amicode#266): the app document itself. The +// workbench can never see keydowns from inside the webview documents +// (cross-origin iframe; the host page's forwarding covers only the host +// page), so the extension cannot translate them into workbench zoom actions. +// Instead every app document — the main frame and each split pane — captures +// the zoom chords itself and applies its own CSS zoom: with the webview +// panel focused, the zoomed content is the webview; with an editor tab +// focused, the workbench's own native window zoom applies, unchanged. import { createSignal } from "solid-js" const KEY = "amicode-zoom" @@ -61,3 +70,35 @@ export const webZoomIn = () => setWebZoom(webZoom() + STEP) export const webZoomOut = () => setWebZoom(webZoom() - STEP) export const webZoomReset = () => setWebZoom(1) export { webZoom } + +// Raw chord capture (harmoniqs/amicode#266). The command registry's exact +// (normalized-key, modifier-mask) lookup cannot express "the key that means +// +" across layouts: Ctrl+Plus on US IS Ctrl+Shift+"=", arriving as key "+" +// with the shift bit — wrong key AND wrong mask — while the numpad "+" +// arrives unshifted and on DE/FR/Nordic layouts even the canonical "=" +// carries shift. Rather than widen the registry's chords (and its tooltips, +// palette, and collision surface), the zoom chords are captured directly by +// each app document, matching the key the layout PRODUCES — the same +// physical key yields one of "="/"+"/"-"/"_" on every layout, with the shift +// bit deliberately ignored. "0" is unshifted on every layout we ship to and +// stays unambiguous. +const ZOOM_IN_KEYS = new Set(["=", "+"]) +const ZOOM_OUT_KEYS = new Set(["-", "_"]) +const ZOOM_RESET_KEYS = new Set(["0"]) + +if (typeof document !== "undefined") { + document.addEventListener( + "keydown", + (event) => { + if (!event.metaKey && !event.ctrlKey) return + const key = event.key + if (ZOOM_IN_KEYS.has(key)) setWebZoom(webZoom() + STEP) + else if (ZOOM_OUT_KEYS.has(key)) setWebZoom(webZoom() - STEP) + else if (ZOOM_RESET_KEYS.has(key)) setWebZoom(1) + else return + event.preventDefault() + event.stopPropagation() + }, + { capture: true }, + ) +}