wip(desktop): progress

This commit is contained in:
Adam 2025-12-09 03:45:50 -06:00
parent d29205e677
commit 0a357be160
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
13 changed files with 784 additions and 171 deletions

View file

@ -2,19 +2,18 @@ import "@/index.css"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
import { Favicon } from "@opencode-ai/ui/favicon"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { Diff } from "@opencode-ai/ui/diff"
import { GlobalSyncProvider, useGlobalSync } from "./context/global-sync"
import { GlobalSyncProvider } from "./context/global-sync"
import Layout from "@/pages/layout"
import Home from "@/pages/home"
import DirectoryLayout from "@/pages/directory-layout"
import Session from "@/pages/session"
import { LayoutProvider } from "./context/layout"
import { GlobalSDKProvider } from "./context/global-sdk"
import { SessionProvider } from "./context/session"
import { base64Encode } from "@opencode-ai/util/encode"
import { createMemo, Show } from "solid-js"
import { Show } from "solid-js"
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
@ -35,14 +34,7 @@ export function DesktopInterface() {
<MetaProvider>
<Font />
<Router root={Layout}>
<Route
path="/"
component={() => {
const globalSync = useGlobalSync()
const slug = createMemo(() => base64Encode(globalSync.data.defaultProject!.worktree))
return <Navigate href={`${slug()}/session`} />
}}
/>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route

View file

@ -51,7 +51,6 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
init: () => {
const [globalStore, setGlobalStore] = createStore<{
ready: boolean
defaultProject?: Project // TODO: remove this when we can select projects
projects: Project[]
children: Record<string, State>
}>({
@ -165,11 +164,11 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
sdk.client.project.list().then((x) =>
setGlobalStore(
"projects",
x.data!.filter((x) => !x.worktree.includes("opencode-test")),
x
.data!.filter((x) => !x.worktree.includes("opencode-test") && x.vcs)
.sort((a, b) => b.time.created - a.time.created),
),
),
// TODO: remove this when we can select projects
sdk.client.project.current().then((x) => setGlobalStore("defaultProject", x.data)),
]).then(() => setGlobalStore("ready", true))
return {

View file

@ -2,17 +2,15 @@ import { createStore } from "solid-js/store"
import { createMemo } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makePersisted } from "@solid-primitives/storage"
import { useGlobalSync } from "./global-sync"
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
const globalSync = useGlobalSync()
const [store, setStore] = makePersisted(
createStore({
projects: [] as { directory: string; expanded: boolean }[],
projects: [] as { directory: string; expanded: boolean; lastSession?: string }[],
sidebar: {
opened: true,
opened: false,
width: 280,
},
terminal: {
@ -24,17 +22,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
}),
{
name: "____default-layout",
name: "default-layout.v4",
},
)
return {
projects: {
list: createMemo(() =>
globalSync.data.defaultProject
? [{ directory: globalSync.data.defaultProject!.worktree, expanded: true }, ...store.projects]
: store.projects,
),
list: createMemo(() => store.projects),
open(directory: string) {
if (store.projects.find((x) => x.directory === directory)) return
setStore("projects", (x) => [...x, { directory, expanded: true }])
@ -48,6 +42,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
collapse(directory: string) {
setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: false } : x)))
},
lastSession(directory: string) {
return store.projects.find((x) => x.directory === directory)?.lastSession
},
setLastSession(directory: string, session: string | undefined) {
setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, lastSession: session } : x)))
},
},
sidebar: {
opened: createMemo(() => store.sidebar.opened),

View file

@ -1,21 +1,63 @@
import { useGlobalSync } from "@/context/global-sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { For } from "solid-js"
import { A } from "@solidjs/router"
import { For, Match, Switch } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { getFilename } from "@opencode-ai/util/path"
import { Logo } from "@opencode-ai/ui/logo"
import { useLayout } from "@/context/layout"
import { useNavigate } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/util/encode"
import { Icon } from "@opencode-ai/ui/icon"
export default function Home() {
const navigate = useNavigate()
const sync = useGlobalSync()
const layout = useLayout()
function openProject(directory: string) {
layout.projects.open(directory)
navigate(`/${base64Encode(directory)}`)
}
return (
<div class="flex flex-col gap-3">
<For each={sync.data.projects}>
{(project) => (
<Button as={A} href={base64Encode(project.worktree)}>
{getFilename(project.worktree)}
</Button>
)}
</For>
<div class="mx-auto mt-55">
<Logo class="w-xl opacity-12" />
<Switch>
<Match when={sync.data.projects.length > 0}>
<div class="mt-20 w-full flex flex-col gap-4">
<div class="flex gap-2 items-center justify-between pl-3">
<div class="text-14-medium text-text-strong">Recent projects</div>
<Button icon="folder-add-left" size="normal" class="pl-2 pr-3">
Open project
</Button>
</div>
<ol class="flex flex-col gap-2">
<For each={sync.data.projects.slice(0, 5)}>
{(project) => (
<Button
size="large"
variant="ghost"
class="text-14-mono text-left justify-between px-3"
onClick={() => openProject(project.worktree)}
>
{project.worktree}
<div class="text-14-regular text-text-weak">10m ago</div>
</Button>
)}
</For>
</ol>
</div>
</Match>
<Match when={true}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<Icon name="folder-add-left" size="large" />
<div class="flex flex-col gap-1 items-center justify-center">
<div class="text-14-medium text-text-strong">No recent projects</div>
<div class="text-12-regular text-text-weak">Get started by opening a local project</div>
</div>
<div />
<Button class="px-3">Open project</Button>
</div>
</Match>
</Switch>
</div>
)
}

View file

@ -1,10 +1,11 @@
import { createMemo, For, ParentProps, Show } from "solid-js"
import { createEffect, createMemo, For, Match, ParentProps, Show, Switch } from "solid-js"
import { DateTime } from "luxon"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout } 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"
import { Icon } from "@opencode-ai/ui/icon"
@ -14,6 +15,7 @@ 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 } from "@opencode-ai/sdk/v2/client"
export default function Layout(props: ParentProps) {
@ -25,15 +27,30 @@ export default function Layout(props: ParentProps) {
const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? [])
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
function navigateToProject(directory: string | undefined) {
if (!directory) return
navigate(`/${base64Encode(directory)}`)
}
function navigateToSession(session: Session | undefined) {
if (!session) return
navigate(`/${params.dir}/session/${session?.id}`)
}
function closeProject(directory: string) {
layout.projects.close(directory)
navigate("/")
}
const handleOpenProject = async () => {
// layout.projects.open(dir.)
}
// createEffect(() => {
// if (!params.dir) return
// layout.projects.setLastSession(base64Decode(params.dir), params.id)
// })
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>
@ -50,61 +67,72 @@ export default function Layout(props: ParentProps) {
<Mark class="shrink-0" />
</A>
<div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
<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())}
class="text-14-regular text-text-base"
variant="ghost"
/>
<div class="text-text-weaker">/</div>
<Select
options={sessions()}
current={currentSession()}
placeholder="Select session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={navigateToSession}
class="text-14-regular text-text-base max-w-md"
variant="ghost"
/>
</div>
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
New session
</Button>
</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>
<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) => getFilename(project.directory))}
current={getFilename(currentDirectory())}
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">{i}</div>
</div>
)}
</Select>
<div class="text-text-weaker">/</div>
<Select
options={sessions()}
current={currentSession()}
placeholder="Select session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={navigateToSession}
class="text-14-regular text-text-base max-w-md"
variant="ghost"
/>
</div>
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
New session
</Button>
</Tooltip>
</div>
</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">
@ -159,84 +187,135 @@ export default function Layout(props: ParentProps) {
</Show>
</Button>
</Tooltip>
<div class="flex flex-col justify-center items-start gap-4 self-stretch min-h-0">
<div class="hidden @[4rem]:flex size-full flex-col grow overflow-y-auto no-scrollbar">
<For each={layout.projects.list()}>
{(project) => {
const [store] = globalSync.child(project.directory)
const slug = createMemo(() => base64Encode(project.directory))
return (
<Collapsible variant="ghost" defaultOpen class="gap-2">
<Button
as={"div"}
variant="ghost"
class="flex items-center justify-between gap-3 w-full h-8 pl-2 pr-2.25 self-stretch"
>
<Collapsible.Trigger class="p-0 text-left text-14-medium text-text-strong grow min-w-0 truncate">
{getFilename(project.directory)}
</Collapsible.Trigger>
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" size="normal" />
</Button>
<Collapsible.Content>
<nav class="w-full flex 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 px-2 py-1 rounded-md
<div class="size-full min-w-8 flex flex-col gap-2 grow 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">
<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="bottom" 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 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>
<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>
)
}}
</For>
</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
as={A}
href={`${slug()}/session`}
variant="ghost"
size="large"
class="flex items-center justify-center p-0 aspect-square border-none"
>
<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>
</div>
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">

View file

@ -9,7 +9,8 @@
"dev": "vite",
"build": "bun run typecheck && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/desktop": "workspace:*",

View file

@ -0,0 +1,35 @@
[data-component="avatar"] {
--avatar-bg: var(--color-surface-info-base);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-weak-base);
font-family: var(--font-mono);
font-weight: 500;
text-transform: uppercase;
background-color: var(--avatar-bg);
color: oklch(from var(--avatar-bg) calc(l * 0.72) calc(c * 8) h);
}
[data-component="avatar"][data-size="small"] {
width: 1.25rem;
height: 1.25rem;
font-size: 0.75rem;
line-height: 1;
}
[data-component="avatar"][data-size="normal"] {
width: 1.5rem;
height: 1.5rem;
font-size: 1.125rem;
line-height: 1.5rem;
}
[data-component="avatar"][data-size="large"] {
width: 2rem;
height: 2rem;
font-size: 1.25rem;
line-height: 2rem;
}

