Merge desktop zoom shortcut support

Bring the detached worktree implementation for issue #407 back into
local main while preserving a merge boundary for review and rollback.

Constraint: Local main already contains post-release-boundary desktop fixes
Confidence: high
Scope-risk: narrow
Tested: bun run verify in detached worktree before merge
Not-tested: Full gate on local main before this merge commit
This commit is contained in:
程序员阿江(Relakkes) 2026-05-13 12:43:17 +08:00
commit 5d66b5a94b
8 changed files with 366 additions and 1 deletions

View File

@ -837,6 +837,14 @@ fn open_windows_notification_settings() -> Result<bool, String> {
open_windows_notification_settings_impl()
}
#[tauri::command]
fn set_app_zoom(window: tauri::WebviewWindow, zoom_factor: f64) -> Result<(), String> {
let clamped = zoom_factor.clamp(0.5, 2.0);
window
.set_zoom(clamped)
.map_err(|err| format!("set app zoom: {err}"))
}
#[cfg(target_os = "windows")]
fn open_windows_notification_settings_impl() -> Result<bool, String> {
StdCommand::new("explorer.exe")
@ -1617,7 +1625,8 @@ pub fn run() {
macos_notification_permission_state,
macos_request_notification_permission,
macos_send_notification,
open_windows_notification_settings
open_windows_notification_settings,
set_app_zoom
]);
// macOS: native menu bar (traffic-light overlay style)

View File

