mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
wip(desktop): progress
This commit is contained in:
parent
0d0c20e673
commit
1bc1e56da3
6 changed files with 240 additions and 151 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue