Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions packages/app/src/context/zoom-keybind.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
85 changes: 12 additions & 73 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
? [
{
Expand Down
30 changes: 30 additions & 0 deletions packages/app/src/utils/web-zoom.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
55 changes: 48 additions & 7 deletions packages/app/src/utils/web-zoom.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 },
)
}
Loading