mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
commit
5d66b5a94b
@ -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)
|
||||
|
||||
99
desktop/src/hooks/useKeyboardShortcuts.test.tsx
Normal file
99
desktop/src/hooks/useKeyboardShortcuts.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
79
desktop/src/lib/appZoom.test.ts
Normal file
79
desktop/src/lib/appZoom.test.ts
Normal 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
130
desktop/src/lib/appZoom.ts
Normal 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 })
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
]))
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user