feat: allow moving floating terminal trigger (#2636)
This commit is contained in:
parent
d121f911f9
commit
810302f892
|
|
@ -1647,9 +1647,6 @@ function App(): React.JSX.Element {
|
|||
</div>
|
||||
{showFloatingTerminalButton ? (
|
||||
<FloatingTerminalToggleButton
|
||||
// Why: anchor the floating trigger to the center surface so it
|
||||
// cannot cover the worktree sidebar or right sidebar.
|
||||
className="absolute bottom-3 right-3"
|
||||
open={floatingTerminalOpen}
|
||||
onToggle={() => setFloatingTerminalOpenWithFocus((open) => !open)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -14,12 +14,14 @@ type FloatingTerminalIconContextMenuProps = {
|
|||
children: React.ReactNode
|
||||
currentLocation: FloatingTerminalTriggerLocation
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function FloatingTerminalIconContextMenu({
|
||||
children,
|
||||
currentLocation,
|
||||
className
|
||||
className,
|
||||
style
|
||||
}: FloatingTerminalIconContextMenuProps): React.JSX.Element {
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
|
@ -44,6 +46,7 @@ export function FloatingTerminalIconContextMenu({
|
|||
<>
|
||||
<span
|
||||
className={className}
|
||||
style={style}
|
||||
data-floating-terminal-toggle
|
||||
onContextMenuCapture={(event) => {
|
||||
// Why: workspace cards use DropdownMenu anchored at the cursor for
|
||||
|
|
|
|||
|
|
@ -1,24 +1,131 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { PanelsTopLeft } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FloatingTerminalIconContextMenu } from './FloatingTerminalIconContextMenu'
|
||||
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import {
|
||||
clampFloatingTerminalTriggerPosition,
|
||||
getDefaultFloatingTerminalTriggerPosition,
|
||||
parseFloatingTerminalTriggerPosition,
|
||||
type FloatingTerminalTriggerPosition
|
||||
} from './floating-terminal-trigger-position'
|
||||
|
||||
// Why: v2 resets older parked positions that sat too low over bottom bars.
|
||||
const FLOATING_TERMINAL_TRIGGER_POSITION_STORAGE_KEY = 'orca-floating-terminal-trigger-position-v2'
|
||||
const FLOATING_TERMINAL_TRIGGER_DRAG_THRESHOLD = 4
|
||||
|
||||
function readInitialTriggerPosition(): FloatingTerminalTriggerPosition {
|
||||
if (typeof window === 'undefined') {
|
||||
return getDefaultFloatingTerminalTriggerPosition()
|
||||
}
|
||||
return (
|
||||
parseFloatingTerminalTriggerPosition(
|
||||
window.localStorage.getItem(FLOATING_TERMINAL_TRIGGER_POSITION_STORAGE_KEY)
|
||||
) ?? getDefaultFloatingTerminalTriggerPosition()
|
||||
)
|
||||
}
|
||||
|
||||
function persistTriggerPosition(position: FloatingTerminalTriggerPosition): void {
|
||||
window.localStorage.setItem(
|
||||
FLOATING_TERMINAL_TRIGGER_POSITION_STORAGE_KEY,
|
||||
JSON.stringify(position)
|
||||
)
|
||||
}
|
||||
|
||||
export function FloatingTerminalToggleButton({
|
||||
open,
|
||||
onToggle,
|
||||
className
|
||||
onToggle
|
||||
}: {
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
const shortcutLabel = useShortcutLabel('floatingTerminal.toggle')
|
||||
const [position, setPosition] = useState(readInitialTriggerPosition)
|
||||
const dragRef = useRef<{
|
||||
pointerId: number
|
||||
startX: number
|
||||
startY: number
|
||||
left: number
|
||||
top: number
|
||||
moved: boolean
|
||||
} | null>(null)
|
||||
const suppressClickRef = useRef(false)
|
||||
|
||||
const updatePosition = useCallback((nextPosition: FloatingTerminalTriggerPosition): void => {
|
||||
const clamped = clampFloatingTerminalTriggerPosition(nextPosition)
|
||||
setPosition(clamped)
|
||||
persistTriggerPosition(clamped)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = (): void => {
|
||||
setPosition((current) => {
|
||||
const clamped = clampFloatingTerminalTriggerPosition(current)
|
||||
persistTriggerPosition(clamped)
|
||||
return clamped
|
||||
})
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>): void => {
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
left: position.left,
|
||||
top: position.top,
|
||||
moved: false
|
||||
}
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent<HTMLButtonElement>): void => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || drag.pointerId !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
const dx = event.clientX - drag.startX
|
||||
const dy = event.clientY - drag.startY
|
||||
if (!drag.moved && Math.hypot(dx, dy) < FLOATING_TERMINAL_TRIGGER_DRAG_THRESHOLD) {
|
||||
return
|
||||
}
|
||||
drag.moved = true
|
||||
updatePosition({
|
||||
left: drag.left + dx,
|
||||
top: drag.top + dy
|
||||
})
|
||||
}
|
||||
|
||||
const handlePointerEnd = (event: React.PointerEvent<HTMLButtonElement>): void => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || drag.pointerId !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
suppressClickRef.current = drag.moved
|
||||
dragRef.current = null
|
||||
}
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
if (suppressClickRef.current) {
|
||||
suppressClickRef.current = false
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
onToggle()
|
||||
}
|
||||
|
||||
return (
|
||||
<FloatingTerminalIconContextMenu
|
||||
currentLocation="floating-button"
|
||||
className={cn('fixed bottom-3 right-3 z-40', className)}
|
||||
className="fixed z-40"
|
||||
style={{ left: position.left, top: position.top }}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -26,11 +133,15 @@ export function FloatingTerminalToggleButton({
|
|||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="border-border bg-secondary text-secondary-foreground shadow-xs hover:bg-accent hover:text-accent-foreground"
|
||||
className="cursor-grab border-border bg-secondary text-secondary-foreground shadow-xs hover:bg-accent hover:text-accent-foreground active:cursor-grabbing"
|
||||
data-floating-terminal-toggle
|
||||
aria-label={open ? 'Minimize floating workspace' : 'Show floating workspace'}
|
||||
aria-pressed={open}
|
||||
onClick={onToggle}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<PanelsTopLeft className="size-3.5" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
clampFloatingTerminalTriggerPosition,
|
||||
getDefaultFloatingTerminalTriggerPosition,
|
||||
parseFloatingTerminalTriggerPosition
|
||||
} from './floating-terminal-trigger-position'
|
||||
|
||||
function stubViewport(width: number, height: number): void {
|
||||
vi.stubGlobal('window', { innerWidth: width, innerHeight: height })
|
||||
}
|
||||
|
||||
describe('floating terminal trigger position', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('defaults to the bottom right of the viewport', () => {
|
||||
stubViewport(1200, 800)
|
||||
|
||||
expect(getDefaultFloatingTerminalTriggerPosition()).toEqual({
|
||||
left: 1144,
|
||||
top: 696
|
||||
})
|
||||
})
|
||||
|
||||
it('clamps parked positions into the viewport', () => {
|
||||
stubViewport(640, 480)
|
||||
|
||||
expect(clampFloatingTerminalTriggerPosition({ left: 900, top: -20 })).toEqual({
|
||||
left: 600,
|
||||
top: 36
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores malformed persisted positions', () => {
|
||||
stubViewport(640, 480)
|
||||
|
||||
expect(parseFloatingTerminalTriggerPosition('not-json')).toBeNull()
|
||||
expect(parseFloatingTerminalTriggerPosition('{"left":"1","top":2}')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
const TRIGGER_SIZE = 32
|
||||
const DEFAULT_RIGHT_GAP = 24
|
||||
const DEFAULT_BOTTOM_GAP = 72
|
||||
const DRAG_MARGIN = 8
|
||||
const TITLEBAR_SAFE_TOP = 36
|
||||
|
||||
export type FloatingTerminalTriggerPosition = {
|
||||
left: number
|
||||
top: number
|
||||
}
|
||||
|
||||
function getViewport(): { width: number; height: number } {
|
||||
return {
|
||||
width: typeof window === 'undefined' ? 1200 : window.innerWidth,
|
||||
height: typeof window === 'undefined' ? 800 : window.innerHeight
|
||||
}
|
||||
}
|
||||
|
||||
function isFiniteCoordinate(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
}
|
||||
|
||||
export function getDefaultFloatingTerminalTriggerPosition(): FloatingTerminalTriggerPosition {
|
||||
const viewport = getViewport()
|
||||
return {
|
||||
left: Math.max(DRAG_MARGIN, viewport.width - TRIGGER_SIZE - DEFAULT_RIGHT_GAP),
|
||||
top: Math.max(TITLEBAR_SAFE_TOP, viewport.height - TRIGGER_SIZE - DEFAULT_BOTTOM_GAP)
|
||||
}
|
||||
}
|
||||
|
||||
export function clampFloatingTerminalTriggerPosition(
|
||||
position: FloatingTerminalTriggerPosition
|
||||
): FloatingTerminalTriggerPosition {
|
||||
const viewport = getViewport()
|
||||
const maxLeft = Math.max(DRAG_MARGIN, viewport.width - TRIGGER_SIZE - DRAG_MARGIN)
|
||||
const maxTop = Math.max(TITLEBAR_SAFE_TOP, viewport.height - TRIGGER_SIZE - DRAG_MARGIN)
|
||||
return {
|
||||
left: Math.min(Math.max(DRAG_MARGIN, position.left), maxLeft),
|
||||
top: Math.min(Math.max(TITLEBAR_SAFE_TOP, position.top), maxTop)
|
||||
}
|
||||
}
|
||||
|
||||
export function parseFloatingTerminalTriggerPosition(
|
||||
serialized: string | null
|
||||
): FloatingTerminalTriggerPosition | null {
|
||||
if (!serialized) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(serialized) as Record<string, unknown>
|
||||
if (!isFiniteCoordinate(parsed.left) || !isFiniteCoordinate(parsed.top)) {
|
||||
return null
|
||||
}
|
||||
return clampFloatingTerminalTriggerPosition({ left: parsed.left, top: parsed.top })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue