Kaynağa Gözat

feat(desktop): add pinch zoom setting (#28632)

Brendan Allan 2 ay önce
ebeveyn
işleme
2caac055ef

+ 56 - 25
packages/app/src/components/settings-general.tsx

@@ -193,6 +193,12 @@ export const SettingsGeneral: Component = () => {
     { initialValue: null as DisplayBackend | null },
   )
 
+  const [pinchZoom, { mutate: setPinchZoom }] = createResource(
+    () => (desktop() && platform.getPinchZoomEnabled ? true : false),
+    () => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
+    { initialValue: false },
+  )
+
   onMount(() => {
     void theme.loadThemes()
   })
@@ -239,6 +245,13 @@ export const SettingsGeneral: Component = () => {
     })
   }
 
+  const onPinchZoomChange = (checked: boolean) => {
+    setPinchZoom(checked)
+    const update = platform.setPinchZoomEnabled?.(checked)
+    if (!update) return
+    void update.catch(() => setPinchZoom(!checked))
+  }
+
   const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
     { value: "system", label: language.t("theme.scheme.system") },
     { value: "light", label: language.t("theme.scheme.light") },
@@ -729,6 +742,48 @@ export const SettingsGeneral: Component = () => {
     </div>
   )
 
+  const DisplaySection = () => (
+    <Show when={desktop()}>
+      <div class="flex flex-col gap-1">
+        <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
+
+        <SettingsList>
+          <SettingsRow
+            title={language.t("settings.general.row.pinchZoom.title")}
+            description={language.t("settings.general.row.pinchZoom.description")}
+          >
+            <div data-action="settings-pinch-zoom">
+              <Switch
+                checked={pinchZoom.latest}
+                onChange={onPinchZoomChange}
+              />
+            </div>
+          </SettingsRow>
+
+          <Show when={linux()}>
+            <SettingsRow
+              title={
+                <div class="flex items-center gap-2">
+                  <span>{language.t("settings.general.row.wayland.title")}</span>
+                  <Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
+                    <span class="text-text-weak">
+                      <Icon name="help" size="small" />
+                    </span>
+                  </Tooltip>
+                </div>
+              }
+              description={language.t("settings.general.row.wayland.description")}
+            >
+              <div data-action="settings-wayland">
+                <Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
+              </div>
+            </SettingsRow>
+          </Show>
+        </SettingsList>
+      </div>
+    </Show>
+  )
+
   console.log(import.meta.env)
   return (
     <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
@@ -749,31 +804,7 @@ export const SettingsGeneral: Component = () => {
 
         <UpdatesSection />
 
-        <Show when={linux()}>
-          <div class="flex flex-col gap-1">
-            <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
-
-            <SettingsList>
-              <SettingsRow
-                title={
-                  <div class="flex items-center gap-2">
-                    <span>{language.t("settings.general.row.wayland.title")}</span>
-                    <Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
-                      <span class="text-text-weak">
-                        <Icon name="help" size="small" />
-                      </span>
-                    </Tooltip>
-                  </div>
-                }
-                description={language.t("settings.general.row.wayland.description")}
-              >
-                <div data-action="settings-wayland">
-                  <Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
-                </div>
-              </SettingsRow>
-            </SettingsList>
-          </div>
-        </Show>
+        <DisplaySection />
 
         <Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
           <AdvancedSection />

+ 33 - 4
packages/app/src/components/titlebar.tsx

@@ -22,6 +22,7 @@ import { iife } from "@opencode-ai/core/util/iife"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
 import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
+import { makeEventListener } from "@solid-primitives/event-listener"
 
 type TauriDesktopWindow = {
   startDragging?: () => Promise<void>
@@ -298,6 +299,34 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
               () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
             )
 
+            const currentSessionTab = () => {
+              if (!params.dir || !params.id) return
+              const href = makeSessionHref(params.dir, params.id)
+              if (!tabsStore.some((tab) => tab.href === href)) return
+              return href
+            }
+
+            const closeCurrentSessionTab = () => {
+              const href = currentSessionTab()
+              if (!href) return false
+              tabsStoreActions.removeTab(href)
+              return true
+            }
+
+            makeEventListener(
+              document,
+              "keydown",
+              (event) => {
+                if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
+                if (event.key.toLowerCase() !== "w") return
+                if (!closeCurrentSessionTab()) return
+
+                event.preventDefault()
+                event.stopPropagation()
+              },
+              { capture: true },
+            )
+
             const tabsEnriched = iife(() => {
               const base = mapArray(
                 () => tabsStore,
@@ -578,15 +607,15 @@ function TabNavItem(props: {
   const isActive = () => !!match()
   return (
     <div
-      class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
+      class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] pl-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
       data-active={isActive()}
     >
       <a
         href={props.href}
-        class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-none tracking-[-0.04px] text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
+        class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
       >
         <ProjectTabAvatar project={props.project} directory={props.directory} />
-        <span class="truncate">{props.title}</span>
+        <span class="text-clip">{props.title}</span>
       </a>
 
       <div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
@@ -624,7 +653,7 @@ function ProjectTabAvatar(props: { project?: LocalProject; directory: string })
 
 function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) {
   return (
-    <div class="group relative flex h-7 w-[135px] min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]">
+    <div class="group relative flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]">
       <a
         href={props.href}
         aria-current="page"

+ 6 - 0
packages/app/src/context/platform.tsx

@@ -93,6 +93,12 @@ export type Platform = {
   /** Webview zoom level (desktop only) */
   webviewZoom?: Accessor<number>
 
+  /** Get whether native pinch/Ctrl-scroll zoom gestures are enabled (desktop only) */
+  getPinchZoomEnabled?(): Promise<boolean> | boolean
+
+  /** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
+  setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
+
   /** Run a desktop-only menu action from the app chrome */
   runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
 

+ 2 - 0
packages/app/src/i18n/en.ts

@@ -784,6 +784,8 @@ export const dict = {
   "settings.general.row.showSessionProgressBar.title": "Show session progress bar",
   "settings.general.row.showSessionProgressBar.description":
     "Display the animated progress bar at the top of the session when the agent is working",
+  "settings.general.row.pinchZoom.title": "Pinch to zoom",
+  "settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
 
   "settings.general.row.wayland.title": "Use native Wayland",
   "settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",

+ 1 - 0
packages/desktop/src/main/constants.ts

@@ -7,4 +7,5 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod
 export const SETTINGS_STORE = "opencode.settings"
 export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
 export const WSL_ENABLED_KEY = "wslEnabled"
+export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
 export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"

+ 5 - 1
packages/desktop/src/main/ipc.ts

@@ -14,7 +14,7 @@ import type {
 } from "../preload/types"
 import { runDesktopMenuAction } from "./desktop-menu-actions"
 import { getStore } from "./store"
-import { setTitlebar, updateTitlebar } from "./windows"
+import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
 
 const pickerFilters = (ext?: string[]) => {
   if (!ext || ext.length === 0) return undefined
@@ -202,6 +202,10 @@ export function registerIpcHandlers(deps: Deps) {
     if (!win) return
     updateTitlebar(win)
   })
+  ipcMain.handle("get-pinch-zoom-enabled", () => getPinchZoomEnabled())
+  ipcMain.handle("set-pinch-zoom-enabled", (_event: IpcMainInvokeEvent, enabled: boolean) => {
+    setPinchZoomEnabled(enabled)
+  })
   ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
     const win = BrowserWindow.fromWebContents(event.sender)
     if (!win) return

+ 40 - 3
packages/desktop/src/main/windows.ts

@@ -3,7 +3,9 @@ import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } f
 import { dirname, isAbsolute, join, relative, resolve } from "node:path"
 import { fileURLToPath, pathToFileURL } from "node:url"
 import type { TitlebarTheme } from "../preload/types"
+import { PINCH_ZOOM_ENABLED_KEY } from "./constants"
 import { exportDebugLogs, write as writeLog } from "./logging"
+import { getStore } from "./store"
 import { createUnresponsiveSampler } from "./unresponsive"
 
 const root = dirname(fileURLToPath(import.meta.url))
@@ -33,7 +35,10 @@ let relaunchHandler = () => {
   app.exit(0)
 }
 const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
+const pinchZoomEnabled = new WeakMap<BrowserWindow, boolean>()
 const titlebarHeight = 40
+const maxZoomLevel = 10
+const minZoomLevel = 0.2
 
 export function setRelaunchHandler(handler: () => void) {
   relaunchHandler = handler
@@ -79,6 +84,20 @@ export function updateTitlebar(win: BrowserWindow) {
   win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor()))
 }
 
+export function setPinchZoomEnabled(enabled: boolean) {
+  getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled)
+  for (const win of BrowserWindow.getAllWindows()) {
+    pinchZoomEnabled.set(win, enabled)
+    win.webContents.send("pinch-zoom-enabled-changed", enabled)
+    if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
+    updateZoom(win)
+  }
+}
+
+export function getPinchZoomEnabled() {
+  return getStore().get(PINCH_ZOOM_ENABLED_KEY) === true
+}
+
 export function setDockIcon() {
   if (process.platform !== "darwin") return
   const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
@@ -392,13 +411,31 @@ function isRendererUrl(value?: string, html = false) {
 }
 
 function wireZoom(win: BrowserWindow) {
+  pinchZoomEnabled.set(win, getPinchZoomEnabled())
   win.webContents.setZoomFactor(1)
-  win.webContents.on("zoom-changed", () => {
-    win.webContents.setZoomFactor(1)
-    updateTitlebar(win)
+  win.webContents.on("zoom-changed", (event, zoomDirection) => {
+    event.preventDefault()
+    if (pinchZoomEnabled.get(win)) {
+      win.webContents.setZoomFactor(
+        clampZoom(win.webContents.getZoomFactor() + (zoomDirection === "in" ? 0.2 : -0.2)),
+      )
+      updateZoom(win)
+      return
+    }
+    if (win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
+    updateZoom(win)
   })
 }
 
+function clampZoom(value: number) {
+  return Math.min(Math.max(value, minZoomLevel), maxZoomLevel)
+}
+
+function updateZoom(win: BrowserWindow) {
+  updateTitlebar(win)
+  win.webContents.send("zoom-factor-changed", win.webContents.getZoomFactor())
+}
+
 function upsertKeyValue(obj: Record<string, any>, keyToChange: string, value: any) {
   const keyToChangeLower = keyToChange.toLowerCase()
   for (const key of Object.keys(obj)) {

+ 12 - 0
packages/desktop/src/preload/index.ts

@@ -60,6 +60,18 @@ const api: ElectronAPI = {
   relaunch: () => ipcRenderer.send("relaunch"),
   getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
   setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
+  getPinchZoomEnabled: () => ipcRenderer.invoke("get-pinch-zoom-enabled"),
+  setPinchZoomEnabled: (enabled) => ipcRenderer.invoke("set-pinch-zoom-enabled", enabled),
+  onPinchZoomEnabledChanged: (cb) => {
+    const handler = (_: unknown, enabled: boolean) => cb(enabled)
+    ipcRenderer.on("pinch-zoom-enabled-changed", handler)
+    return () => ipcRenderer.removeListener("pinch-zoom-enabled-changed", handler)
+  },
+  onZoomFactorChanged: (cb) => {
+    const handler = (_: unknown, factor: number) => cb(factor)
+    ipcRenderer.on("zoom-factor-changed", handler)
+    return () => ipcRenderer.removeListener("zoom-factor-changed", handler)
+  },
   setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
   runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
   loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),

+ 4 - 0
packages/desktop/src/preload/types.ts

@@ -79,6 +79,10 @@ export type ElectronAPI = {
   relaunch: () => void
   getZoomFactor: () => Promise<number>
   setZoomFactor: (factor: number) => Promise<void>
+  getPinchZoomEnabled: () => Promise<boolean>
+  setPinchZoomEnabled: (enabled: boolean) => Promise<void>
+  onPinchZoomEnabledChanged: (cb: (enabled: boolean) => void) => () => void
+  onZoomFactorChanged: (cb: (factor: number) => void) => () => void
   setTitlebar: (theme: TitlebarTheme) => Promise<void>
   runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
   loadingWindowComplete: () => void

+ 5 - 1
packages/desktop/src/renderer/index.tsx

@@ -21,7 +21,7 @@ import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js
 import { render } from "solid-js/web"
 import pkg from "../../package.json"
 import { initI18n, t } from "./i18n"
-import { resetZoom, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
+import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
 import "./styles.css"
 import { useTheme } from "@opencode-ai/ui/theme"
 
@@ -274,6 +274,10 @@ const createPlatform = (): Platform => {
 
     webviewZoom,
 
+    getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(),
+
+    setPinchZoomEnabled,
+
     runDesktopMenuAction,
 
     checkAppExists: async (appName: string) => {

+ 80 - 1
packages/desktop/src/renderer/webview-zoom.ts

@@ -13,9 +13,21 @@ const OS_NAME = (() => {
 
 const [webviewZoom, setWebviewZoom] = createSignal(1)
 let requestedZoom = 1
+let pinchZoomEnabled = false
+let wheelPinch = undefined as
+  | {
+      active: boolean
+      startZoom: number
+      totalDelta: number
+      timeout: ReturnType<typeof setTimeout> | undefined
+    }
+  | undefined
 
 const MAX_ZOOM_LEVEL = 10
 const MIN_ZOOM_LEVEL = 0.2
+const WHEEL_PINCH_THRESHOLD = 20
+const WHEEL_PINCH_STEP = 0.2
+const WHEEL_PINCH_END_DELAY = 160
 
 const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
 
@@ -33,10 +45,77 @@ const applyZoom = (next: number) => {
     })
 }
 
+window.api.onZoomFactorChanged((factor) => {
+  requestedZoom = clamp(factor)
+  setWebviewZoom(requestedZoom)
+})
+
+void window.api.getPinchZoomEnabled().then((enabled) => {
+  pinchZoomEnabled = enabled
+})
+
+window.api.onPinchZoomEnabledChanged((enabled) => {
+  pinchZoomEnabled = enabled
+  resetWheelPinch()
+})
+
+const setPinchZoomEnabled = (enabled: boolean) => {
+  pinchZoomEnabled = enabled
+  resetWheelPinch()
+  return window.api.setPinchZoomEnabled(enabled)
+}
+
 const resetZoom = () => applyZoom(1)
 const zoomIn = () => applyZoom(clamp(requestedZoom + 0.2))
 const zoomOut = () => applyZoom(clamp(requestedZoom - 0.2))
 
+const resetWheelPinch = () => {
+  clearTimeout(wheelPinch?.timeout)
+  wheelPinch = undefined
+}
+
+const normalizeWheelDelta = (event: WheelEvent) => {
+  if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return event.deltaY * 16
+  if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return event.deltaY * window.innerHeight
+  return event.deltaY
+}
+
+const updateWheelPinch = (event: WheelEvent) => {
+  wheelPinch ??= {
+    active: false,
+    startZoom: requestedZoom,
+    totalDelta: 0,
+    timeout: undefined,
+  }
+
+  clearTimeout(wheelPinch.timeout)
+  wheelPinch.timeout = setTimeout(resetWheelPinch, WHEEL_PINCH_END_DELAY)
+  wheelPinch.totalDelta += normalizeWheelDelta(event)
+
+  if (!wheelPinch.active && Math.abs(wheelPinch.totalDelta) < WHEEL_PINCH_THRESHOLD) return
+  if (!wheelPinch.active) {
+    wheelPinch.active = true
+    wheelPinch.startZoom = requestedZoom
+    wheelPinch.totalDelta = 0
+    return
+  }
+
+  wheelPinch.active = true
+  applyZoom(clamp(wheelPinch.startZoom - (wheelPinch.totalDelta / WHEEL_PINCH_THRESHOLD) * WHEEL_PINCH_STEP))
+}
+
+window.addEventListener(
+  "wheel",
+  (event) => {
+    if (!pinchZoomEnabled) return
+    if (!event.ctrlKey) return
+
+    event.preventDefault()
+    updateWheelPinch(event)
+  },
+  { passive: false },
+)
+
 window.addEventListener("keydown", (event) => {
   if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
 
@@ -56,4 +135,4 @@ window.addEventListener("keydown", (event) => {
   }
 })
 
-export { webviewZoom, resetZoom, zoomIn, zoomOut }
+export { webviewZoom, resetZoom, setPinchZoomEnabled, zoomIn, zoomOut }