diff options
| author | Adam <[email protected]> | 2025-12-09 11:52:39 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-09 11:52:43 -0600 |
| commit | 1bc1e56da379fdd9040dc40caac1a57ffe8d1197 (patch) | |
| tree | a29b8ae17b7db70cb7cf22f2378b8f6b0295090d | |
| parent | 0d0c20e673d90bf5f5bb005fb6b91fd4850726a3 (diff) | |
| download | opencode-1bc1e56da379fdd9040dc40caac1a57ffe8d1197.tar.gz opencode-1bc1e56da379fdd9040dc40caac1a57ffe8d1197.zip | |
wip(desktop): progress
| -rw-r--r-- | packages/desktop/src/context/layout.tsx | 10 | ||||
| -rw-r--r-- | packages/desktop/src/context/local.tsx | 4 | ||||
| -rw-r--r-- | packages/desktop/src/context/session.tsx | 13 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 354 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session.tsx | 2 | ||||
| -rw-r--r-- | packages/util/src/path.ts | 8 |
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] } |
