Adam 2 месяцев назад
Родитель
Сommit
1bc1e56da3

+ 10 - 0
packages/desktop/src/context/layout.tsx

@@ -66,6 +66,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         collapse(directory: string) {
           setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: false } : x)))
         },
+        move(directory: string, toIndex: number) {
+          setStore("projects", (projects) => {
+            const fromIndex = projects.findIndex((x) => x.directory === directory)
+            if (fromIndex === -1 || fromIndex === toIndex) return projects
+            const result = [...projects]
+            const [item] = result.splice(fromIndex, 1)
+            result.splice(toIndex, 0, item)
+            return result
+          })
+        },
       },
       sidebar: {
         opened: createMemo(() => store.sidebar.opened),

+ 2 - 2
packages/desktop/src/context/local.tsx

@@ -257,7 +257,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
 
       const load = async (path: string) => {
         const relativePath = relative(path)
-        sdk.client.file.read({ path: relativePath }).then((x) => {
+        await sdk.client.file.read({ path: relativePath }).then((x) => {
           setStore(
             "node",
             relativePath,
@@ -335,7 +335,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
 
       return {
         node: async (path: string) => {
-          if (!store.node[path] || store.node[path].loaded === false) {
+          if (!store.node[path] || !store.node[path].loaded) {
             await init(path)
           }
           return store.node[path]

+ 2 - 11
packages/desktop/src/context/session.tsx

@@ -1,9 +1,9 @@
 import { createStore, produce } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
-import { batch, createEffect, createMemo, onMount } from "solid-js"
+import { batch, createEffect, createMemo } from "solid-js"
 import { useSync } from "./sync"
 import { makePersisted } from "@solid-primitives/storage"
-import { TextSelection, useLocal } from "./local"
+import { TextSelection } from "./local"
 import { pipe, sumBy } from "remeda"
 import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
 import { useParams } from "@solidjs/router"
@@ -25,7 +25,6 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
     const sdk = useSDK()
     const params = useParams()
     const sync = useSync()
-    const local = useLocal()
     const name = createMemo(
       () => `${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}.v2`,
     )
@@ -56,14 +55,6 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
       },
     )
 
-    onMount(() => {
-      store.tabs.all.forEach((tab) => {
-        if (tab.startsWith("file://")) {
-          local.file.open(tab.replace("file://", ""))
-        }
-      })
-    })
-
     createEffect(() => {
       if (!params.id) return
       sync.session.sync(params.id)

+ 220 - 134
packages/desktop/src/pages/layout.tsx

@@ -1,4 +1,4 @@
-import { createEffect, createMemo, For, Match, ParentProps, Show, Switch } from "solid-js"
+import { createEffect, createMemo, For, Match, ParentProps, Show, Switch, type JSX } from "solid-js"
 import { DateTime } from "luxon"
 import { A, useNavigate, useParams } from "@solidjs/router"
 import { useLayout } from "@/context/layout"
@@ -19,10 +19,21 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Session } from "@opencode-ai/sdk/v2/client"
 import { usePlatform } from "@/context/platform"
 import { createStore } from "solid-js/store"
+import {
+  DragDropProvider,
+  DragDropSensors,
+  DragOverlay,
+  SortableProvider,
+  closestCenter,
+  createSortable,
+  useDragDropContext,
+} from "@thisbeyond/solid-dnd"
+import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
 
 export default function Layout(props: ParentProps) {
   const [store, setStore] = createStore({
     lastSession: {} as { [directory: string]: string },
+    activeDraggable: undefined as string | undefined,
   })
 
   const params = useParams()
@@ -52,6 +63,7 @@ export default function Layout(props: ParentProps) {
 
   function closeProject(directory: string) {
     layout.projects.close(directory)
+    // TODO: more intelligent navigation
     navigate("/")
   }
 
@@ -76,6 +88,192 @@ export default function Layout(props: ParentProps) {
     setStore("lastSession", directory, params.id)
   })
 
+  function getDraggableId(event: unknown): string | undefined {
+    if (typeof event !== "object" || event === null) return undefined
+    if (!("draggable" in event)) return undefined
+    const draggable = (event as { draggable?: { id?: unknown } }).draggable
+    if (!draggable) return undefined
+    return typeof draggable.id === "string" ? draggable.id : undefined
+  }
+
+  function handleDragStart(event: unknown) {
+    const id = getDraggableId(event)
+    if (!id) return
+    setStore("activeDraggable", id)
+  }
+
+  function handleDragOver(event: DragEvent) {
+    const { draggable, droppable } = event
+    if (draggable && droppable) {
+      const projects = layout.projects.list()
+      const fromIndex = projects.findIndex((p) => p.directory === draggable.id.toString())
+      const toIndex = projects.findIndex((p) => p.directory === droppable.id.toString())
+      if (fromIndex !== toIndex && toIndex !== -1) {
+        layout.projects.move(draggable.id.toString(), toIndex)
+      }
+    }
+  }
+
+  function handleDragEnd() {
+    setStore("activeDraggable", undefined)
+  }
+
+  const ConstrainDragXAxis = (): JSX.Element => {
+    const context = useDragDropContext()
+    if (!context) return <></>
+    const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
+    const transformer: Transformer = {
+      id: "constrain-x-axis",
+      order: 100,
+      callback: (transform) => ({ ...transform, x: 0 }),
+    }
+    onDragStart((event) => {
+      const id = getDraggableId(event)
+      if (!id) return
+      addTransformer("draggables", id, transformer)
+    })
+    onDragEnd((event) => {
+      const id = getDraggableId(event)
+      if (!id) return
+      removeTransformer("draggables", id, transformer.id)
+    })
+    return <></>
+  }
+
+  const SortableProject = (props: { project: { directory: string; expanded: boolean } }): JSX.Element => {
+    const sortable = createSortable(props.project.directory)
+    const [projectStore] = globalSync.child(props.project.directory)
+    const slug = createMemo(() => base64Encode(props.project.directory))
+    const name = createMemo(() => getFilename(props.project.directory))
+    return (
+      // @ts-ignore
+      <div use:sortable classList={{ "opacity-0": sortable.isActiveDraggable }}>
+        <Switch>
+          <Match when={layout.sidebar.opened()}>
+            <Collapsible variant="ghost" defaultOpen class="gap-2 shrink-0">
+              <Button
+                as={"div"}
+                variant="ghost"
+                class="group/session flex items-center justify-between gap-3 w-full px-1 self-stretch h-auto border-none"
+              >
+                <Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
+                  <div class="size-6 shrink-0">
+                    <Avatar
+                      fallback={name()}
+                      background="var(--surface-info-base)"
+                      class="size-full group-hover/session:hidden"
+                    />
+                    <Icon
+                      name="chevron-right"
+                      size="large"
+                      class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50"
+                    />
+                  </div>
+                  <span class="truncate text-14-medium text-text-strong">{name()}</span>
+                </Collapsible.Trigger>
+                <div class="flex invisible gap-1 items-center group-hover/session:visible has-[[data-expanded]]:visible">
+                  <DropdownMenu>
+                    <DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
+                    <DropdownMenu.Portal>
+                      <DropdownMenu.Content>
+                        <DropdownMenu.Item onSelect={() => closeProject(props.project.directory)}>
+                          <DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel>
+                        </DropdownMenu.Item>
+                      </DropdownMenu.Content>
+                    </DropdownMenu.Portal>
+                  </DropdownMenu>
+                  <Tooltip placement="top" value="New session">
+                    <IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
+                  </Tooltip>
+                </div>
+              </Button>
+              <Collapsible.Content>
+                <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
+                  <For each={projectStore.session}>
+                    {(session) => {
+                      const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
+                      return (
+                        <A
+                          data-active={session.id === params.id}
+                          href={`${slug()}/session/${session.id}`}
+                          class="group/session focus:outline-none cursor-default"
+                        >
+                          <Tooltip placement="right" value={session.title}>
+                            <div
+                              class="w-full pl-4 pr-2 py-1 rounded-md
+                                   group-data-[active=true]/session:bg-surface-raised-base-hover
+                                   group-hover/session:bg-surface-raised-base-hover
+                                   group-focus/session:bg-surface-raised-base-hover"
+                            >
+                              <div class="flex items-center self-stretch gap-6 justify-between">
+                                <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
+                                  {session.title}
+                                </span>
+                                <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
+                                  {Math.abs(updated().diffNow().as("seconds")) < 60
+                                    ? "Now"
+                                    : updated()
+                                        .toRelative({
+                                          style: "short",
+                                          unit: ["days", "hours", "minutes"],
+                                        })
+                                        ?.replace(" ago", "")
+                                        ?.replace(/ days?/, "d")
+                                        ?.replace(" min.", "m")
+                                        ?.replace(" hr.", "h")}
+                                </span>
+                              </div>
+                              <div class="hidden _flex justify-between items-center self-stretch">
+                                <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
+                                <Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
+                              </div>
+                            </div>
+                          </Tooltip>
+                        </A>
+                      )
+                    }}
+                  </For>
+                </nav>
+              </Collapsible.Content>
+            </Collapsible>
+          </Match>
+          <Match when={true}>
+            <Tooltip placement="right" value={props.project.directory}>
+              <Button
+                variant="ghost"
+                size="large"
+                class="flex items-center justify-center p-0 aspect-square border-none"
+                data-selected={props.project.directory === currentDirectory()}
+                onClick={() => navigateToProject(props.project.directory)}
+              >
+                <div class="size-6 shrink-0 inset-0">
+                  <Avatar fallback={name()} background="var(--surface-info-base)" class="size-full" />
+                </div>
+              </Button>
+            </Tooltip>
+          </Match>
+        </Switch>
+      </div>
+    )
+  }
+
+  const ProjectDragOverlay = (): JSX.Element => {
+    const activeName = createMemo(() => {
+      if (!store.activeDraggable) return undefined
+      return getFilename(store.activeDraggable)
+    })
+    return (
+      <Show when={activeName()}>
+        {(name) => (
+          <div class="flex items-center gap-3 px-2 py-1 bg-background-stronger rounded-md border border-border-weak-base">
+            <Avatar fallback={name()} background="var(--surface-info-base)" class="size-6" />
+            <span class="text-14-medium text-text-strong">{name()}</span>
+          </div>
+        )}
+      </Show>
+    )
+  }
+
   return (
     <div class="relative h-screen flex flex-col">
       <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
@@ -96,8 +294,9 @@ export default function Layout(props: ParentProps) {
             <div class="flex items-center gap-3">
               <div class="flex items-center gap-2">
                 <Select
-                  options={layout.projects.list().map((project) => getFilename(project.directory))}
-                  current={getFilename(currentDirectory())}
+                  options={layout.projects.list().map((project) => project.directory)}
+                  current={currentDirectory()}
+                  label={(x) => getFilename(x)}
                   onSelect={(x) => (x ? navigateToProject(x) : undefined)}
                   class="text-14-regular text-text-base"
                   variant="ghost"
@@ -106,7 +305,7 @@ export default function Layout(props: ParentProps) {
                   {(i) => (
                     <div class="flex items-center gap-2">
                       <Icon name="folder" size="small" />
-                      <div class="text-text-strong">{i}</div>
+                      <div class="text-text-strong">{getFilename(i)}</div>
                     </div>
                   )}
                 </Select>
@@ -214,136 +413,23 @@ export default function Layout(props: ParentProps) {
                 </Show>
               </Button>
             </Tooltip>
-            <div class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar">
-              <For each={layout.projects.list()}>
-                {(project) => {
-                  const [store] = globalSync.child(project.directory)
-                  const slug = createMemo(() => base64Encode(project.directory))
-                  const name = createMemo(() => getFilename(project.directory))
-                  return (
-                    <Switch>
-                      <Match when={layout.sidebar.opened()}>
-                        <Collapsible variant="ghost" defaultOpen class="gap-2 shrink-0">
-                          <Button
-                            as={"div"}
-                            variant="ghost"
-                            class="group/session flex items-center justify-between gap-3 w-full px-1 self-stretch h-auto border-none"
-                          >
-                            <Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
-                              <div class="size-6 shrink-0">
-                                <Avatar
-                                  fallback={name()}
-                                  background="var(--surface-info-base)"
-                                  class="size-full group-hover/session:hidden"
-                                />
-                                <Icon
-                                  name="chevron-right"
-                                  size="large"
-                                  class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50"
-                                />
-                              </div>
-                              <span class="truncate text-14-medium text-text-strong">{name()}</span>
-                            </Collapsible.Trigger>
-                            <div class="flex invisible gap-1 items-center group-hover/session:visible has-[[data-expanded]]:visible">
-                              <DropdownMenu>
-                                <DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
-                                <DropdownMenu.Portal>
-                                  <DropdownMenu.Content>
-                                    <DropdownMenu.Item onSelect={() => closeProject(project.directory)}>
-                                      <DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel>
-                                    </DropdownMenu.Item>
-                                    {/* <DropdownMenu.Separator /> */}
-                                    {/* <DropdownMenu.Item> */}
-                                    {/*   <DropdownMenu.ItemLabel>Action 2</DropdownMenu.ItemLabel> */}
-                                    {/* </DropdownMenu.Item> */}
-                                  </DropdownMenu.Content>
-                                </DropdownMenu.Portal>
-                              </DropdownMenu>
-                              <Tooltip placement="top" value="New session">
-                                <IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
-                              </Tooltip>
-                            </div>
-                          </Button>
-                          <Collapsible.Content>
-                            <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
-                              <For each={store.session}>
-                                {(session) => {
-                                  const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
-                                  return (
-                                    <A
-                                      data-active={session.id === params.id}
-                                      href={`${slug()}/session/${session.id}`}
-                                      class="group/session focus:outline-none cursor-default"
-                                    >
-                                      <Tooltip placement="right" value={session.title}>
-                                        <div
-                                          class="w-full pl-4 pr-2 py-1 rounded-md
-                                               group-data-[active=true]/session:bg-surface-raised-base-hover
-                                               group-hover/session:bg-surface-raised-base-hover
-                                               group-focus/session:bg-surface-raised-base-hover"
-                                        >
-                                          <div class="flex items-center self-stretch gap-6 justify-between">
-                                            <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
-                                              {session.title}
-                                            </span>
-                                            <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
-                                              {Math.abs(updated().diffNow().as("seconds")) < 60
-                                                ? "Now"
-                                                : updated()
-                                                    .toRelative({
-                                                      style: "short",
-                                                      unit: ["days", "hours", "minutes"],
-                                                    })
-                                                    ?.replace(" ago", "")
-                                                    ?.replace(/ days?/, "d")
-                                                    ?.replace(" min.", "m")
-                                                    ?.replace(" hr.", "h")}
-                                            </span>
-                                          </div>
-                                          <div class="hidden _flex justify-between items-center self-stretch">
-                                            <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
-                                            <Show when={session.summary}>
-                                              {(summary) => <DiffChanges changes={summary()} />}
-                                            </Show>
-                                          </div>
-                                        </div>
-                                      </Tooltip>
-                                    </A>
-                                  )
-                                }}
-                              </For>
-                            </nav>
-                            {/* <Show when={sync.session.more()}> */}
-                            {/*   <button */}
-                            {/*     class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong" */}
-                            {/*     onClick={() => sync.session.fetch()} */}
-                            {/*   > */}
-                            {/*     Show more */}
-                            {/*   </button> */}
-                            {/* </Show> */}
-                          </Collapsible.Content>
-                        </Collapsible>
-                      </Match>
-                      <Match when={true}>
-                        <Tooltip placement="right" value={project.directory}>
-                          <Button
-                            variant="ghost"
-                            size="large"
-                            class="flex items-center justify-center p-0 aspect-square border-none"
-                            data-selected={project.directory === currentDirectory()}
-                            onClick={() => navigateToProject(project.directory)}
-                          >
-                            <div class="size-6 shrink-0 inset-0">
-                              <Avatar fallback={name()} background="var(--surface-info-base)" class="size-full" />
-                            </div>
-                          </Button>
-                        </Tooltip>
-                      </Match>
-                    </Switch>
-                  )
-                }}
-              </For>
-            </div>
+            <DragDropProvider
+              onDragStart={handleDragStart}
+              onDragEnd={handleDragEnd}
+              onDragOver={handleDragOver}
+              collisionDetector={closestCenter}
+            >
+              <DragDropSensors />
+              <ConstrainDragXAxis />
+              <div class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar">
+                <SortableProvider ids={layout.projects.list().map((p) => p.directory)}>
+                  <For each={layout.projects.list()}>{(project) => <SortableProject project={project} />}</For>
+                </SortableProvider>
+              </div>
+              <DragOverlay>
+                <ProjectDragOverlay />
+              </DragOverlay>
+            </DragDropProvider>
           </div>
           <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
             <Show when={platform.openDirectoryPickerDialog}>

+ 1 - 1
packages/desktop/src/pages/session.tsx

@@ -574,7 +574,7 @@ export default function Page() {
             onOpenChange={(open) => setStore("fileSelectOpen", open)}
             onSelect={(x) => {
               if (x) {
-                return local.file.open(x).then(() => session.layout.openTab("file://" + x))
+                return session.layout.openTab("file://" + x)
               }
               return undefined
             }}

+ 5 - 3
packages/util/src/path.ts

@@ -1,16 +1,18 @@
-export function getFilename(path: string) {
+export function getFilename(path: string | undefined) {
   if (!path) return ""
   const trimmed = path.replace(/[\/]+$/, "")
   const parts = trimmed.split("/")
   return parts[parts.length - 1] ?? ""
 }
 
-export function getDirectory(path: string) {
+export function getDirectory(path: string | undefined) {
+  if (!path) return ""
   const parts = path.split("/")
   return parts.slice(0, parts.length - 1).join("/") + "/"
 }
 
-export function getFileExtension(path: string) {
+export function getFileExtension(path: string | undefined) {
+  if (!path) return ""
   const parts = path.split(".")
   return parts[parts.length - 1]
 }