Browse Source

feat(app): draggable project rows (#38055)

Aarav Sareen 3 weeks ago
parent
commit
2d2339d37d
1 changed files with 130 additions and 25 deletions
  1. 130 25
      packages/app/src/pages/home.tsx

+ 130 - 25
packages/app/src/pages/home.tsx

@@ -17,6 +17,11 @@ import {
 } from "solid-js"
 import { makeEventListener } from "@solid-primitives/event-listener"
 import { createStore, produce } from "solid-js/store"
+import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
+import { isSortable, useSortable } from "@dnd-kit/solid/sortable"
+import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
+import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"
+import { RestrictToElement } from "@dnd-kit/dom/modifiers"
 import { useQuery } from "@tanstack/solid-query"
 import { Button } from "@opencode-ai/ui/button"
 import { Logo } from "@opencode-ai/ui/logo"
@@ -1037,7 +1042,7 @@ function HomeServerRow(props: {
   )
 }
 
-function HomeProjectList(props: {
+type HomeProjectListProps = {
   server: ServerConnection.Any
   projects: LocalProject[]
   selected: HomeProjectSelection
@@ -1048,29 +1053,84 @@ function HomeProjectList(props: {
   clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
   unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
   language: ReturnType<typeof useLanguage>
-}) {
+}
+
+function HomeProjectList(props: HomeProjectListProps) {
+  const global = useGlobal()
+  let listRef!: HTMLDivElement
+  const projects = () => global.ensureServerCtx(props.server).projects
+
   return (
-    <div class="flex min-w-0 flex-col gap-1">
-      <For each={props.projects}>
-        {(project) => (
-          <HomeProjectRow
-            project={project}
-            server={props.server}
-            selected={
-              props.selected.server === ServerConnection.key(props.server) &&
-              props.selected.directory === project.worktree
-            }
-            unseenCount={props.unseenCount(props.server, project)}
-            selectProject={props.selectProject}
-            openNewSession={props.openNewSession}
-            editProject={props.editProject}
-            closeProject={props.closeProject}
-            clearNotifications={props.clearNotifications}
-            language={props.language}
-          />
-        )}
-      </For>
-    </div>
+    <DragDropProvider
+      sensors={(defaults) => [
+        ...defaults.filter((sensor) => sensor !== PointerSensor),
+        PointerSensor.configure({
+          activationConstraints: (event) =>
+            event.pointerType === "touch"
+              ? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })]
+              : [new PointerActivationConstraints.Distance({ value: 4 })],
+          preventActivation: (event) =>
+            event.target instanceof Element && !!event.target.closest("[data-action]"),
+        }),
+      ]}
+      modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]}
+      plugins={(defaults) => [
+        ...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback),
+        AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }),
+        Feedback.configure({ dropAnimation: null }),
+      ]}
+      onDragEnd={(event) => {
+        const source = event.operation.source
+        if (event.canceled || !isSortable(source)) return
+        if (source.initialIndex !== source.index) projects().move(source.id.toString(), source.index)
+        if (props.selected.server !== ServerConnection.key(props.server))
+          props.selectProject(props.server, source.id.toString())
+      }}
+    >
+      <div class="flex min-w-0 flex-col gap-1" ref={listRef}>
+        {/* Keyed on worktree strings: the enriched project objects are
+            recreated on every store or sync update, so iterating them directly
+            remounts all rows — killing any in-flight drag activation (the
+            row's sortable unregisters on unmount) and discarding animations.
+            String keys keep row elements alive and move them on reorder. */}
+        <For each={props.projects.map((project) => project.worktree)}>
+          {(worktree, index) => <HomeProjectSlot {...props} worktree={worktree} index={index} />}
+        </For>
+      </div>
+    </DragDropProvider>
+  )
+}
+
+function HomeProjectSlot(
+  props: HomeProjectListProps & {
+    worktree: string
+    index: () => number
+  },
+) {
+  const project = createMemo(() => props.projects.find((item) => item.worktree === props.worktree))
+
+  return (
+    <Show when={project()}>
+      {(item) => (
+        <HomeProjectRow
+          project={item()}
+          server={props.server}
+          index={props.index}
+          serverSelected={props.selected.server === ServerConnection.key(props.server)}
+          selected={
+            props.selected.server === ServerConnection.key(props.server) &&
+            props.selected.directory === props.worktree
+          }
+          unseenCount={props.unseenCount(props.server, item())}
+          selectProject={props.selectProject}
+          openNewSession={props.openNewSession}
+          editProject={props.editProject}
+          closeProject={props.closeProject}
+          clearNotifications={props.clearNotifications}
+          language={props.language}
+        />
+      )}
+    </Show>
   )
 }
 
@@ -1150,6 +1210,8 @@ function HomeRecentlyClosedRow(props: {
 function HomeProjectRow(props: {
   project: LocalProject
   server: ServerConnection.Any
+  index: () => number
+  serverSelected: boolean
   selected: boolean
   unseenCount: number
   selectProject: (server: ServerConnection.Any, directory: string) => void
@@ -1163,6 +1225,15 @@ function HomeProjectRow(props: {
   const platform = usePlatform()
   const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
   const [state, setState] = createStore({ menuOpen: false })
+  const sortable = useSortable({
+    get id() {
+      return props.project.worktree
+    },
+    get index() {
+      return props.index()
+    },
+  })
+  let pointerDownSelected: boolean | undefined
   const canRevealInFileManager = () =>
     platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server)
   const fileManagerActionLabel = () =>
@@ -1179,15 +1250,49 @@ function HomeProjectRow(props: {
     )
   }
   return (
-    <div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
+    <div
+      ref={sortable.ref}
+      class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]"
+      classList={{ "z-10": sortable.isDragSource() }}
+    >
       <button
         type="button"
         data-component="home-project-row"
         class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
+        classList={{
+          "bg-v2-background-bg-layer-01 text-v2-text-text-base [box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]":
+            sortable.isDragSource(),
+        }}
         data-selected={props.selected ? "" : undefined}
         aria-current={props.selected ? "page" : undefined}
         disabled={serverUnreachable()}
-        onClick={() => props.selectProject(props.server, props.project.worktree)}
+        onPointerDown={(event) => {
+          // Same-server mouse selection happens on pointerdown (like tabs),
+          // but only ever selects; selectProject toggles, and deselecting here
+          // would fire on every drag before the threshold is met. Cross-server
+          // selection waits for click so reordering a remote server's projects
+          // does not focus that server and load its session index. Touch is
+          // excluded so flick-scrolling the list cannot select rows.
+          pointerDownSelected = undefined
+          if (event.button !== 0 || event.pointerType === "touch") return
+          if (!props.serverSelected) return
+          pointerDownSelected = props.selected
+          if (!props.selected) props.selectProject(props.server, props.project.worktree)
+        }}
+        onClick={(event) => {
+          // The drag sensor calls preventDefault on post-drag clicks; never
+          // toggle selection as part of a reorder.
+          if (event.defaultPrevented) return
+          // Keyboard activation and touch taps keep the original toggle.
+          if (event.detail === 0 || pointerDownSelected === undefined) {
+            props.selectProject(props.server, props.project.worktree)
+            return
+          }
+          // Mouse: pointerdown already selected unselected rows; a plain click
+          // on an already-selected row toggles it off.
+          if (pointerDownSelected) props.selectProject(props.server, props.project.worktree)
+          pointerDownSelected = undefined
+        }}
       >
         <HomeProjectAvatar project={props.project} />
         <span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>