Share macOS Option-key tracking
Replaces per-card macOS Option-key listeners with one shared external store while preserving quick-action behavior.
This commit is contained in:
parent
b1973657ea
commit
98822fdf7e
|
|
@ -48,6 +48,7 @@ import { hasActiveWorkspaceActivity } from '@/lib/worktree-activity-state'
|
|||
import { runWorktreeDelete } from './delete-worktree-flow'
|
||||
import { runSleepWorktree } from './sleep-worktree-flow'
|
||||
import { getWorkspaceQuickActionKind } from './worktree-card-quick-action'
|
||||
import { useMacOptionKeyPressed } from './mac-option-key-state'
|
||||
|
||||
type WorktreeCardProps = {
|
||||
worktree: Worktree
|
||||
|
|
@ -160,26 +161,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
|||
})
|
||||
const isSshDisconnected = sshStatus != null && sshStatus !== 'connected'
|
||||
const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false)
|
||||
const [isMacOptionPressed, setIsMacOptionPressed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
if (!isMac) {
|
||||
return
|
||||
}
|
||||
const handleKeyChange = (event: KeyboardEvent): void => {
|
||||
setIsMacOptionPressed(event.altKey)
|
||||
}
|
||||
const handleWindowBlur = (): void => setIsMacOptionPressed(false)
|
||||
window.addEventListener('keydown', handleKeyChange, true)
|
||||
window.addEventListener('keyup', handleKeyChange, true)
|
||||
window.addEventListener('blur', handleWindowBlur)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyChange, true)
|
||||
window.removeEventListener('keyup', handleKeyChange, true)
|
||||
window.removeEventListener('blur', handleWindowBlur)
|
||||
}
|
||||
}, [])
|
||||
const isMacOptionPressed = useMacOptionKeyPressed()
|
||||
|
||||
// Why: on restart the previously-active worktree is auto-restored without a
|
||||
// click, so the dialog never opens. Auto-show it for the active card when SSH
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type StoredListener = (event: KeyboardEvent) => void
|
||||
|
||||
function createWindowStub(): {
|
||||
addEventListener: ReturnType<typeof vi.fn>
|
||||
removeEventListener: ReturnType<typeof vi.fn>
|
||||
dispatch: (type: string, event: KeyboardEvent) => void
|
||||
} {
|
||||
const listeners = new Map<string, Set<StoredListener>>()
|
||||
return {
|
||||
addEventListener: vi.fn((type: string, listener: StoredListener) => {
|
||||
const bucket = listeners.get(type) ?? new Set<StoredListener>()
|
||||
bucket.add(listener)
|
||||
listeners.set(type, bucket)
|
||||
}),
|
||||
removeEventListener: vi.fn((type: string, listener: StoredListener) => {
|
||||
listeners.get(type)?.delete(listener)
|
||||
}),
|
||||
dispatch: (type, event) => {
|
||||
for (const listener of listeners.get(type) ?? []) {
|
||||
listener(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('mac option key state', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('shares one window listener set across subscribers and only notifies on value changes', async () => {
|
||||
const windowStub = createWindowStub()
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
vi.stubGlobal('window', windowStub)
|
||||
const { getMacOptionKeySnapshot, subscribeMacOptionKey } =
|
||||
await import('./mac-option-key-state')
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
|
||||
const unsubscribeFirst = subscribeMacOptionKey(first)
|
||||
const unsubscribeSecond = subscribeMacOptionKey(second)
|
||||
|
||||
expect(windowStub.addEventListener).toHaveBeenCalledTimes(3)
|
||||
windowStub.dispatch('keydown', { altKey: true } as KeyboardEvent)
|
||||
expect(getMacOptionKeySnapshot()).toBe(true)
|
||||
expect(first).toHaveBeenCalledTimes(1)
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
|
||||
windowStub.dispatch('keydown', { altKey: true } as KeyboardEvent)
|
||||
expect(first).toHaveBeenCalledTimes(1)
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
|
||||
unsubscribeFirst()
|
||||
expect(windowStub.removeEventListener).not.toHaveBeenCalled()
|
||||
windowStub.dispatch('keyup', { altKey: false } as KeyboardEvent)
|
||||
expect(first).toHaveBeenCalledTimes(1)
|
||||
expect(second).toHaveBeenCalledTimes(2)
|
||||
|
||||
unsubscribeSecond()
|
||||
expect(windowStub.removeEventListener).toHaveBeenCalledTimes(3)
|
||||
expect(getMacOptionKeySnapshot()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not attach keyboard listeners on non-mac platforms', async () => {
|
||||
const windowStub = createWindowStub()
|
||||
vi.stubGlobal('navigator', { userAgent: 'Windows' })
|
||||
vi.stubGlobal('window', windowStub)
|
||||
const { getMacOptionKeySnapshot, subscribeMacOptionKey } =
|
||||
await import('./mac-option-key-state')
|
||||
|
||||
const unsubscribe = subscribeMacOptionKey(vi.fn())
|
||||
|
||||
expect(windowStub.addEventListener).not.toHaveBeenCalled()
|
||||
expect(getMacOptionKeySnapshot()).toBe(false)
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
type OptionKeyListener = () => void
|
||||
|
||||
let optionPressed = false
|
||||
const listeners = new Set<OptionKeyListener>()
|
||||
let disposeWindowListeners: (() => void) | null = null
|
||||
|
||||
function isMacPlatform(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
|
||||
}
|
||||
|
||||
function setOptionPressed(nextPressed: boolean): void {
|
||||
if (optionPressed === nextPressed) {
|
||||
return
|
||||
}
|
||||
optionPressed = nextPressed
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
function startWindowListeners(): void {
|
||||
if (disposeWindowListeners || !isMacPlatform() || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const handleKeyChange = (event: KeyboardEvent): void => setOptionPressed(event.altKey)
|
||||
const handleWindowBlur = (): void => setOptionPressed(false)
|
||||
window.addEventListener('keydown', handleKeyChange, true)
|
||||
window.addEventListener('keyup', handleKeyChange, true)
|
||||
window.addEventListener('blur', handleWindowBlur)
|
||||
disposeWindowListeners = () => {
|
||||
window.removeEventListener('keydown', handleKeyChange, true)
|
||||
window.removeEventListener('keyup', handleKeyChange, true)
|
||||
window.removeEventListener('blur', handleWindowBlur)
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeMacOptionKey(listener: OptionKeyListener): () => void {
|
||||
if (!isMacPlatform()) {
|
||||
return () => undefined
|
||||
}
|
||||
listeners.add(listener)
|
||||
startWindowListeners()
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (listeners.size > 0) {
|
||||
return
|
||||
}
|
||||
disposeWindowListeners?.()
|
||||
disposeWindowListeners = null
|
||||
setOptionPressed(false)
|
||||
}
|
||||
}
|
||||
|
||||
export function getMacOptionKeySnapshot(): boolean {
|
||||
return isMacPlatform() ? optionPressed : false
|
||||
}
|
||||
|
||||
export function useMacOptionKeyPressed(): boolean {
|
||||
// Why: the sidebar can render dozens of cards. One shared external store
|
||||
// avoids a global key listener per card and only re-renders on Option flips.
|
||||
return useSyncExternalStore(subscribeMacOptionKey, getMacOptionKeySnapshot, () => false)
|
||||
}
|
||||
Loading…
Reference in New Issue