summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-09 11:52:39 -0600
committerAdam <[email protected]>2025-12-09 11:52:43 -0600
commit1bc1e56da379fdd9040dc40caac1a57ffe8d1197 (patch)
treea29b8ae17b7db70cb7cf22f2378b8f6b0295090d
parent0d0c20e673d90bf5f5bb005fb6b91fd4850726a3 (diff)
downloadopencode-1bc1e56da379fdd9040dc40caac1a57ffe8d1197.tar.gz
opencode-1bc1e56da379fdd9040dc40caac1a57ffe8d1197.zip
wip(desktop): progress
-rw-r--r--packages/desktop/src/context/layout.tsx10
-rw-r--r--packages/desktop/src/context/local.tsx4
-rw-r--r--packages/desktop/src/context/session.tsx13
-rw-r--r--packages/desktop/src/pages/layout.tsx354
-rw-r--r--packages/desktop/src/pages/session.tsx2
-rw-r--r--packages/util/src/path.ts8
6 files changed, 240 insertions, 151 deletions
diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx
index 5ef41c1f4..58d947af4 100644
--- a/packages/desktop/src/context/layout.tsx
+++ b/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),
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx
index 0d8303b45..8223a36b9 100644
--- a/packages/desktop/src/context/local.tsx
+++ b/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]
diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx
index 5335422f7..2a0391d6b 100644
--- a/packages/desktop/src/context/session.tsx
+++ b/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)
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
index 6831fdf31..61fa6c766 100644
--- a/packages/desktop/src/pages/layout.tsx
+++ b/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}>
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
index 90f339798..68a2418f4 100644
--- a/packages/desktop/src/pages/session.tsx
+++ b/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
}}
diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts
index fbb84878d..f7c46d4ef 100644
--- a/packages/util/src/path.ts
+++ b/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]
}