@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { APP_ZOOM_STORAGE_KEY } from '../lib/appZoom'
import { useKeyboardShortcuts } from './useKeyboardShortcuts'
function ShortcutHost() {
useKeyboardShortcuts()
return null
}
function setNavigatorPlatform(platform: string) {
Object.defineProperty(window.navigator, 'platform', {
configurable: true,
value: platform,
})
}
describe('useKeyboardShortcuts app zoom', () => {
beforeEach(() => {
window.localStorage.clear()
document.documentElement.removeAttribute('data-app-zoom-mode')
document.documentElement.removeAttribute('data-app-zoom-percent')
document.documentElement.style.removeProperty('--app-zoom')
document.body.style.removeProperty('zoom')
setNavigatorPlatform('Win32')
})
afterEach(() => {
cleanup()
})
it('handles Ctrl zoom shortcuts on Windows and Linux style platforms', async () => {
render(<ShortcutHost />)
fireEvent.keyDown(document, {
code: 'Equal',
ctrlKey: true,
key: '=',
})
await waitFor(() => {
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.1')
})
expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('110')
fireEvent.keyDown(document, {
code: 'Minus',
ctrlKey: true,
key: '-',
})
await waitFor(() => {
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1')
})
fireEvent.keyDown(document, {
code: 'NumpadAdd',
ctrlKey: true,
key: '+',
})
await waitFor(() => {
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.1')
})
fireEvent.keyDown(document, {
code: 'Digit0',
ctrlKey: true,
key: '0',
})
await waitFor(() => {
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1')
})
})
it('uses Cmd zoom shortcuts on macOS', async () => {
setNavigatorPlatform('MacIntel')
render(<ShortcutHost />)
fireEvent.keyDown(document, {
code: 'Minus',
key: '-',
metaKey: true,
})
await waitFor(() => {
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('0.9')
})
fireEvent.keyDown(document, {
code: 'Equal',
ctrlKey: true,
key: '=',
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('0.9')
})
})

View File

@ -3,6 +3,13 @@ import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore'
import { useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
import {
applyAppZoomLevel,
getAppZoomKeyboardAction,
initializeAppZoom,
nextAppZoomLevel,
readStoredAppZoomLevel,
} from '../lib/appZoom'
export function useKeyboardShortcuts() {
const setActiveSession = useSessionStore((s) => s.setActiveSession)
@ -20,9 +27,21 @@ export function useKeyboardShortcuts() {
chatStateRef.current = chatState
const activeTabIdRef = useRef(activeTabId)
activeTabIdRef.current = activeTabId
const appZoomLevelRef = useRef(readStoredAppZoomLevel())
useEffect(() => {
void initializeAppZoom()
const handler = (e: KeyboardEvent) => {
const zoomAction = getAppZoomKeyboardAction(e)
if (zoomAction) {
e.preventDefault()
const nextZoom = nextAppZoomLevel(appZoomLevelRef.current, zoomAction)
appZoomLevelRef.current = nextZoom
void applyAppZoomLevel(nextZoom)
return
}
const meta = e.metaKey || e.ctrlKey
// Cmd+N — New session

View File

@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
APP_ZOOM_STORAGE_KEY,
applyAppZoomLevel,
getAppZoomKeyboardAction,
initializeAppZoom,
nextAppZoomLevel,
normalizeAppZoomLevel,
} from './appZoom'
describe('appZoom', () => {
beforeEach(() => {
window.localStorage.clear()
document.documentElement.removeAttribute('data-app-zoom-mode')
document.documentElement.removeAttribute('data-app-zoom-percent')
document.documentElement.style.removeProperty('--app-zoom')
document.body.style.removeProperty('zoom')
})
it('normalizes, clamps, and steps app zoom levels', () => {
expect(normalizeAppZoomLevel('1.25')).toBe(1.25)
expect(normalizeAppZoomLevel('bad')).toBe(1)
expect(normalizeAppZoomLevel(4)).toBe(2)
expect(normalizeAppZoomLevel(0.1)).toBe(0.5)
expect(nextAppZoomLevel(1, 'in')).toBe(1.1)
expect(nextAppZoomLevel(1, 'out')).toBe(0.9)
expect(nextAppZoomLevel(1.7, 'reset')).toBe(1)
})
it('applies browser fallback zoom and preserves valid persisted zoom', async () => {
window.localStorage.setItem(APP_ZOOM_STORAGE_KEY, '1.2')
await initializeAppZoom()
expect(document.documentElement.getAttribute('data-app-zoom-mode')).toBe('css')
expect(document.documentElement.getAttribute('data-app-zoom-percent')).toBe('120')
expect(document.documentElement.style.getPropertyValue('--app-zoom')).toBe('1.2')
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.2')
})
it('persists app zoom changes', async () => {
await applyAppZoomLevel(1.3)
expect(window.localStorage.getItem(APP_ZOOM_STORAGE_KEY)).toBe('1.3')
expect(document.documentElement.style.getPropertyValue('--app-zoom')).toBe('1.3')
})
it('maps IDE-style zoom shortcuts by platform', () => {
expect(getAppZoomKeyboardAction({
altKey: false,
code: 'Equal',
ctrlKey: false,
key: '=',
metaKey: true,
} as KeyboardEvent, 'MacIntel')).toBe('in')
expect(getAppZoomKeyboardAction({
altKey: false,
code: 'Minus',
ctrlKey: true,
key: '-',
metaKey: false,
} as KeyboardEvent, 'Win32')).toBe('out')
expect(getAppZoomKeyboardAction({
altKey: false,
code: 'Numpad0',
ctrlKey: true,
key: '0',
metaKey: false,
} as KeyboardEvent, 'Linux x86_64')).toBe('reset')
expect(getAppZoomKeyboardAction({
altKey: true,
code: 'Equal',
ctrlKey: true,
key: '=',
metaKey: false,
} as KeyboardEvent, 'Win32')).toBeNull()
})
})

130
desktop/src/lib/appZoom.ts Normal file
View File

@ -0,0 +1,130 @@
export const APP_ZOOM_STORAGE_KEY = 'cc-haha-app-zoom'
export const DEFAULT_APP_ZOOM = 1
export const MIN_APP_ZOOM = 0.5
export const MAX_APP_ZOOM = 2
export const APP_ZOOM_STEP = 0.1
export type AppZoomAction = 'in' | 'out' | 'reset'
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
type KeyboardShortcutInput = Pick<KeyboardEvent, 'altKey' | 'code' | 'ctrlKey' | 'key' | 'metaKey'>
function isTauriRuntime() {
return typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
}
function getDefaultStorage(): StorageLike | null {
try {
return globalThis.localStorage ?? null
} catch {
return null
}
}
function roundZoom(value: number): number {
return Math.round(value * 100) / 100
}
export function normalizeAppZoomLevel(value: unknown): number {
const numeric = typeof value === 'number'
? value
: typeof value === 'string' && value.trim() !== ''
? Number(value)
: DEFAULT_APP_ZOOM
if (!Number.isFinite(numeric)) return DEFAULT_APP_ZOOM
return roundZoom(Math.min(Math.max(numeric, MIN_APP_ZOOM), MAX_APP_ZOOM))
}
export function isValidStoredAppZoomLevel(value: string | null): boolean {
if (value === null) return true
if (value.trim() === '') return false
const numeric = Number(value)
return Number.isFinite(numeric) && numeric >= MIN_APP_ZOOM && numeric <= MAX_APP_ZOOM
}
export function readStoredAppZoomLevel(storage: StorageLike | null = getDefaultStorage()): number {
if (!storage) return DEFAULT_APP_ZOOM
try {
return normalizeAppZoomLevel(storage.getItem(APP_ZOOM_STORAGE_KEY))
} catch {
return DEFAULT_APP_ZOOM
}
}
function persistAppZoomLevel(level: number, storage: StorageLike | null = getDefaultStorage()) {
if (!storage) return
try {
storage.setItem(APP_ZOOM_STORAGE_KEY, String(level))
} catch {
// localStorage can be unavailable in hardened browser contexts.
}
}
function setCssAppZoomMode(level: number, mode: 'css' | 'native') {
if (typeof document === 'undefined') return
document.documentElement.style.setProperty('--app-zoom', String(level))
document.documentElement.setAttribute('data-app-zoom-mode', mode)
document.documentElement.setAttribute('data-app-zoom-percent', String(Math.round(level * 100)))
document.body?.style.setProperty('zoom', mode === 'css' ? String(level) : '')
}
async function trySetNativeAppZoom(level: number): Promise<boolean> {
if (!isTauriRuntime()) return false
try {
const { invoke } = await import('@tauri-apps/api/core')
await invoke('set_app_zoom', { zoomFactor: level })
setCssAppZoomMode(level, 'native')
return true
} catch {
return false
}
}
export async function applyAppZoomLevel(
input: number,
options: { persist?: boolean } = {},
): Promise<number> {
const level = normalizeAppZoomLevel(input)
if (options.persist !== false) {
persistAppZoomLevel(level)
}
const nativeApplied = await trySetNativeAppZoom(level)
if (!nativeApplied) {
setCssAppZoomMode(level, 'css')
}
return level
}
export function nextAppZoomLevel(current: number, action: AppZoomAction): number {
if (action === 'reset') return DEFAULT_APP_ZOOM
const delta = action === 'in' ? APP_ZOOM_STEP : -APP_ZOOM_STEP
return normalizeAppZoomLevel(roundZoom(current + delta))
}
export function getAppZoomKeyboardAction(
event: KeyboardShortcutInput,
platform: string = typeof navigator === 'undefined' ? '' : navigator.platform,
): AppZoomAction | null {
if (event.altKey) return null
const isMac = /mac/i.test(platform)
const hasPrimaryModifier = isMac ? event.metaKey : event.ctrlKey
if (!hasPrimaryModifier) return null
const key = event.key.toLowerCase()
if (key === '+' || key === '=' || event.code === 'Equal' || event.code === 'NumpadAdd') {
return 'in'
}
if (key === '-' || event.code === 'Minus' || event.code === 'NumpadSubtract') {
return 'out'
}
if (key === '0' || event.code === 'Digit0' || event.code === 'Numpad0') {
return 'reset'
}
return null
}
export function initializeAppZoom(): Promise<number> {
return applyAppZoomLevel(readStoredAppZoomLevel(), { persist: false })
}

View File

@ -1,4 +1,5 @@
import { doctorApi, type DoctorReportRepairResponse } from '../api/doctor'
import { APP_ZOOM_STORAGE_KEY } from './appZoom'
import { DESKTOP_PERSISTENCE_VERSION_KEY } from './persistenceMigrations'
export const SAFE_DOCTOR_STORAGE_KEYS = [
@ -6,6 +7,7 @@ export const SAFE_DOCTOR_STORAGE_KEYS = [
'cc-haha-session-runtime',
'cc-haha-theme',
'cc-haha-locale',
APP_ZOOM_STORAGE_KEY,
DESKTOP_PERSISTENCE_VERSION_KEY,
] as const

View File

@ -65,6 +65,22 @@ describe('desktop persistence migrations', () => {
expect(window.localStorage.getItem('cc-haha-theme')).toBe('white')
})
test('preserves valid app zoom and removes invalid app zoom values', () => {
window.localStorage.setItem('cc-haha-app-zoom', '1.2')
const validReport = runDesktopPersistenceMigrations()
expect(validReport.migratedKeys).not.toContain('cc-haha-app-zoom')
expect(window.localStorage.getItem('cc-haha-app-zoom')).toBe('1.2')
window.localStorage.setItem('cc-haha-app-zoom', '4')
const invalidReport = runDesktopPersistenceMigrations()
expect(invalidReport.migratedKeys).toContain('cc-haha-app-zoom')
expect(window.localStorage.getItem('cc-haha-app-zoom')).toBeNull()
})
test('does not throw if schema version persistence is blocked', () => {
const storage = {
getItem: window.localStorage.getItem.bind(window.localStorage),
@ -101,6 +117,7 @@ describe('desktop persistence migrations', () => {
'cc-haha-session-runtime',
'cc-haha-theme',
'cc-haha-locale',
'cc-haha-app-zoom',
DESKTOP_PERSISTENCE_VERSION_KEY,
]))
})

View File

@ -1,4 +1,5 @@
import { THEME_MODES } from '../types/settings'
import { APP_ZOOM_STORAGE_KEY, isValidStoredAppZoomLevel } from './appZoom'
export const CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION = 1
export const DESKTOP_PERSISTENCE_VERSION_KEY = 'cc-haha.persistence.schemaVersion'
@ -114,6 +115,14 @@ function normalizeEnumKey(
}
}
function normalizeAppZoomKey(storage: StorageLike, report: DesktopMigrationReport): void {
const value = storage.getItem(APP_ZOOM_STORAGE_KEY)
if (!isValidStoredAppZoomLevel(value)) {
storage.removeItem(APP_ZOOM_STORAGE_KEY)
report.migratedKeys.push(APP_ZOOM_STORAGE_KEY)
}
}
function runMigrationStep(
report: DesktopMigrationReport,
fallbackKey: string,
@ -142,6 +151,7 @@ export function runDesktopPersistenceMigrations(storage: StorageLike | null = ge
runMigrationStep(report, SESSION_RUNTIME_STORAGE_KEY, () => migrateSessionRuntime(storage, report))
runMigrationStep(report, THEME_STORAGE_KEY, () => normalizeEnumKey(storage, THEME_STORAGE_KEY, [...THEME_MODES], report))
runMigrationStep(report, LOCALE_STORAGE_KEY, () => normalizeEnumKey(storage, LOCALE_STORAGE_KEY, ['zh', 'en'], report))
runMigrationStep(report, APP_ZOOM_STORAGE_KEY, () => normalizeAppZoomKey(storage, report))
try {
storage.setItem(DESKTOP_PERSISTENCE_VERSION_KEY, String(CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION))
} catch {