View file

@ -0,0 +1,28 @@
import { type ComponentProps, splitProps, Show } from "solid-js"
export interface AvatarProps extends ComponentProps<"div"> {
fallback: string
background?: string
size?: "small" | "normal" | "large"
}
export function Avatar(props: AvatarProps) {
const [split, rest] = splitProps(props, ["fallback", "background", "size", "class", "classList", "style"])
return (
<div
{...rest}
data-component="avatar"
data-size={split.size || "normal"}
classList={{
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
style={{
...(typeof split.style === "object" ? split.style : {}),
...(split.background ? { "--avatar-bg": split.background } : {}),
}}
>
<Show when={split.fallback}>{split.fallback[0]}</Show>
</div>
)
}

View file

@ -0,0 +1,119 @@
[data-component="dropdown-menu-content"],
[data-component="dropdown-menu-sub-content"] {
min-width: 8rem;
overflow: hidden;
border-radius: var(--radius-md);
border: 1px solid var(--border-weak-base);
background-color: var(--surface-raised-stronger-non-alpha);
padding: 4px;
box-shadow: var(--shadow-md);
z-index: 50;
transform-origin: var(--kb-menu-content-transform-origin);
&[data-closed] {
animation: dropdown-menu-close 0.15s ease-out;
}
&[data-expanded] {
animation: dropdown-menu-open 0.15s ease-out;
}
}
[data-component="dropdown-menu-content"],
[data-component="dropdown-menu-sub-content"] {
[data-slot="dropdown-menu-item"],
[data-slot="dropdown-menu-checkbox-item"],
[data-slot="dropdown-menu-radio-item"],
[data-slot="dropdown-menu-sub-trigger"] {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: var(--radius-sm);
cursor: default;
user-select: none;
outline: none;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
&[data-highlighted] {
background: var(--surface-raised-base-hover);
}
&[data-disabled] {
color: var(--text-weak);
pointer-events: none;
}
}
[data-slot="dropdown-menu-sub-trigger"] {
&[data-expanded] {
background: var(--surface-raised-base-hover);
}
}
[data-slot="dropdown-menu-item-indicator"] {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
[data-slot="dropdown-menu-item-label"] {
flex: 1;
}
[data-slot="dropdown-menu-item-description"] {
font-size: var(--font-size-x-small);
color: var(--text-weak);
}
[data-slot="dropdown-menu-separator"] {
height: 1px;
margin: 4px -4px;
border-top-color: var(--border-weak-base);
}
[data-slot="dropdown-menu-group-label"] {
padding: 4px 8px;
font-family: var(--font-family-sans);
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-weak);
}
[data-slot="dropdown-menu-arrow"] {
fill: var(--surface-raised-stronger-non-alpha);
}
}
@keyframes dropdown-menu-open {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes dropdown-menu-close {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}

View file

@ -0,0 +1,308 @@
import { DropdownMenu as Kobalte } from "@kobalte/core/dropdown-menu"
import { splitProps } from "solid-js"
import type { ComponentProps, ParentProps } from "solid-js"
export interface DropdownMenuProps extends ComponentProps<typeof Kobalte> {}
export interface DropdownMenuTriggerProps extends ComponentProps<typeof Kobalte.Trigger> {}
export interface DropdownMenuIconProps extends ComponentProps<typeof Kobalte.Icon> {}
export interface DropdownMenuPortalProps extends ComponentProps<typeof Kobalte.Portal> {}
export interface DropdownMenuContentProps extends ComponentProps<typeof Kobalte.Content> {}
export interface DropdownMenuArrowProps extends ComponentProps<typeof Kobalte.Arrow> {}
export interface DropdownMenuSeparatorProps extends ComponentProps<typeof Kobalte.Separator> {}
export interface DropdownMenuGroupProps extends ComponentProps<typeof Kobalte.Group> {}
export interface DropdownMenuGroupLabelProps extends ComponentProps<typeof Kobalte.GroupLabel> {}
export interface DropdownMenuItemProps extends ComponentProps<typeof Kobalte.Item> {}
export interface DropdownMenuItemLabelProps extends ComponentProps<typeof Kobalte.ItemLabel> {}
export interface DropdownMenuItemDescriptionProps extends ComponentProps<typeof Kobalte.ItemDescription> {}
export interface DropdownMenuItemIndicatorProps extends ComponentProps<typeof Kobalte.ItemIndicator> {}
export interface DropdownMenuRadioGroupProps extends ComponentProps<typeof Kobalte.RadioGroup> {}
export interface DropdownMenuRadioItemProps extends ComponentProps<typeof Kobalte.RadioItem> {}
export interface DropdownMenuCheckboxItemProps extends ComponentProps<typeof Kobalte.CheckboxItem> {}
export interface DropdownMenuSubProps extends ComponentProps<typeof Kobalte.Sub> {}
export interface DropdownMenuSubTriggerProps extends ComponentProps<typeof Kobalte.SubTrigger> {}
export interface DropdownMenuSubContentProps extends ComponentProps<typeof Kobalte.SubContent> {}
function DropdownMenuRoot(props: DropdownMenuProps) {
return <Kobalte {...props} data-component="dropdown-menu" />
}
function DropdownMenuTrigger(props: ParentProps<DropdownMenuTriggerProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Trigger
{...rest}
data-slot="dropdown-menu-trigger"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.Trigger>
)
}
function DropdownMenuIcon(props: ParentProps<DropdownMenuIconProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Icon
{...rest}
data-slot="dropdown-menu-icon"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.Icon>
)
}
function DropdownMenuPortal(props: DropdownMenuPortalProps) {
return <Kobalte.Portal {...props} />
}
function DropdownMenuContent(props: ParentProps<DropdownMenuContentProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Content
{...rest}
data-component="dropdown-menu-content"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.Content>
)
}
function DropdownMenuArrow(props: DropdownMenuArrowProps) {
const [local, rest] = splitProps(props, ["class", "classList"])
return (
<Kobalte.Arrow
{...rest}
data-slot="dropdown-menu-arrow"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
/>
)
}
function DropdownMenuSeparator(props: DropdownMenuSeparatorProps) {
const [local, rest] = splitProps(props, ["class", "classList"])
return (
<Kobalte.Separator
{...rest}
data-slot="dropdown-menu-separator"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
/>
)
}
function DropdownMenuGroup(props: ParentProps<DropdownMenuGroupProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Group
{...rest}
data-slot="dropdown-menu-group"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.Group>
)
}
function DropdownMenuGroupLabel(props: ParentProps<DropdownMenuGroupLabelProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.GroupLabel
{...rest}
data-slot="dropdown-menu-group-label"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.GroupLabel>
)
}
function DropdownMenuItem(props: ParentProps<DropdownMenuItemProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Item
{...rest}
data-slot="dropdown-menu-item"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.Item>
)
}
function DropdownMenuItemLabel(props: ParentProps<DropdownMenuItemLabelProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.ItemLabel
{...rest}
data-slot="dropdown-menu-item-label"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.ItemLabel>
)
}
function DropdownMenuItemDescription(props: ParentProps<DropdownMenuItemDescriptionProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.ItemDescription
{...rest}
data-slot="dropdown-menu-item-description"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.ItemDescription>
)
}
function DropdownMenuItemIndicator(props: ParentProps<DropdownMenuItemIndicatorProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.ItemIndicator
{...rest}
data-slot="dropdown-menu-item-indicator"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.ItemIndicator>
)
}
function DropdownMenuRadioGroup(props: ParentProps<DropdownMenuRadioGroupProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.RadioGroup
{...rest}
data-slot="dropdown-menu-radio-group"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.RadioGroup>
)
}
function DropdownMenuRadioItem(props: ParentProps<DropdownMenuRadioItemProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.RadioItem
{...rest}
data-slot="dropdown-menu-radio-item"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.RadioItem>
)
}
function DropdownMenuCheckboxItem(props: ParentProps<DropdownMenuCheckboxItemProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.CheckboxItem
{...rest}
data-slot="dropdown-menu-checkbox-item"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.CheckboxItem>
)
}
function DropdownMenuSub(props: DropdownMenuSubProps) {
return <Kobalte.Sub {...props} />
}
function DropdownMenuSubTrigger(props: ParentProps<DropdownMenuSubTriggerProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.SubTrigger
{...rest}
data-slot="dropdown-menu-sub-trigger"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.SubTrigger>
)
}
function DropdownMenuSubContent(props: ParentProps<DropdownMenuSubContentProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.SubContent
{...rest}
data-component="dropdown-menu-sub-content"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</Kobalte.SubContent>
)
}
export const DropdownMenu = Object.assign(DropdownMenuRoot, {
Trigger: DropdownMenuTrigger,
Icon: DropdownMenuIcon,
Portal: DropdownMenuPortal,
Content: DropdownMenuContent,
Arrow: DropdownMenuArrow,
Separator: DropdownMenuSeparator,
Group: DropdownMenuGroup,
GroupLabel: DropdownMenuGroupLabel,
Item: DropdownMenuItem,
ItemLabel: DropdownMenuItemLabel,
ItemDescription: DropdownMenuItemDescription,
ItemIndicator: DropdownMenuItemIndicator,
RadioGroup: DropdownMenuRadioGroup,
RadioItem: DropdownMenuRadioItem,
CheckboxItem: DropdownMenuCheckboxItem,
Sub: DropdownMenuSub,
SubTrigger: DropdownMenuSubTrigger,
SubContent: DropdownMenuSubContent,
})

