Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 31 additions & 11 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand All @@ -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)
},
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -2806,7 +2825,7 @@ export default function Layout(props: ParentProps) {

return (
<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">
<DragDropProvider
onDragStart={handleDragStart}
Expand Down Expand Up @@ -2901,6 +2920,7 @@ export default function Layout(props: ParentProps) {
navLeave.current = undefined
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return

if (navLeave.current !== undefined) clearTimeout(navLeave.current)
Expand All @@ -2916,7 +2936,7 @@ export default function Layout(props: ParentProps) {
</div>
<Show when={!layout.sidebar.opened() ? hoverProjectData() : undefined} keyed>
{(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} />
</div>
)}
Expand Down
138 changes: 138 additions & 0 deletions packages/app/src/utils/aim.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading