diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 1c5edbf2b445..4ee54d306504 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -58,6 +58,7 @@ import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { playSound, soundSrc } from "@/utils/sound" +import { createAim } from "@/utils/aim" import { Worktree as WorktreeState } from "@/utils/worktree" import { agentColor } from "@/utils/agent" @@ -146,9 +147,20 @@ export default function Layout(props: ParentProps) { 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(() => { - if (navLeave.current === undefined) return - clearTimeout(navLeave.current) + if (navLeave.current !== undefined) clearTimeout(navLeave.current) + aim.reset() }) const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) @@ -162,15 +174,22 @@ export default function Layout(props: ParentProps) { createEffect(() => { if (!layout.sidebar.opened()) return + aim.reset() setState("hoverProject", undefined) }) + createEffect(() => { + if (state.hoverProject !== undefined) return + aim.reset() + }) + createEffect( on( () => ({ dir: params.dir, id: params.id }), () => { if (layout.sidebar.opened()) return if (!state.hoverProject) return + aim.reset() setState("hoverSession", undefined) setState("hoverProject", undefined) }, @@ -2311,17 +2330,17 @@ export default function Layout(props: ParentProps) { !selected() && !active(), "bg-surface-base-hover border border-border-weak-base": !selected() && active(), }} - onMouseEnter={() => { + onMouseEnter={(event: MouseEvent) => { + if (!overlay()) return + aim.enter(props.project.worktree, event) + }} + onMouseLeave={() => { if (!overlay()) return - globalSync.child(props.project.worktree) - setState("hoverProject", props.project.worktree) - setState("hoverSession", undefined) + aim.leave(props.project.worktree) }} onFocus={() => { if (!overlay()) return - globalSync.child(props.project.worktree) - setState("hoverProject", props.project.worktree) - setState("hoverSession", undefined) + aim.activate(props.project.worktree) }} onClick={() => navigateToProject(props.project.worktree)} onBlur={() => setOpen(false)} @@ -2806,7 +2825,7 @@ export default function Layout(props: ParentProps) { return (
-
+
{ + aim.reset() if (!sidebarHovering()) return if (navLeave.current !== undefined) clearTimeout(navLeave.current) @@ -2916,7 +2936,7 @@ export default function Layout(props: ParentProps) {
{(project) => ( -
+
)} diff --git a/packages/app/src/utils/aim.ts b/packages/app/src/utils/aim.ts new file mode 100644 index 000000000000..23471959e168 --- /dev/null +++ b/packages/app/src/utils/aim.ts @@ -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 } +}