View file

@ -133,6 +133,7 @@ const newIcons = {
"magnifying-glass": `<path d="M15.8332 15.8337L13.0819 13.0824M14.6143 9.39088C14.6143 12.2759 12.2755 14.6148 9.39039 14.6148C6.50532 14.6148 4.1665 12.2759 4.1665 9.39088C4.1665 6.5058 6.50532 4.16699 9.39039 4.16699C12.2755 4.16699 14.6143 6.5058 14.6143 9.39088Z" stroke="currentColor" stroke-linecap="square"/>`,
"plus-small": `<path d="M9.99984 5.41699V10.0003M9.99984 10.0003V14.5837M9.99984 10.0003H5.4165M9.99984 10.0003H14.5832" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-right": `<path d="M8.33301 13.3327L11.6663 9.99935L8.33301 6.66602" stroke="currentColor" stroke-linecap="square"/>`,
"arrow-up": `<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99991 2.24121L16.0921 8.33343L15.2083 9.21731L10.6249 4.63397V17.5001H9.37492V4.63398L4.7916 9.21731L3.90771 8.33343L9.99991 2.24121Z" fill="currentColor"/>`,
"check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square"/>`,
"edit-small-2": `<path d="M17.0834 17.0833V17.5833H17.5834V17.0833H17.0834ZM2.91675 17.0833H2.41675V17.5833H2.91675V17.0833ZM2.91675 2.91659V2.41659H2.41675V2.91659H2.91675ZM9.58341 3.41659H10.0834V2.41659H9.58341V2.91659V3.41659ZM17.5834 10.4166V9.91659H16.5834V10.4166H17.0834H17.5834ZM10.4167 7.08325L10.0632 6.7297L9.91675 6.87615V7.08325H10.4167ZM10.4167 9.58325H9.91675V10.0833H10.4167V9.58325ZM12.9167 9.58325V10.0833H13.1239L13.2703 9.93681L12.9167 9.58325ZM15.4167 2.08325L15.7703 1.7297L15.4167 1.37615L15.0632 1.7297L15.4167 2.08325ZM17.9167 4.58325L18.2703 4.93681L18.6239 4.58325L18.2703 4.2297L17.9167 4.58325ZM17.0834 17.0833V16.5833H2.91675V17.0833V17.5833H17.0834V17.0833ZM2.91675 17.0833H3.41675V2.91659H2.91675H2.41675V17.0833H2.91675ZM2.91675 2.91659V3.41659H9.58341V2.91659V2.41659H2.91675V2.91659ZM17.0834 10.4166H16.5834V17.0833H17.0834H17.5834V10.4166H17.0834ZM10.4167 7.08325H9.91675V9.58325H10.4167H10.9167V7.08325H10.4167ZM10.4167 9.58325V10.0833H12.9167V9.58325V9.08325H10.4167V9.58325ZM10.4167 7.08325L10.7703 7.43681L15.7703 2.43681L15.4167 2.08325L15.0632 1.7297L10.0632 6.7297L10.4167 7.08325ZM15.4167 2.08325L15.0632 2.43681L17.5632 4.93681L17.9167 4.58325L18.2703 4.2297L15.7703 1.7297L15.4167 2.08325ZM17.9167 4.58325L17.5632 4.2297L12.5632 9.2297L12.9167 9.58325L13.2703 9.93681L18.2703 4.93681L17.9167 4.58325Z" fill="currentColor"/>`,
@ -170,6 +171,7 @@ const newIcons = {
"layout-bottom": `<path d="M18.125 18.125L1.875 18.125L1.875 1.875L18.125 1.875L18.125 18.125ZM3.125 12.8308L3.125 16.875L16.875 16.875L16.875 12.8308L3.125 12.8308ZM3.125 3.125L3.125 11.5808L16.875 11.5808L16.875 3.125L3.125 3.125Z" fill="currentColor"/>`,
"layout-bottom-partial": `<path d="M2.5 17.5L2.5 12.2059L17.5 12.2059L17.5 17.5L2.5 17.5Z" fill="currentColor" fill-opacity="40%" /><path d="M2.5 17.5L2.5 2.5M2.5 17.5L17.5 17.5M2.5 17.5L2.5 12.2059M2.5 2.5L17.5 2.5M2.5 2.5L2.5 12.2059M17.5 2.5L17.5 17.5M17.5 2.5L17.5 12.2059M17.5 17.5L17.5 12.2059M17.5 12.2059L2.5 12.2059" stroke="currentColor" stroke-linecap="square"/>`,
"layout-bottom-full": `<path d="M2.5 17.5L2.5 12.2059L17.5 12.2059L17.5 17.5L2.5 17.5Z" fill="currentColor"/><path d="M2.5 17.5L2.5 2.5M2.5 17.5L17.5 17.5M2.5 17.5L2.5 12.2059M2.5 2.5L17.5 2.5M2.5 2.5L2.5 12.2059M17.5 2.5L17.5 17.5M17.5 2.5L17.5 12.2059M17.5 17.5L17.5 12.2059M17.5 12.2059L2.5 12.2059" stroke="currentColor" stroke-linecap="square"/>`,
"dot-grid": `<path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" fill="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" fill="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" fill="currentColor"/><path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" stroke="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" stroke="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" stroke="currentColor"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {

View file

@ -1,10 +1,10 @@
import { Select as Kobalte } from "@kobalte/core/select"
import { createMemo, splitProps, type ComponentProps } from "solid-js"
import { createMemo, splitProps, type ComponentProps, type JSX } from "solid-js"
import { pipe, groupBy, entries, map } from "remeda"
import { Button, ButtonProps } from "./button"
import { Icon } from "./icon"
export type SelectProps<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | "onSelect"> & {
export type SelectProps<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | "onSelect" | "children"> & {
placeholder?: string
options: T[]
current?: T
@ -14,6 +14,7 @@ export type SelectProps<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | "
onSelect?: (value: T | undefined) => void
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
children?: (item: T | undefined) => JSX.Element
}
export function Select<T>(props: SelectProps<T> & ButtonProps) {
@ -27,6 +28,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
"label",
"groupBy",
"onSelect",
"children",
])
const grouped = createMemo(() => {
const result = pipe(
@ -63,7 +65,11 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
{...itemProps}
>
<Kobalte.ItemLabel data-slot="select-select-item-label">
{local.label ? local.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
{local.children
? local.children(itemProps.item.rawValue)
: local.label
? local.label(itemProps.item.rawValue)
: (itemProps.item.rawValue as string)}
</Kobalte.ItemLabel>
<Kobalte.ItemIndicator data-slot="select-select-item-indicator">
<Icon name="check-small" size="small" />

View file

@ -6,6 +6,7 @@
@import "./base.css" layer(base);
@import "../components/accordion.css" layer(components);
@import "../components/avatar.css" layer(components);
@import "../components/basic-tool.css" layer(components);
@import "../components/button.css" layer(components);
@import "../components/card.css" layer(components);
@ -14,6 +15,7 @@
@import "../components/collapsible.css" layer(components);
@import "../components/diff.css" layer(components);
@import "../components/diff-changes.css" layer(components);
@import "../components/dropdown-menu.css" layer(components);
@import "../components/dialog.css" layer(components);
@import "../components/file-icon.css" layer(components);
@import "../components/icon.css" layer(components);