Browse Source

fix(app): safety triangle for sidebar hover (#12179)

Adam 3 weeks ago
parent
commit
9436cb575b
2 changed files with 169 additions and 11 deletions
  1. 31 11
      packages/app/src/pages/layout.tsx
  2. 138 0
      packages/app/src/utils/aim.ts

+ 31 - 11
packages/app/src/pages/layout.tsx

@@ -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
-    clearTimeout(navLeave.current)
+    if (navLeave.current !== undefined) 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
+            aim.enter(props.project.worktree, event)
+          }}
+          onMouseLeave={() => {
             if (!overlay()) return
             if (!overlay()) return
-            globalSync.child(props.project.worktree)
-            setState("hoverProject", props.project.worktree)
-            setState("hoverSession", undefined)
+            aim.leave(props.project.worktree)
           }}
           }}
           onFocus={() => {
           onFocus={() => {
             if (!overlay()) return
             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)}
           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 - 0
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 }
+}