|
|
@@ -1,10 +1,21 @@
|
|
|
-import { createEffect, createMemo, For, Match, onCleanup, onMount, ParentProps, Show, Switch, type JSX } from "solid-js"
|
|
|
+import {
|
|
|
+ createEffect,
|
|
|
+ createMemo,
|
|
|
+ createSignal,
|
|
|
+ For,
|
|
|
+ Match,
|
|
|
+ onCleanup,
|
|
|
+ onMount,
|
|
|
+ ParentProps,
|
|
|
+ Show,
|
|
|
+ Switch,
|
|
|
+ type JSX,
|
|
|
+} from "solid-js"
|
|
|
import { DateTime } from "luxon"
|
|
|
import { A, useNavigate, useParams } from "@solidjs/router"
|
|
|
import { useLayout, getAvatarColors } from "@/context/layout"
|
|
|
import { useGlobalSync } from "@/context/global-sync"
|
|
|
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
|
|
-import { Mark } from "@opencode-ai/ui/logo"
|
|
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
|
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
|
|
import { Button } from "@opencode-ai/ui/button"
|
|
|
@@ -15,7 +26,6 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
|
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
|
|
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
|
|
import { getFilename } from "@opencode-ai/util/path"
|
|
|
-import { Select } from "@opencode-ai/ui/select"
|
|
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|
|
import { Session, Project, ProviderAuthMethod, ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
|
|
|
import { usePlatform } from "@/context/platform"
|
|
|
@@ -42,6 +52,9 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
|
|
import { showToast, Toast } from "@opencode-ai/ui/toast"
|
|
|
import { useGlobalSDK } from "@/context/global-sdk"
|
|
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
|
+import { useNotification } from "@/context/notification"
|
|
|
+import { Binary } from "@opencode-ai/util/binary"
|
|
|
+import { Header } from "@/components/header"
|
|
|
|
|
|
export default function Layout(props: ParentProps) {
|
|
|
const [store, setStore] = createStore({
|
|
|
@@ -54,10 +67,8 @@ export default function Layout(props: ParentProps) {
|
|
|
const globalSync = useGlobalSync()
|
|
|
const layout = useLayout()
|
|
|
const platform = usePlatform()
|
|
|
+ const notification = useNotification()
|
|
|
const navigate = useNavigate()
|
|
|
- const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
|
|
|
- const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? [])
|
|
|
- const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
|
|
|
const providers = useProviders()
|
|
|
|
|
|
function navigateToProject(directory: string | undefined) {
|
|
|
@@ -77,9 +88,11 @@ export default function Layout(props: ParentProps) {
|
|
|
}
|
|
|
|
|
|
function closeProject(directory: string) {
|
|
|
+ const index = layout.projects.list().findIndex((x) => x.worktree === directory)
|
|
|
+ const next = layout.projects.list()[index + 1]
|
|
|
layout.projects.close(directory)
|
|
|
- // TODO: more intelligent navigation
|
|
|
- navigate("/")
|
|
|
+ if (next) navigateToProject(next.worktree)
|
|
|
+ else navigate("/")
|
|
|
}
|
|
|
|
|
|
async function chooseProject() {
|
|
|
@@ -105,6 +118,7 @@ export default function Layout(props: ParentProps) {
|
|
|
if (!params.dir || !params.id) return
|
|
|
const directory = base64Decode(params.dir)
|
|
|
setStore("lastSession", directory, params.id)
|
|
|
+ notification.session.markViewed(params.id)
|
|
|
})
|
|
|
|
|
|
createEffect(() => {
|
|
|
@@ -164,8 +178,51 @@ export default function Layout(props: ParentProps) {
|
|
|
return <></>
|
|
|
}
|
|
|
|
|
|
+ const ProjectAvatar = (props: {
|
|
|
+ project: Project
|
|
|
+ class?: string
|
|
|
+ expandable?: boolean
|
|
|
+ notify?: boolean
|
|
|
+ }): JSX.Element => {
|
|
|
+ const notification = useNotification()
|
|
|
+ const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
|
|
|
+ const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
|
|
+ const name = createMemo(() => getFilename(props.project.worktree))
|
|
|
+ const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
|
|
|
+ return (
|
|
|
+ <div class="relative size-6 shrink-0">
|
|
|
+ <Avatar
|
|
|
+ fallback={name()}
|
|
|
+ src={props.project.icon?.url}
|
|
|
+ {...getAvatarColors(props.project.icon?.color)}
|
|
|
+ class={`size-full ${props.class ?? ""}`}
|
|
|
+ style={
|
|
|
+ notifications().length > 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined
|
|
|
+ }
|
|
|
+ />
|
|
|
+ <Show when={props.expandable}>
|
|
|
+ <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"
|
|
|
+ />
|
|
|
+ </Show>
|
|
|
+ <Show when={notifications().length > 0 && props.notify}>
|
|
|
+ <div
|
|
|
+ classList={{
|
|
|
+ "absolute -top-0.5 -right-0.5 size-1.5 rounded-full": true,
|
|
|
+ "bg-icon-critical-base": hasError(),
|
|
|
+ "bg-text-interactive-base": !hasError(),
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
const ProjectVisual = (props: { project: Project & { expanded: boolean }; class?: string }): JSX.Element => {
|
|
|
const name = createMemo(() => getFilename(props.project.worktree))
|
|
|
+ const current = createMemo(() => base64Decode(params.dir ?? ""))
|
|
|
return (
|
|
|
<Switch>
|
|
|
<Match when={layout.sidebar.opened()}>
|
|
|
@@ -176,14 +233,7 @@ export default function Layout(props: ParentProps) {
|
|
|
class="flex items-center justify-between gap-3 w-full px-1 self-stretch h-8 border-none rounded-lg"
|
|
|
>
|
|
|
<div class="flex items-center gap-3 p-0 text-left min-w-0 grow">
|
|
|
- <div class="size-6 shrink-0">
|
|
|
- <Avatar
|
|
|
- fallback={name()}
|
|
|
- src={props.project.icon?.url}
|
|
|
- {...getAvatarColors(props.project.icon?.color)}
|
|
|
- class="size-full"
|
|
|
- />
|
|
|
- </div>
|
|
|
+ <ProjectAvatar project={props.project} />
|
|
|
<span class="truncate text-14-medium text-text-strong">{name()}</span>
|
|
|
</div>
|
|
|
</Button>
|
|
|
@@ -193,17 +243,10 @@ export default function Layout(props: ParentProps) {
|
|
|
variant="ghost"
|
|
|
size="large"
|
|
|
class="flex items-center justify-center p-0 aspect-square border-none rounded-lg"
|
|
|
- data-selected={props.project.worktree === currentDirectory()}
|
|
|
+ data-selected={props.project.worktree === current()}
|
|
|
onClick={() => navigateToProject(props.project.worktree)}
|
|
|
>
|
|
|
- <div class="size-6 shrink-0">
|
|
|
- <Avatar
|
|
|
- fallback={name()}
|
|
|
- src={props.project.icon?.url}
|
|
|
- {...getAvatarColors(props.project.icon?.color)}
|
|
|
- class="size-full"
|
|
|
- />
|
|
|
- </div>
|
|
|
+ <ProjectAvatar project={props.project} notify />
|
|
|
</Button>
|
|
|
</Match>
|
|
|
</Switch>
|
|
|
@@ -211,35 +254,31 @@ export default function Layout(props: ParentProps) {
|
|
|
}
|
|
|
|
|
|
const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => {
|
|
|
+ const notification = useNotification()
|
|
|
const sortable = createSortable(props.project.worktree)
|
|
|
- const [projectStore] = globalSync.child(props.project.worktree)
|
|
|
const slug = createMemo(() => base64Encode(props.project.worktree))
|
|
|
const name = createMemo(() => getFilename(props.project.worktree))
|
|
|
+ const [store, setStore] = globalSync.child(props.project.worktree)
|
|
|
+ const sessions = createMemo(() => store.session ?? [])
|
|
|
+ const [expanded, setExpanded] = createSignal(true)
|
|
|
return (
|
|
|
// @ts-ignore
|
|
|
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
|
|
<Switch>
|
|
|
<Match when={layout.sidebar.opened()}>
|
|
|
- <Collapsible variant="ghost" defaultOpen class="gap-2 shrink-0">
|
|
|
+ <Collapsible variant="ghost" defaultOpen class="gap-2 shrink-0" onOpenChange={setExpanded}>
|
|
|
<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 rounded-lg"
|
|
|
>
|
|
|
<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()}
|
|
|
- src={props.project.icon?.url}
|
|
|
- {...getAvatarColors(props.project.icon?.color)}
|
|
|
- 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>
|
|
|
+ <ProjectAvatar
|
|
|
+ project={props.project}
|
|
|
+ class="group-hover/session:hidden"
|
|
|
+ expandable
|
|
|
+ notify={!expanded()}
|
|
|
+ />
|
|
|
<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">
|
|
|
@@ -260,50 +299,102 @@ export default function Layout(props: ParentProps) {
|
|
|
</Button>
|
|
|
<Collapsible.Content>
|
|
|
<nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
|
|
|
- <For each={projectStore.session}>
|
|
|
+ <For each={sessions()}>
|
|
|
{(session) => {
|
|
|
const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
|
|
|
+ const notifications = createMemo(() => notification.session.unseen(session.id))
|
|
|
+ const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
|
|
+ async function archive(session: Session) {
|
|
|
+ await globalSDK.client.session.update({
|
|
|
+ directory: session.directory,
|
|
|
+ sessionID: session.id,
|
|
|
+ time: { archived: Date.now() },
|
|
|
+ })
|
|
|
+ setStore(
|
|
|
+ produce((draft) => {
|
|
|
+ const match = Binary.search(draft.session, session.id, (s) => s.id)
|
|
|
+ if (match.found) draft.session.splice(match.index, 1)
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ }
|
|
|
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"
|
|
|
+ class="relative w-full pl-4 pr-1 py-1 rounded-md
|
|
|
+ group-[.active]/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 class="shrink-0 group-hover/session:hidden mr-1">
|
|
|
+ <Switch>
|
|
|
+ <Match when={hasError()}>
|
|
|
+ <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
|
|
|
+ </Match>
|
|
|
+ <Match when={notifications().length > 0}>
|
|
|
+ <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" />
|
|
|
+ </Match>
|
|
|
+ <Match when={true}>
|
|
|
+ <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>
|
|
|
+ </Match>
|
|
|
+ </Switch>
|
|
|
+ </div>
|
|
|
+ <div class="hidden group-hover/session:flex group-active/session:flex text-text-base gap-1">
|
|
|
+ {/* <IconButton icon="dot-grid" variant="ghost" /> */}
|
|
|
+ <Tooltip placement="right" value="Archive session">
|
|
|
+ <IconButton icon="archive" variant="ghost" onClick={() => archive(session)} />
|
|
|
+ </Tooltip>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
+ <Show when={session.summary?.files}>
|
|
|
+ <div class="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>
|
|
|
+ </Show>
|
|
|
</div>
|
|
|
</Tooltip>
|
|
|
</A>
|
|
|
)
|
|
|
}}
|
|
|
</For>
|
|
|
+ <Show when={sessions().length === 0}>
|
|
|
+ <A href={`${slug()}/session`} class="group/session focus:outline-none cursor-default">
|
|
|
+ <Tooltip placement="right" value="New session">
|
|
|
+ <div
|
|
|
+ class="relative w-full pl-4 pr-1 py-1 rounded-md
|
|
|
+ group-[.active]/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">
|
|
|
+ New session
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Tooltip>
|
|
|
+ </A>
|
|
|
+ </Show>
|
|
|
</nav>
|
|
|
</Collapsible.Content>
|
|
|
</Collapsible>
|
|
|
@@ -332,93 +423,9 @@ export default function Layout(props: ParentProps) {
|
|
|
}
|
|
|
|
|
|
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>
|
|
|
- <A
|
|
|
- href="/"
|
|
|
- classList={{
|
|
|
- "w-12 shrink-0 px-4 py-3.5": true,
|
|
|
- "flex items-center justify-start self-stretch": true,
|
|
|
- "border-r border-border-weak-base": true,
|
|
|
- }}
|
|
|
- style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
|
|
|
- data-tauri-drag-region
|
|
|
- >
|
|
|
- <Mark class="shrink-0" />
|
|
|
- </A>
|
|
|
- <div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
|
|
|
- <Show when={params.dir && layout.projects.list().length > 0}>
|
|
|
- <div class="flex items-center gap-3">
|
|
|
- <div class="flex items-center gap-2">
|
|
|
- <Select
|
|
|
- options={layout.projects.list().map((project) => project.worktree)}
|
|
|
- current={currentDirectory()}
|
|
|
- label={(x) => getFilename(x)}
|
|
|
- onSelect={(x) => (x ? navigateToProject(x) : undefined)}
|
|
|
- class="text-14-regular text-text-base"
|
|
|
- variant="ghost"
|
|
|
- >
|
|
|
- {/* @ts-ignore */}
|
|
|
- {(i) => (
|
|
|
- <div class="flex items-center gap-2">
|
|
|
- <Icon name="folder" size="small" />
|
|
|
- <div class="text-text-strong">{getFilename(i)}</div>
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </Select>
|
|
|
- <div class="text-text-weaker">/</div>
|
|
|
- <Select
|
|
|
- options={sessions()}
|
|
|
- current={currentSession()}
|
|
|
- placeholder="New session"
|
|
|
- label={(x) => x.title}
|
|
|
- value={(x) => x.id}
|
|
|
- onSelect={navigateToSession}
|
|
|
- class="text-14-regular text-text-base max-w-md"
|
|
|
- variant="ghost"
|
|
|
- />
|
|
|
- </div>
|
|
|
- <Show when={currentSession()}>
|
|
|
- <Button as={A} href={`/${params.dir}/session`} icon="plus-small">
|
|
|
- New session
|
|
|
- </Button>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- <div class="flex items-center gap-4">
|
|
|
- <Tooltip
|
|
|
- class="shrink-0"
|
|
|
- value={
|
|
|
- <div class="flex items-center gap-2">
|
|
|
- <span>Toggle terminal</span>
|
|
|
- <span class="text-icon-base text-12-medium">Ctrl `</span>
|
|
|
- </div>
|
|
|
- }
|
|
|
- >
|
|
|
- <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
|
|
|
- <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
|
|
- <Icon
|
|
|
- size="small"
|
|
|
- name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
|
|
|
- class="group-hover/terminal-toggle:hidden"
|
|
|
- />
|
|
|
- <Icon
|
|
|
- size="small"
|
|
|
- name="layout-bottom-partial"
|
|
|
- class="hidden group-hover/terminal-toggle:inline-block"
|
|
|
- />
|
|
|
- <Icon
|
|
|
- size="small"
|
|
|
- name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
|
|
|
- class="hidden group-active/terminal-toggle:inline-block"
|
|
|
- />
|
|
|
- </div>
|
|
|
- </Button>
|
|
|
- </Tooltip>
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- </header>
|
|
|
- <div class="h-[calc(100vh-3rem)] flex">
|
|
|
+ <div class="relative flex-1 min-h-0 flex flex-col">
|
|
|
+ <Header navigateToProject={navigateToProject} navigateToSession={navigateToSession} />
|
|
|
+ <div class="flex-1 min-h-0 flex">
|
|
|
<div
|
|
|
classList={{
|
|
|
"relative @container w-12 pb-5 shrink-0 bg-background-base": true,
|