fix(app): safety triangle for sidebar hover (#12179)
This commit is contained in:
@@ -58,6 +58,7 @@ import { usePermission } from "@/context/permission"
|
|||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { retry } from "@opencode-ai/util/retry"
|
import { retry } from "@opencode-ai/util/retry"
|
||||||
import { playSound, soundSrc } from "@/utils/sound"
|
import { playSound, soundSrc } from "@/utils/sound"
|
||||||
|
import { createAim } from "@/utils/aim"
|
||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
import { agentColor } from "@/utils/agent"
|
import { agentColor } from "@/utils/agent"
|
||||||
|
|
||||||
@@ -146,9 +147,20 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
const navLeave = { current: undefined as number | undefined }
|
const navLeave = { current: undefined as number | undefined }
|
||||||
|
|
||||||
|
const aim = createAim({
|
||||||
|
enabled: () => !layout.sidebar.opened(),
|
||||||
|
active: () => state.hoverProject,
|
||||||
|
el: () => state.nav,
|
||||||
|
onActivate: (directory) => {
|
||||||
|
globalSync.child(directory)
|
||||||
|
setState("hoverProject", directory)
|
||||||
|
setState("hoverSession", undefined)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (navLeave.current === undefined) return
|
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||||
clearTimeout(navLeave.current)
|
aim.reset()
|
||||||
})
|
})
|
||||||
|
|
||||||
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
|
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
|
||||||
@@ -162,15 +174,22 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!layout.sidebar.opened()) return
|
if (!layout.sidebar.opened()) return
|
||||||
|
aim.reset()
|
||||||
setState("hoverProject", undefined)
|
setState("hoverProject", undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (state.hoverProject !== undefined) return
|
||||||
|
aim.reset()
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => ({ dir: params.dir, id: params.id }),
|
() => ({ dir: params.dir, id: params.id }),
|
||||||
() => {
|
() => {
|
||||||
if (layout.sidebar.opened()) return
|
if (layout.sidebar.opened()) return
|
||||||
if (!state.hoverProject) return
|
if (!state.hoverProject) return
|
||||||
|
aim.reset()
|
||||||
setState("hoverSession", undefined)
|
setState("hoverSession", undefined)
|
||||||
setState("hoverProject", undefined)
|
setState("hoverProject", undefined)
|
||||||
},
|
},
|
||||||
@@ -2311,17 +2330,17 @@ export default function Layout(props: ParentProps) {
|
|||||||
!selected() && !active(),
|
!selected() && !active(),
|
||||||
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
|
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={(event: MouseEvent) => {
|
||||||
if (!overlay()) return
|
if (!overlay()) return
|
||||||
globalSync.child(props.project.worktree)
|
aim.enter(props.project.worktree, event)
|
||||||
setState("hoverProject", props.project.worktree)
|
}}
|
||||||
setState("hoverSession", undefined)
|
onMouseLeave={() => {
|
||||||
|
if (!overlay()) return
|
||||||
|
aim.leave(props.project.worktree)
|
||||||
}}
|
}}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (!overlay()) return
|
if (!overlay()) return
|
||||||
globalSync.child(props.project.worktree)
|
aim.activate(props.project.worktree)
|
||||||
setState("hoverProject", props.project.worktree)
|
|
||||||
setState("hoverSession", undefined)
|
|
||||||
}}
|
}}
|
||||||
onClick={() => navigateToProject(props.project.worktree)}
|
onClick={() => navigateToProject(props.project.worktree)}
|
||||||
onBlur={() => setOpen(false)}
|
onBlur={() => setOpen(false)}
|
||||||
@@ -2806,7 +2825,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex h-full w-full overflow-hidden">
|
<div class="flex h-full w-full overflow-hidden">
|
||||||
<div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden">
|
<div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden" onMouseMove={aim.move}>
|
||||||
<div class="flex-1 min-h-0 w-full">
|
<div class="flex-1 min-h-0 w-full">
|
||||||
<DragDropProvider
|
<DragDropProvider
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
@@ -2901,6 +2920,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
navLeave.current = undefined
|
navLeave.current = undefined
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
|
aim.reset()
|
||||||
if (!sidebarHovering()) return
|
if (!sidebarHovering()) return
|
||||||
|
|
||||||
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
|
||||||
@@ -2916,7 +2936,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
</div>
|
</div>
|
||||||
<Show when={!layout.sidebar.opened() ? hoverProjectData() : undefined} keyed>
|
<Show when={!layout.sidebar.opened() ? hoverProjectData() : undefined} keyed>
|
||||||
{(project) => (
|
{(project) => (
|
||||||
<div class="absolute inset-y-0 left-16 z-50 flex">
|
<div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
|
||||||
<SidebarPanel project={project} />
|
<SidebarPanel project={project} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
138
packages/app/src/utils/aim.ts
Normal file
138
packages/app/src/utils/aim.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
type Point = { x: number; y: number }
|
||||||
|
|
||||||
|
export function createAim(props: {
|
||||||
|
enabled: () => boolean
|
||||||
|
active: () => string | undefined
|
||||||
|
el: () => HTMLElement | undefined
|
||||||
|
onActivate: (id: string) => void
|
||||||
|
delay?: number
|
||||||
|
max?: number
|
||||||
|
tolerance?: number
|
||||||
|
edge?: number
|
||||||
|
}) {
|
||||||
|
const state = {
|
||||||
|
locs: [] as Point[],
|
||||||
|
timer: undefined as number | undefined,
|
||||||
|
pending: undefined as string | undefined,
|
||||||
|
over: undefined as string | undefined,
|
||||||
|
last: undefined as Point | undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = props.delay ?? 250
|
||||||
|
const max = props.max ?? 4
|
||||||
|
const tolerance = props.tolerance ?? 80
|
||||||
|
const edge = props.edge ?? 18
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
if (state.timer !== undefined) clearTimeout(state.timer)
|
||||||
|
state.timer = undefined
|
||||||
|
state.pending = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
cancel()
|
||||||
|
state.over = undefined
|
||||||
|
state.last = undefined
|
||||||
|
state.locs.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const move = (event: MouseEvent) => {
|
||||||
|
if (!props.enabled()) return
|
||||||
|
const el = props.el()
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const x = event.clientX
|
||||||
|
const y = event.clientY
|
||||||
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return
|
||||||
|
|
||||||
|
state.locs.push({ x, y })
|
||||||
|
if (state.locs.length > max) state.locs.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
const wait = () => {
|
||||||
|
if (!props.enabled()) return 0
|
||||||
|
if (!props.active()) return 0
|
||||||
|
|
||||||
|
const el = props.el()
|
||||||
|
if (!el) return 0
|
||||||
|
if (state.locs.length < 2) return 0
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const loc = state.locs[state.locs.length - 1]
|
||||||
|
if (!loc) return 0
|
||||||
|
|
||||||
|
const prev = state.locs[0] ?? loc
|
||||||
|
if (prev.x < rect.left || prev.x > rect.right || prev.y < rect.top || prev.y > rect.bottom) return 0
|
||||||
|
if (state.last && loc.x === state.last.x && loc.y === state.last.y) return 0
|
||||||
|
|
||||||
|
if (rect.right - loc.x <= edge) {
|
||||||
|
state.last = loc
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
|
||||||
|
const upper = { x: rect.right, y: rect.top - tolerance }
|
||||||
|
const lower = { x: rect.right, y: rect.bottom + tolerance }
|
||||||
|
const slope = (a: Point, b: Point) => (b.y - a.y) / (b.x - a.x)
|
||||||
|
|
||||||
|
const decreasing = slope(loc, upper)
|
||||||
|
const increasing = slope(loc, lower)
|
||||||
|
const prevDecreasing = slope(prev, upper)
|
||||||
|
const prevIncreasing = slope(prev, lower)
|
||||||
|
|
||||||
|
if (decreasing < prevDecreasing && increasing > prevIncreasing) {
|
||||||
|
state.last = loc
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
|
||||||
|
state.last = undefined
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const activate = (id: string) => {
|
||||||
|
cancel()
|
||||||
|
props.onActivate(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = (id: string) => {
|
||||||
|
if (!id) return
|
||||||
|
if (props.active() === id) return
|
||||||
|
|
||||||
|
if (!props.active()) {
|
||||||
|
activate(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ms = wait()
|
||||||
|
if (ms === 0) {
|
||||||
|
activate(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
state.pending = id
|
||||||
|
state.timer = window.setTimeout(() => {
|
||||||
|
state.timer = undefined
|
||||||
|
if (state.pending !== id) return
|
||||||
|
state.pending = undefined
|
||||||
|
if (!props.enabled()) return
|
||||||
|
if (!props.active()) return
|
||||||
|
if (state.over !== id) return
|
||||||
|
props.onActivate(id)
|
||||||
|
}, ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enter = (id: string, event: MouseEvent) => {
|
||||||
|
if (!props.enabled()) return
|
||||||
|
state.over = id
|
||||||
|
move(event)
|
||||||
|
request(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const leave = (id: string) => {
|
||||||
|
if (state.over === id) state.over = undefined
|
||||||
|
if (state.pending === id) cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { move, enter, leave, activate, request, cancel, reset }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user