Skip to content

Commit 9436cb5

Browse files
authored
fix(app): safety triangle for sidebar hover (anomalyco#12179)
1 parent d168666 commit 9436cb5

2 files changed

Lines changed: 169 additions & 11 deletions

File tree

packages/app/src/pages/layout.tsx

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { usePermission } from "@/context/permission"
5858
import { Binary } from "@opencode-ai/util/binary"
5959
import { retry } from "@opencode-ai/util/retry"
6060
import { playSound, soundSrc } from "@/utils/sound"
61+
import { createAim } from "@/utils/aim"
6162
import { Worktree as WorktreeState } from "@/utils/worktree"
6263
import { agentColor } from "@/utils/agent"
6364

@@ -146,9 +147,20 @@ export default function Layout(props: ParentProps) {
146147

147148
const navLeave = { current: undefined as number | undefined }
148149

150+
const aim = createAim({
151+
enabled: () => !layout.sidebar.opened(),
152+
active: () => state.hoverProject,
153+
el: () => state.nav,
154+
onActivate: (directory) => {
155+
globalSync.child(directory)
156+
setState("hoverProject", directory)
157+
setState("hoverSession", undefined)
158+
},
159+
})
160+
149161
onCleanup(() => {
150-
if (navLeave.current === undefined) return
151-
clearTimeout(navLeave.current)
162+
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
163+
aim.reset()
152164
})
153165

154166
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
@@ -162,15 +174,22 @@ export default function Layout(props: ParentProps) {
162174

163175
createEffect(() => {
164176
if (!layout.sidebar.opened()) return
177+
aim.reset()
165178
setState("hoverProject", undefined)
166179
})
167180

181+
createEffect(() => {
182+
if (state.hoverProject !== undefined) return
183+
aim.reset()
184+
})
185+
168186
createEffect(
169187
on(
170188
() => ({ dir: params.dir, id: params.id }),
171189
() => {
172190
if (layout.sidebar.opened()) return
173191
if (!state.hoverProject) return
192+
aim.reset()
174193
setState("hoverSession", undefined)
175194
setState("hoverProject", undefined)
176195
},
@@ -2311,17 +2330,17 @@ export default function Layout(props: ParentProps) {
23112330
!selected() && !active(),
23122331
"bg-surface-base-hover border border-border-weak-base": !selected() && active(),
23132332
}}
2314-
onMouseEnter={() => {
2333+
onMouseEnter={(event: MouseEvent) => {
2334+
if (!overlay()) return
2335+
aim.enter(props.project.worktree, event)
2336+
}}
2337+
onMouseLeave={() => {
23152338
if (!overlay()) return
2316-
globalSync.child(props.project.worktree)
2317-
setState("hoverProject", props.project.worktree)
2318-
setState("hoverSession", undefined)
2339+
aim.leave(props.project.worktree)
23192340
}}
23202341
onFocus={() => {
23212342
if (!overlay()) return
2322-
globalSync.child(props.project.worktree)
2323-
setState("hoverProject", props.project.worktree)
2324-
setState("hoverSession", undefined)
2343+
aim.activate(props.project.worktree)
23252344
}}
23262345
onClick={() => navigateToProject(props.project.worktree)}
23272346
onBlur={() => setOpen(false)}
@@ -2806,7 +2825,7 @@ export default function Layout(props: ParentProps) {
28062825

28072826
return (
28082827
<div class="flex h-full w-full overflow-hidden">
2809-
<div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden">
2828+
<div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden" onMouseMove={aim.move}>
28102829
<div class="flex-1 min-h-0 w-full">
28112830
<DragDropProvider
28122831
onDragStart={handleDragStart}
@@ -2901,6 +2920,7 @@ export default function Layout(props: ParentProps) {
29012920
navLeave.current = undefined
29022921
}}
29032922
onMouseLeave={() => {
2923+
aim.reset()
29042924
if (!sidebarHovering()) return
29052925

29062926
if (navLeave.current !== undefined) clearTimeout(navLeave.current)
@@ -2916,7 +2936,7 @@ export default function Layout(props: ParentProps) {
29162936
</div>
29172937
<Show when={!layout.sidebar.opened() ? hoverProjectData() : undefined} keyed>
29182938
{(project) => (
2919-
<div class="absolute inset-y-0 left-16 z-50 flex">
2939+
<div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
29202940
<SidebarPanel project={project} />
29212941
</div>
29222942
)}

packages/app/src/utils/aim.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
type Point = { x: number; y: number }
2+
3+
export function createAim(props: {
4+
enabled: () => boolean
5+
active: () => string | undefined
6+
el: () => HTMLElement | undefined
7+
onActivate: (id: string) => void
8+
delay?: number
9+
max?: number
10+
tolerance?: number
11+
edge?: number
12+
}) {
13+
const state = {
14+
locs: [] as Point[],
15+
timer: undefined as number | undefined,
16+
pending: undefined as string | undefined,
17+
over: undefined as string | undefined,
18+
last: undefined as Point | undefined,
19+
}
20+
21+
const delay = props.delay ?? 250
22+
const max = props.max ?? 4
23+
const tolerance = props.tolerance ?? 80
24+
const edge = props.edge ?? 18
25+
26+
const cancel = () => {
27+
if (state.timer !== undefined) clearTimeout(state.timer)
28+
state.timer = undefined
29+
state.pending = undefined
30+
}
31+
32+
const reset = () => {
33+
cancel()
34+
state.over = undefined
35+
state.last = undefined
36+
state.locs.length = 0
37+
}
38+
39+
const move = (event: MouseEvent) => {
40+
if (!props.enabled()) return
41+
const el = props.el()
42+
if (!el) return
43+
44+
const rect = el.getBoundingClientRect()
45+
const x = event.clientX
46+
const y = event.clientY
47+
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return
48+
49+
state.locs.push({ x, y })
50+
if (state.locs.length > max) state.locs.shift()
51+
}
52+
53+
const wait = () => {
54+
if (!props.enabled()) return 0
55+
if (!props.active()) return 0
56+
57+
const el = props.el()
58+
if (!el) return 0
59+
if (state.locs.length < 2) return 0
60+
61+
const rect = el.getBoundingClientRect()
62+
const loc = state.locs[state.locs.length - 1]
63+
if (!loc) return 0
64+
65+
const prev = state.locs[0] ?? loc
66+
if (prev.x < rect.left || prev.x > rect.right || prev.y < rect.top || prev.y > rect.bottom) return 0
67+
if (state.last && loc.x === state.last.x && loc.y === state.last.y) return 0
68+
69+
if (rect.right - loc.x <= edge) {
70+
state.last = loc
71+
return delay
72+
}
73+
74+
const upper = { x: rect.right, y: rect.top - tolerance }
75+
const lower = { x: rect.right, y: rect.bottom + tolerance }
76+
const slope = (a: Point, b: Point) => (b.y - a.y) / (b.x - a.x)
77+
78+
const decreasing = slope(loc, upper)
79+
const increasing = slope(loc, lower)
80+
const prevDecreasing = slope(prev, upper)
81+
const prevIncreasing = slope(prev, lower)
82+
83+
if (decreasing < prevDecreasing && increasing > prevIncreasing) {
84+
state.last = loc
85+
return delay
86+
}
87+
88+
state.last = undefined
89+
return 0
90+
}
91+
92+
const activate = (id: string) => {
93+
cancel()
94+
props.onActivate(id)
95+
}
96+
97+
const request = (id: string) => {
98+
if (!id) return
99+
if (props.active() === id) return
100+
101+
if (!props.active()) {
102+
activate(id)
103+
return
104+
}
105+
106+
const ms = wait()
107+
if (ms === 0) {
108+
activate(id)
109+
return
110+
}
111+
112+
cancel()
113+
state.pending = id
114+
state.timer = window.setTimeout(() => {
115+
state.timer = undefined
116+
if (state.pending !== id) return
117+
state.pending = undefined
118+
if (!props.enabled()) return
119+
if (!props.active()) return
120+
if (state.over !== id) return
121+
props.onActivate(id)
122+
}, ms)
123+
}
124+
125+
const enter = (id: string, event: MouseEvent) => {
126+
if (!props.enabled()) return
127+
state.over = id
128+
move(event)
129+
request(id)
130+
}
131+
132+
const leave = (id: string) => {
133+
if (state.over === id) state.over = undefined
134+
if (state.pending === id) cancel()
135+
}
136+
137+
return { move, enter, leave, activate, request, cancel, reset }
138+
}

0 commit comments

Comments
 (0)