feat(app): new layout

This commit is contained in:
Adam
2026-01-12 10:11:29 -06:00
parent 9f66a45970
commit 679270d9e0
19 changed files with 681 additions and 276 deletions

View File

@@ -13,7 +13,6 @@
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
<!-- Theme preload script - applies cached theme to avoid FOUC -->
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">

View File

@@ -34,6 +34,17 @@ export function SessionHeader() {
const sync = useSync()
const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const name = createMemo(() => {
const current = project()
if (current) return current.name || getFilename(current.worktree)
return getFilename(projectDirectory())
})
const hotkey = createMemo(() => command.keybind("file.open"))
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
@@ -58,87 +69,29 @@ export function SessionHeader() {
navigate(`/${params.dir}/session/${session.id}`)
}
const leftMount = createMemo(() => document.getElementById("opencode-titlebar-left"))
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
return (
<>
<Show when={leftMount()}>
<Show when={centerMount()}>
{(mount) => (
<Portal mount={mount()}>
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2">
<Select
options={worktrees()}
current={sync.project?.worktree ?? projectDirectory()}
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>
</div>
<Show
when={parentSession()}
fallback={
<>
<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-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
</>
}
>
<div class="flex items-center gap-2 min-w-0">
<Select
options={sessions()}
current={parentSession()}
placeholder="Back to parent session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={(session) => {
const currentParent = parentSession()
if (session && currentParent && session.id !== currentParent.id) {
navigateToSession(session)
}
}}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
<div class="text-text-weaker">/</div>
<Tooltip value="Back to parent session">
<button
type="button"
class="flex items-center justify-center gap-1 p-1 rounded hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors flex-shrink-0"
onClick={() => navigateToSession(parentSession())}
>
<Icon name="arrow-left" size="small" class="text-icon-base" />
</button>
</Tooltip>
</div>
</Show>
</div>
<Show when={currentSession() && !parentSession()}>
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
<IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
</TooltipKeybind>
<button
type="button"
class="hidden md:flex w-[320px] h-7 px-1.5 items-center gap-2 rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
onClick={() => command.trigger("file.open")}
>
<Icon name="magnifying-glass" size="small" class="text-text-weak" />
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate">Search {name()}</span>
<Show when={hotkey()}>
{(keybind) => (
<span class="shrink-0 flex items-center justify-center h-5 px-2 rounded-md border border-border-weak-base bg-surface-base text-12-medium text-text-weak">
{keybind()}
</span>
)}
</Show>
</div>
</button>
</Portal>
)}
</Show>

View File

@@ -0,0 +1,115 @@
import { createEffect, createMemo, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
export function Titlebar() {
const layout = useLayout()
const platform = usePlatform()
const command = useCommand()
const theme = useTheme()
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const reserve = createMemo(
() => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"),
)
const getWin = () => {
if (platform.platform !== "desktop") return
const tauri = (
window as unknown as {
__TAURI__?: { window?: { getCurrentWindow?: () => { startDragging?: () => Promise<void> } } }
}
).__TAURI__
if (!tauri?.window?.getCurrentWindow) return
return tauri.window.getCurrentWindow()
}
createEffect(() => {
if (platform.platform !== "desktop") return
const scheme = theme.colorScheme()
const value = scheme === "system" ? null : scheme
const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } })
.__TAURI__
const get = tauri?.webviewWindow?.getCurrentWebviewWindow
if (!get) return
const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise<void> }
if (!win.setTheme) return
void win.setTheme(value).catch(() => undefined)
})
const interactive = (target: EventTarget | null) => {
if (!(target instanceof Element)) return false
const selector =
"button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']"
return !!target.closest(selector)
}
const drag = (e: MouseEvent) => {
if (platform.platform !== "desktop") return
if (e.buttons !== 1) return
if (interactive(e.target)) return
const win = getWin()
if (!win?.startDragging) return
e.preventDefault()
void win.startDragging().catch(() => undefined)
}
return (
<header class="h-10 shrink-0 bg-background-base flex items-center relative">
<div
classList={{
"flex items-center w-full min-w-0 pr-2": true,
"pl-2": !mac(),
}}
onMouseDown={drag}
>
<Show when={mac()}>
<div class="w-[72px] h-full shrink-0" data-tauri-drag-region />
</Show>
<IconButton
icon="menu"
variant="ghost"
class="xl:hidden size-8 rounded-md"
onClick={layout.mobileSidebar.toggle}
/>
<TooltipKeybind
class="hidden xl:flex shrink-0"
placement="bottom"
title="Toggle sidebar"
keybind={command.keybind("sidebar.toggle")}
>
<IconButton
icon={layout.sidebar.opened() ? "layout-left" : "layout-right"}
variant="ghost"
class="size-8 rounded-md"
onClick={layout.sidebar.toggle}
/>
</TooltipKeybind>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="flex-1 h-full" data-tauri-drag-region />
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0" />
<Show when={reserve()}>
<div class="w-[120px] h-full shrink-0" data-tauri-drag-region />
</Show>
</div>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div id="opencode-titlebar-center" class="pointer-events-auto" />
</div>
</header>
)
}

View File

@@ -53,6 +53,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
sidebar: {
opened: false,
width: 280,
workspaces: false,
},
terminal: {
height: 280,
@@ -304,6 +305,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
resize(width: number) {
setStore("sidebar", "width", width)
},
workspaces: createMemo(() => store.sidebar.workspaces ?? false),
setWorkspaces(value: boolean) {
setStore("sidebar", "workspaces", value)
},
toggleWorkspaces() {
setStore("sidebar", "workspaces", (x) => !x)
},
},
terminal: {
height: createMemo(() => store.terminal.height),

View File

@@ -14,7 +14,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const sdk = useSDK()
const [store, setStore] = globalSync.child(sdk.directory)
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
const chunk = 200
const chunk = 400
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()

View File

@@ -5,3 +5,7 @@
cursor: default;
}
}
*[data-tauri-drag-region] {
app-region: drag;
}

View File

@@ -23,6 +23,8 @@ import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -55,6 +57,8 @@ import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { navStart } from "@/utils/perf"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
export default function Layout(props: ParentProps) {
@@ -814,20 +818,24 @@ export default function Layout(props: ParentProps) {
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
return (
<div class={`relative size-10 shrink-0 ${props.class ?? ""}`}>
<Avatar
fallback={name()}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
{...getAvatarColors(props.project.icon?.color)}
class="size-full rounded-lg"
style={
notifications().length > 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined
}
/>
<div class={`relative size-8 shrink-0 rounded-sm ${props.class ?? ""}`}>
<div class="size-full rounded-sm overflow-clip">
<Avatar
fallback={name()}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
{...getAvatarColors(props.project.icon?.color)}
class="size-full rounded-sm"
style={
notifications().length > 0 && props.notify
? { "-webkit-mask-image": mask, "mask-image": mask }
: undefined
}
/>
</div>
<Show when={notifications().length > 0 && props.notify}>
<div
classList={{
"absolute -top-0.5 -right-0.5 size-2 rounded-full": true,
"absolute -top-px -right-px size-2 rounded-full z-10": true,
"bg-icon-critical-base": hasError(),
"bg-text-interactive-base": !hasError(),
}}
@@ -837,7 +845,7 @@ export default function Layout(props: ParentProps) {
)
}
const SessionItem = (props: { session: Session; slug: string; mobile?: boolean }): JSX.Element => {
const SessionItem = (props: { session: Session; slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => {
const notification = useNotification()
const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
@@ -859,47 +867,62 @@ export default function Layout(props: ParentProps) {
return status?.type === "busy" || status?.type === "retry"
})
const tint = createMemo(() => {
const messages = sessionStore.message[props.session.id]
if (!messages) return undefined
const user = messages
.slice()
.reverse()
.find((m) => m.role === "user")
if (!user?.agent) return undefined
const agent = sessionStore.agent.find((a) => a.name === user.agent)
return agent?.color
})
return (
<div
data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default transition-colors
class="group/session relative w-full rounded-md cursor-default transition-colors px-3
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
>
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
<A
href={`${props.slug}/session/${props.session.id}`}
class="flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1 transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7"
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onMouseEnter={() => prefetchSession(props.session, "high")}
onFocus={() => prefetchSession(props.session, "high")}
>
<span
classList={{
"text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true,
"animate-pulse": isWorking(),
}}
>
{props.session.title}
</span>
<div class="shrink-0 flex items-center gap-2">
<Switch>
<Match when={isWorking()}>
<Spinner class="size-2.5" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={notifications().length > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
<div class="flex items-center gap-1 w-full">
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
>
<Switch>
<Match when={isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={notifications().length > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
<Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
</div>
</A>
</Tooltip>
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
<div
class={`hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"}`}
>
<TooltipKeybind
placement={props.mobile ? "bottom" : "right"}
title="Archive session"
@@ -914,26 +937,81 @@ export default function Layout(props: ParentProps) {
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
const sortable = createSortable(props.project.worktree)
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const selected = createMemo(() => {
const current = params.dir ? base64Decode(params.dir) : ""
return props.project.worktree === current || props.project.sandboxes?.includes(current)
})
const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2))
const label = (directory: string) => {
const [data] = globalSync.child(directory)
const kind = directory === props.project.worktree ? "local" : "sandbox"
const name = data.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name}`
}
const sessions = (directory: string) => {
const [data] = globalSync.child(directory)
return data.session
.filter((session) => session.directory === data.path.directory)
.filter((session) => !session.parentID)
.toSorted(sortSessions)
.slice(0, 2)
}
const trigger = (
<button
type="button"
classList={{
"flex items-center justify-center size-10 p-1 rounded-md border transition-colors cursor-default": true,
"bg-surface-base-hover border-icon-strong-base": selected(),
"bg-transparent border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": !selected(),
}}
onClick={() => navigateToProject(props.project.worktree)}
>
<ProjectIcon project={props.project} notify />
</button>
)
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Tooltip placement={props.mobile ? "bottom" : "right"} value={name()}>
<Button
variant="ghost"
size="large"
class="flex items-center justify-center p-0 size-12 rounded-xl"
data-selected={selected()}
onClick={() => navigateToProject(props.project.worktree)}
>
<ProjectIcon project={props.project} notify />
</Button>
</Tooltip>
<HoverCard openDelay={0} closeDelay={0} placement="right-start" gutter={10} trigger={trigger}>
<div class="-m-3 flex flex-col w-72">
<div class="px-3 py-2 text-12-medium text-text-strong">Recent sessions</div>
<div class="px-2 pb-2 flex flex-col gap-2">
<For each={workspaces()}>
{(directory) => (
<div class="flex flex-col gap-1">
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
<div class="shrink-0 size-6 flex items-center justify-center">
<Icon name="branch" size="small" class="text-icon-base" />
</div>
<span class="truncate text-14-medium text-text-strong">{label(directory)}</span>
</div>
<For each={sessions(directory)}>
{(session) => (
<SessionItem session={session} slug={base64Encode(directory)} dense mobile={props.mobile} />
)}
</For>
</div>
)}
</For>
</div>
<div class="px-2 py-2 border-t border-border-weak-base">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-text-base px-2"
onClick={() => {
layout.sidebar.open()
navigateToProject(props.project.worktree)
}}
>
View all sessions
</Button>
</div>
</div>
</HoverCard>
</div>
)
}
@@ -967,7 +1045,7 @@ export default function Layout(props: ParentProps) {
return (
<Show when={label()}>
{(value) => (
<div class="bg-background-base rounded-md px-2 py-1 text-12-medium text-text-strong">{value()}</div>
<div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>
)}
</Show>
)
@@ -1003,39 +1081,59 @@ export default function Layout(props: ParentProps) {
<Collapsible
variant="ghost"
open={open()}
class="gap-1.5 shrink-0"
class="shrink-0"
onOpenChange={(value) => setStore("workspaceExpanded", props.directory, value)}
>
<Collapsible.Trigger class="group/trigger flex items-center justify-between w-full px-2 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
<div class="flex items-center gap-2 min-w-0">
<Icon
name="chevron-right"
size="small"
class="text-text-subtle transition-transform duration-50 group-data-[expanded]/trigger:rotate-90"
/>
<span class="truncate text-12-medium text-text-strong">{title()}</span>
<div class="px-2 py-1">
<div class="group/trigger relative">
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
<div class="flex items-center gap-1 min-w-0">
<div class="flex items-center justify-center shrink-0 size-6">
<Icon name="branch" size="small" />
</div>
<span class="truncate text-14-medium text-text-strong">{title()}</span>
<Icon
name={open() ? "chevron-down" : "chevron-right"}
size="small"
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/trigger:opacity-100 group-focus-within/trigger:opacity-100"
/>
</div>
</Collapsible.Trigger>
<div class="absolute right-1 top-1/2 -translate-y-1/2 hidden items-center gap-0.5 pointer-events-none group-hover/trigger:flex group-focus-within/trigger:flex">
<Tooltip class="pointer-events-auto" value="More options" placement="top">
<IconButton icon="dot-grid" variant="ghost" class="size-6 rounded-md" />
</Tooltip>
<Tooltip class="pointer-events-auto" value="New session" placement="top">
<IconButton
icon="plus-small"
variant="ghost"
class="size-6 rounded-md"
onClick={() => navigate(`/${slug()}/session`)}
/>
</Tooltip>
</div>
</div>
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<nav class="flex flex-col gap-1 pl-2">
<For each={sessions()}>
{(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} />}
</For>
<nav class="flex flex-col gap-1 px-2">
<Button
as={A}
href={`${slug()}/session`}
variant="ghost"
size="large"
icon="plus-small"
class="flex w-full text-left justify-start text-text-base rounded-md px-3"
icon="edit"
class="hidden _flex w-full text-left justify-start text-text-base rounded-md px-3"
>
New session
</Button>
<For each={sessions()}>
{(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} />}
</For>
<Show when={hasMore()}>
<div class="relative w-full py-1">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-12-medium opacity-50 px-3.5"
class="flex w-full text-left justify-start text-12-medium text-text-weak px-10"
size="large"
onClick={loadMore}
>
@@ -1050,9 +1148,53 @@ export default function Layout(props: ParentProps) {
)
}
const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.project.worktree)
const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() =>
workspaceStore.session
.filter((session) => session.directory === workspaceStore.path.directory)
.filter((session) => !session.parentID)
.toSorted(sortSessions),
)
const hasMore = createMemo(() => workspaceStore.session.length >= workspaceStore.limit)
const loadMore = async () => {
setWorkspaceStore("limit", (limit) => limit + 5)
await globalSync.project.loadSessions(props.project.worktree)
}
return (
<div
ref={(el) => {
if (!props.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar"
>
<nav class="flex flex-col gap-1 px-2">
<For each={sessions()}>
{(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} />}
</For>
<Show when={hasMore()}>
<div class="relative w-full py-1">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-12-medium text-text-weak px-10"
size="large"
onClick={loadMore}
>
Load more
</Button>
</div>
</Show>
</nav>
</div>
)
}
const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
const sync = useGlobalSync()
const project = createMemo(() => currentProject())
const projectName = createMemo(() => {
const current = project()
@@ -1091,9 +1233,11 @@ export default function Layout(props: ParentProps) {
navigate(`/${base64Encode(created.directory)}/session`)
}
const homedir = createMemo(() => sync.data.path.home)
return (
<div class="flex h-full w-full overflow-hidden">
<div class="w-16 shrink-0 bg-background-base border-r border-border-weak-base flex flex-col items-center overflow-hidden">
<div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden">
<div class="flex-1 min-h-0 w-full">
<DragDropProvider
onDragStart={handleDragStart}
@@ -1103,7 +1247,7 @@ export default function Layout(props: ParentProps) {
>
<DragDropSensors />
<ConstrainDragXAxis />
<div class="h-full w-full flex flex-col items-center gap-2 px-2 py-3 overflow-y-auto no-scrollbar">
<div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 overflow-y-auto no-scrollbar">
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
<For each={layout.projects.list()}>
{(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
@@ -1120,7 +1264,7 @@ export default function Layout(props: ParentProps) {
</div>
}
>
<IconButton icon="plus" variant="ghost" class="size-12 rounded-xl" onClick={chooseProject} />
<IconButton icon="plus" variant="ghost" size="large" onClick={chooseProject} />
</Tooltip>
</div>
<DragOverlay>
@@ -1128,12 +1272,25 @@ export default function Layout(props: ParentProps) {
</DragOverlay>
</DragDropProvider>
</div>
<div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings">
<IconButton icon="settings-gear" variant="ghost" size="large" onClick={command.show} />
</Tooltip>
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
<IconButton
icon="help"
variant="ghost"
size="large"
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
/>
</Tooltip>
</div>
</div>
<Show when={expanded()}>
<div
classList={{
"flex flex-col min-h-0 bg-background-base border-r border-border-weak-base": true,
"flex flex-col min-h-0 bg-background-stronger border border-border-weak-base rounded-tl-sm": true,
"flex-1 min-w-0": sidebarProps.mobile,
}}
style={{ width: sidebarProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
@@ -1141,69 +1298,125 @@ export default function Layout(props: ParentProps) {
<Show when={project()}>
{(p) => (
<>
<div class="shrink-0 h-12 flex items-center justify-between px-3 border-b border-border-weak-base">
<div class="min-w-0 truncate text-14-medium text-text-strong">{projectName()}</div>
<Button variant="ghost" size="large" icon="plus-small" onClick={createWorkspace}>
New workspace
</Button>
<div class="shrink-0 px-2 py-1">
<div class="flex items-start justify-between gap-2 p-2">
<div class="flex flex-col min-w-0">
<span class="text-16-medium text-text-strong truncate">{projectName()}</span>
<Tooltip placement="right" value={project()?.worktree} class="shrink-0">
<span class="text-12-regular text-text-base truncate">
{project()?.worktree.replace(homedir(), "~")}
</span>
</Tooltip>
</div>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-6 rounded-md"
/>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p()} />)}>
<DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => closeProject(p().worktree)}>
<DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => layout.sidebar.toggleWorkspaces()}>
<DropdownMenu.ItemLabel>
{layout.sidebar.workspaces() ? "Disable workspaces" : "Enable workspaces"}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
<div class="flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!sidebarProps.mobile) scrollContainerRef = el
}}
class="h-full w-full flex flex-col gap-2 px-2 py-2 overflow-y-auto no-scrollbar"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace directory={directory} project={p()} mobile={sidebarProps.mobile} />
)}
</For>
</SortableProvider>
<Show
when={layout.sidebar.workspaces()}
fallback={
<>
<div class="py-4 px-3">
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => {
navigate(`/${base64Encode(p().worktree)}/session`)
layout.mobileSidebar.hide()
}}
>
New session
</Button>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace project={p()} mobile={sidebarProps.mobile} />
</div>
</>
}
>
<>
<div class="py-4 px-3">
<Button size="large" icon="plus-small" class="w-full" onClick={createWorkspace}>
New workspace
</Button>
</div>
<DragOverlay>
<WorkspaceDragOverlay />
</DragOverlay>
</DragDropProvider>
</div>
<div class="flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!sidebarProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace directory={directory} project={p()} mobile={sidebarProps.mobile} />
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay />
</DragOverlay>
</DragDropProvider>
</div>
</>
</Show>
</>
)}
</Show>
<Show when={!project()}>
<div class="p-3 text-12-regular text-text-weak">Open a project to see workspaces.</div>
<div class="p-3 text-12-regular text-text-weak">Open a project to see sessions.</div>
</Show>
<Show when={providers.all().length > 0}>
<div class="shrink-0 px-2 py-3 border-t border-border-weak-base flex flex-col gap-1.5">
<Button
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="plus"
onClick={connectProvider}
>
Connect provider
</Button>
<Button
as={"a"}
href="https://opencode.ai/desktop-feedback"
target="_blank"
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="bubble-5"
>
Share feedback
</Button>
<Show when={providers.all().length > 0 && providers.paid().length === 0}>
<div class="shrink-0 px-2 py-3 border-t border-border-weak-base">
<div class="rounded-md bg-background-base shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">Getting started</div>
<div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
<div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
</div>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
size="large"
icon="plus"
onClick={connectProvider}
>
Connect provider
</Button>
</div>
</div>
</Show>
</div>
@@ -1212,50 +1425,9 @@ export default function Layout(props: ParentProps) {
)
}
const isMac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const reserveWindowButtons = createMemo(
() => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"),
)
return (
<div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex items-center">
<div
classList={{
"flex items-center w-full min-w-0 pr-2": true,
"pl-2": !isMac(),
}}
>
<Show when={isMac()}>
<div class="w-[72px] h-full shrink-0" data-tauri-drag-region />
</Show>
<IconButton
icon="menu"
variant="ghost"
class="xl:hidden size-8 rounded-md"
onClick={layout.mobileSidebar.toggle}
/>
<TooltipKeybind
class="hidden xl:flex shrink-0"
placement="bottom"
title="Toggle sidebar"
keybind={command.keybind("sidebar.toggle")}
>
<IconButton
icon={layout.sidebar.opened() ? "layout-left" : "layout-right"}
variant="ghost"
class="size-8 rounded-md"
onClick={layout.sidebar.toggle}
/>
</TooltipKeybind>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="flex-1 h-full" data-tauri-drag-region />
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0" />
<Show when={reserveWindowButtons()}>
<div class="w-[120px] h-full shrink-0" data-tauri-drag-region />
</Show>
</div>
</header>
<div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Titlebar />
<div class="flex-1 min-h-0 flex">
<div
classList={{
@@ -1282,7 +1454,7 @@ export default function Layout(props: ParentProps) {
<div class="xl:hidden">
<div
classList={{
"fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
"fixed inset-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
@@ -1302,7 +1474,14 @@ export default function Layout(props: ParentProps) {
</div>
</div>
<main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
"border-l rounded-tl-sm": !layout.sidebar.opened(),
}}
>
{props.children}
</main>
</div>
<Toast.Region />
</div>

View File

@@ -885,6 +885,19 @@ export default function Page() {
window.history.replaceState(null, "", `#${anchor(id)}`)
}
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
const root = scroller
if (!root) {
el.scrollIntoView({ behavior, block: "start" })
return
}
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
const top = a.top - b.top + root.scrollTop
root.scrollTo({ top, behavior })
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
setActiveMessage(message)
@@ -896,7 +909,7 @@ export default function Page() {
requestAnimationFrame(() => {
const el = document.getElementById(anchor(message.id))
if (el) el.scrollIntoView({ behavior, block: "start" })
if (el) scrollToElement(el, behavior)
})
updateHash(message.id)
@@ -904,7 +917,7 @@ export default function Page() {
}
const el = document.getElementById(anchor(message.id))
if (el) el.scrollIntoView({ behavior, block: "start" })
if (el) scrollToElement(el, behavior)
updateHash(message.id)
}
@@ -956,7 +969,7 @@ export default function Page() {
const hashTarget = document.getElementById(hash)
if (hashTarget) {
hashTarget.scrollIntoView({ behavior: "auto", block: "start" })
scrollToElement(hashTarget, "auto")
return
}

View File

@@ -17,7 +17,7 @@
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-screen"></div>
<div id="root" class="flex flex-col h-dvh"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

View File

@@ -41,6 +41,7 @@ semver = "1.0.27"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
uuid = { version = "1.19.0", features = ["v4"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
webkit2gtk = "=2.0.1"

View File

@@ -7,6 +7,7 @@
"core:default",
"opener:default",
"core:window:allow-start-dragging",
"core:window:allow-set-theme",
"core:webview:allow-set-webview-zoom",
"core:window:allow-is-focused",
"core:window:allow-show",

View File

@@ -14,7 +14,7 @@ use std::{
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewUrl, WebviewWindow};
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_store::StoreExt;
@@ -237,7 +237,14 @@ pub fn run() {
}
}))
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_window_state::Builder::new().build())
.plugin(
tauri_plugin_window_state::Builder::new()
.with_state_flags(
tauri_plugin_window_state::StateFlags::all()
- tauri_plugin_window_state::StateFlags::DECORATIONS,
)
.build(),
)
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
@@ -268,29 +275,25 @@ pub fn run() {
.map(|m| m.size().to_logical(m.scale_factor()))
.unwrap_or(LogicalSize::new(1920, 1080));
#[allow(unused_mut)]
let mut window_builder =
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
.title("OpenCode")
.inner_size(size.width as f64, size.height as f64)
.decorations(true)
.zoom_hotkeys_enabled(true)
.disable_drag_drop_handler()
.initialization_script(format!(
r#"
let config = app
.config()
.app
.windows
.iter()
.find(|w| w.label == "main")
.expect("main window config missing");
let window_builder = WebviewWindowBuilder::from_config(&app, config)
.expect("Failed to create window builder from config")
.inner_size(size.width as f64, size.height as f64)
.initialization_script(format!(
r#"
window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {updater_enabled};
"#
));
));
#[cfg(target_os = "macos")]
{
window_builder = window_builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
}
window_builder.build().expect("Failed to create window");
let _window = window_builder.build().expect("Failed to create window");
let (tx, rx) = oneshot::channel();
app.manage(ServerState::new(None, rx));

View File

@@ -11,6 +11,20 @@
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"create": false,
"title": "OpenCode",
"url": "/",
"decorations": true,
"dragDropEnabled": false,
"zoomHotkeysEnabled": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"trafficLightPosition": { "x": 12.0, "y": 18.0 }
}
],
"withGlobalTauri": true,
"security": {
"csp": null

View File

@@ -2,6 +2,27 @@
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenCode",
"identifier": "ai.opencode.desktop",
"app": {
"windows": [
{
"label": "main",
"create": false,
"title": "OpenCode",
"url": "/",
"decorations": true,
"dragDropEnabled": false,
"zoomHotkeysEnabled": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"trafficLightPosition": { "x": 12.0, "y": 18.0 }
}
],
"withGlobalTauri": true,
"security": {
"csp": null
},
"macOSPrivateApi": true
},
"bundle": {
"createUpdaterArtifacts": true,
"icon": [

View File

@@ -0,0 +1,55 @@
[data-slot="hover-card-trigger"] {
display: inline-flex;
}
[data-component="hover-card-content"] {
z-index: 50;
min-width: 200px;
max-width: 320px;
border-radius: var(--radius-md);
background-color: var(--surface-raised-stronger-non-alpha);
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
background-clip: padding-box;
box-shadow: var(--shadow-md);
transform-origin: var(--kb-hovercard-content-transform-origin);
&:focus-within {
outline: none;
}
&[data-closed] {
animation: hover-card-close 0.15s ease-out;
}
&[data-expanded] {
animation: hover-card-open 0.15s ease-out;
}
[data-slot="hover-card-body"] {
padding: 12px;
}
}
@keyframes hover-card-open {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes hover-card-close {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}

View File

@@ -0,0 +1,31 @@
import { HoverCard as Kobalte } from "@kobalte/core/hover-card"
import { ComponentProps, JSXElement, ParentProps, splitProps } from "solid-js"
export interface HoverCardProps extends ParentProps, Omit<ComponentProps<typeof Kobalte>, "children"> {
trigger: JSXElement
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
}
export function HoverCard(props: HoverCardProps) {
const [local, rest] = splitProps(props, ["trigger", "class", "classList", "children"])
return (
<Kobalte gutter={4} {...rest}>
<Kobalte.Trigger as="div" data-slot="hover-card-trigger">
{local.trigger}
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content
data-component="hover-card-content"
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
>
<div data-slot="hover-card-body">{local.children}</div>
</Kobalte.Content>
</Kobalte.Portal>
</Kobalte>
)
}

View File

@@ -45,7 +45,6 @@ const icons = {
"square-arrow-top-right": `<path d="M7.91675 2.9165H2.91675V17.0832H17.0834V12.0832M12.0834 2.9165H17.0834V7.9165M9.58342 10.4165L16.6667 3.33317" stroke="currentColor" stroke-linecap="square"/>`,
"speech-bubble": `<path d="M18.3334 10.0003C18.3334 5.57324 15.0927 2.91699 10.0001 2.91699C4.90749 2.91699 1.66675 5.57324 1.66675 10.0003C1.66675 11.1497 2.45578 13.1016 2.5771 13.3949C2.5878 13.4207 2.59839 13.4444 2.60802 13.4706C2.69194 13.6996 3.04282 14.9364 1.66675 16.7684C3.5186 17.6538 5.48526 16.1982 5.48526 16.1982C6.84592 16.9202 8.46491 17.0837 10.0001 17.0837C15.0927 17.0837 18.3334 14.4274 18.3334 10.0003Z" stroke="currentColor" stroke-linecap="square"/>`,
"folder-add-left": `<path d="M2.08333 9.58268V2.91602H8.33333L10 5.41602H17.9167V16.2493H8.75M3.75 12.0827V14.5827M3.75 14.5827V17.0827M3.75 14.5827H1.25M3.75 14.5827H6.25" stroke="currentColor" stroke-linecap="square"/>`,
"settings-gear": ` <path d="M9.99999 1L18 5.49998L18 14.5001L9.99998 19L2 14.5003L2 5.49996L9.99999 1Z" stroke="currentColor" stroke-linecap="square"/><path d="M13.2941 10.0001C13.2941 11.8313 11.8193 13.3159 10 13.3159C8.18073 13.3159 6.7059 11.8313 6.7059 10.0001C6.7059 8.16879 8.18073 6.68425 10 6.68425C11.8193 6.68425 13.2941 8.16879 13.2941 10.0001Z" stroke="currentColor" stroke-linecap="square"/>`,
github: `<path d="M10.0001 1.62549C14.6042 1.62549 18.3334 5.35465 18.3334 9.95882C18.333 11.7049 17.785 13.4068 16.7666 14.8251C15.7482 16.2434 14.3107 17.3066 12.6563 17.8651C12.2397 17.9484 12.0834 17.688 12.0834 17.4692C12.0834 17.188 12.0938 16.2922 12.0938 15.1776C12.0938 14.3963 11.8334 13.8963 11.5313 13.6359C13.3855 13.4276 15.3334 12.7192 15.3334 9.52132C15.3334 8.60465 15.0105 7.86507 14.4792 7.28174C14.5626 7.0734 14.8542 6.21924 14.3959 5.0734C14.3959 5.0734 13.698 4.84424 12.1042 5.92757C11.4376 5.74007 10.7292 5.64632 10.0209 5.64632C9.31258 5.64632 8.60425 5.74007 7.93758 5.92757C6.34383 4.85465 5.64592 5.0734 5.64592 5.0734C5.18758 6.21924 5.47925 7.0734 5.56258 7.28174C5.03133 7.86507 4.70842 8.61507 4.70842 9.52132C4.70842 12.7088 6.64592 13.4276 8.50008 13.6359C8.2605 13.8442 8.04175 14.2088 7.96883 14.7505C7.48967 14.9692 6.29175 15.3234 5.54175 14.063C5.3855 13.813 4.91675 13.1984 4.2605 13.2088C3.56258 13.2192 3.97925 13.6047 4.27092 13.7609C4.62508 13.9588 5.03133 14.6984 5.12508 14.938C5.29175 15.4067 5.83342 16.3026 7.92717 15.9172C7.92717 16.6151 7.93758 17.2713 7.93758 17.4692C7.93758 17.688 7.78133 17.938 7.36467 17.8651C5.70491 17.3126 4.26126 16.2515 3.23851 14.8324C2.21576 13.4133 1.66583 11.7081 1.66675 9.95882C1.66675 5.35465 5.39592 1.62549 10.0001 1.62549Z" fill="currentColor"/>`,
discord: `<path d="M16.0742 4.45014C14.9244 3.92097 13.7106 3.54556 12.4638 3.3335C12.2932 3.64011 12.1388 3.95557 12.0013 4.27856C10.6732 4.07738 9.32261 4.07738 7.99451 4.27856C7.85694 3.9556 7.70257 3.64014 7.53203 3.3335C6.28441 3.54735 5.06981 3.92365 3.91889 4.45291C1.63401 7.85128 1.01462 11.1652 1.32431 14.4322C2.6624 15.426 4.16009 16.1819 5.7523 16.6668C6.11082 16.1821 6.42806 15.6678 6.70066 15.1295C6.18289 14.9351 5.68315 14.6953 5.20723 14.4128C5.33249 14.3215 5.45499 14.2274 5.57336 14.136C6.95819 14.7907 8.46965 15.1302 9.99997 15.1302C11.5303 15.1302 13.0418 14.7907 14.4266 14.136C14.5463 14.2343 14.6688 14.3284 14.7927 14.4128C14.3159 14.6957 13.8152 14.9361 13.2965 15.1309C13.5688 15.669 13.8861 16.1828 14.2449 16.6668C15.8385 16.1838 17.3373 15.4283 18.6756 14.4335C19.039 10.645 18.0549 7.36145 16.0742 4.45014ZM7.09294 12.423C6.22992 12.423 5.51693 11.6357 5.51693 10.6671C5.51693 9.69852 6.20514 8.90427 7.09019 8.90427C7.97524 8.90427 8.68272 9.69852 8.66758 10.6671C8.65244 11.6357 7.97248 12.423 7.09294 12.423ZM12.907 12.423C12.0426 12.423 11.3324 11.6357 11.3324 10.6671C11.3324 9.69852 12.0206 8.90427 12.907 8.90427C13.7934 8.90427 14.4954 9.69852 14.4803 10.6671C14.4651 11.6357 13.7865 12.423 12.907 12.423Z" fill="currentColor"/>`,
"layout-bottom": `<path d="M2.91699 17.0832L2.41699 17.0832L2.41699 17.5832L2.91699 17.5832L2.91699 17.0832ZM2.91699 2.91653L2.91699 2.41653L2.41699 2.41653L2.41699 2.91653L2.91699 2.91653ZM17.0837 2.91653L17.5837 2.91653L17.5837 2.41653L17.0837 2.41653L17.0837 2.91653ZM17.0837 17.0832L17.5837 17.0832L17.5837 17.5832L17.0837 17.5832L17.0837 17.0832ZM17.0837 12.5827L17.5837 12.5827L17.5837 11.5827L17.0837 11.5827L17.0837 12.0827L17.0837 12.5827ZM2.91699 11.5827L2.41699 11.5827L2.41699 12.5827L2.91699 12.5827L2.91699 12.0827L2.91699 11.5827ZM2.91699 17.0832L3.41699 17.0832L3.41699 2.91653L2.91699 2.91653L2.41699 2.91653L2.41699 17.0832L2.91699 17.0832ZM2.91699 2.91653L2.91699 3.41653L17.0837 3.41653L17.0837 2.91653L17.0837 2.41653L2.91699 2.41653L2.91699 2.91653ZM17.0837 2.91653L16.5837 2.91653L16.5837 17.0832L17.0837 17.0832L17.5837 17.0832L17.5837 2.91653L17.0837 2.91653ZM17.0837 17.0832L17.0837 16.5832L2.91699 16.5832L2.91699 17.0832L2.91699 17.5832L17.0837 17.5832L17.0837 17.0832ZM17.0837 12.0827L17.0837 11.5827L2.91699 11.5827L2.91699 12.0827L2.91699 12.5827L17.0837 12.5827L17.0837 12.0827Z" fill="currentColor"/>`,
@@ -60,7 +59,10 @@ const icons = {
download: `<path d="M13.9583 10.6257L10 14.584L6.04167 10.6257M10 2.08398V13.959M16.25 17.9173H3.75" stroke="currentColor" stroke-linecap="square"/>`,
menu: `<path d="M2.5 5H17.5M2.5 10H17.5M2.5 15H17.5" stroke="currentColor" stroke-linecap="square"/>`,
server: `<rect x="3.35547" y="1.92969" width="13.2857" height="16.1429" stroke="currentColor"/><rect x="3.35547" y="11.9297" width="13.2857" height="6.14286" stroke="currentColor"/><rect x="12.8555" y="14.2852" width="1.42857" height="1.42857" fill="currentColor"/><rect x="10" y="14.2852" width="1.42857" height="1.42857" fill="currentColor"/>`,
branch: `<path d="M14.1667 9.9987V10.4987H14.6667V9.9987H14.1667ZM5.83333 9.9987V9.4987H5.33333V9.9987H5.83333ZM6.33333 6.66536V6.16536H5.33333V6.66536H5.83333H6.33333ZM14.6667 6.66536V6.16536H13.6667V6.66536H14.1667H14.6667ZM5.33333 13.332C5.33333 13.6082 5.55719 13.832 5.83333 13.832C6.10948 13.832 6.33333 13.6082 6.33333 13.332H5.83333H5.33333ZM7.91667 4.16536H7.41667C7.41667 5.03982 6.70778 5.7487 5.83333 5.7487V6.2487V6.7487C7.26007 6.7487 8.41667 5.5921 8.41667 4.16536H7.91667ZM5.83333 6.2487V5.7487C4.95888 5.7487 4.25 5.03982 4.25 4.16536H3.75H3.25C3.25 5.5921 4.4066 6.7487 5.83333 6.7487V6.2487ZM3.75 4.16536H4.25C4.25 3.29091 4.95888 2.58203 5.83333 2.58203V2.08203V1.58203C4.4066 1.58203 3.25 2.73863 3.25 4.16536H3.75ZM5.83333 2.08203V2.58203C6.70778 2.58203 7.41667 3.29091 7.41667 4.16536H7.91667H8.41667C8.41667 2.73863 7.26007 1.58203 5.83333 1.58203V2.08203ZM7.91667 15.832H7.41667C7.41667 16.7065 6.70778 17.4154 5.83333 17.4154V17.9154V18.4154C7.26007 18.4154 8.41667 17.2588 8.41667 15.832H7.91667ZM5.83333 17.9154V17.4154C4.95888 17.4154 4.25 16.7065 4.25 15.832H3.75H3.25C3.25 17.2588 4.4066 18.4154 5.83333 18.4154V17.9154ZM3.75 15.832H4.25C4.25 14.9576 4.95888 14.2487 5.83333 14.2487V13.7487V13.2487C4.4066 13.2487 3.25 14.4053 3.25 15.832H3.75ZM5.83333 13.7487V14.2487C6.70778 14.2487 7.41667 14.9576 7.41667 15.832H7.91667H8.41667C8.41667 14.4053 7.26007 13.2487 5.83333 13.2487V13.7487ZM14.1667 9.9987V9.4987H5.83333V9.9987V10.4987H14.1667V9.9987ZM16.25 4.16536H15.75C15.75 5.03982 15.0411 5.7487 14.1667 5.7487V6.2487V6.7487C15.5934 6.7487 16.75 5.5921 16.75 4.16536H16.25ZM14.1667 6.2487V5.7487C13.2922 5.7487 12.5833 5.03982 12.5833 4.16536H12.0833H11.5833C11.5833 5.5921 12.7399 6.7487 14.1667 6.7487V6.2487ZM12.0833 4.16536H12.5833C12.5833 3.29091 13.2922 2.58203 14.1667 2.58203V2.08203V1.58203C12.7399 1.58203 11.5833 2.73863 11.5833 4.16536H12.0833ZM14.1667 2.08203V2.58203C15.0411 2.58203 15.75 3.29091 15.75 4.16536H16.25H16.75C16.75 2.73863 15.5934 1.58203 14.1667 1.58203V2.08203ZM14.1667 6.66536H13.6667V9.9987H14.1667H14.6667V6.66536H14.1667ZM5.83333 6.66536H5.33333V13.332H5.83333H6.33333V6.66536H5.83333ZM5.83333 9.9987H5.33333V13.332H5.83333H6.33333V9.9987H5.83333Z" fill="currentColor"/>`,
branch: `<path d="M14.2036 7.19987L14.2079 6.69989L13.2079 6.69132L13.2036 7.1913L13.7036 7.19559L14.2036 7.19987ZM8.14804 5.09032H7.64804C7.64804 5.75797 7.06861 6.34471 6.29619 6.34471V6.84471V7.34471C7.56926 7.34471 8.64804 6.36051 8.64804 5.09032H8.14804ZM6.29619 6.84471V6.34471C5.52376 6.34471 4.94434 5.75797 4.94434 5.09032H4.44434H3.94434C3.94434 6.36051 5.02311 7.34471 6.29619 7.34471V6.84471ZM4.44434 5.09032H4.94434C4.94434 4.42267 5.52376 3.83594 6.29619 3.83594V3.33594V2.83594C5.02311 2.83594 3.94434 3.82013 3.94434 5.09032H4.44434ZM6.29619 3.33594V3.83594C7.06861 3.83594 7.64804 4.42267 7.64804 5.09032H8.14804H8.64804C8.64804 3.82013 7.56926 2.83594 6.29619 2.83594V3.33594ZM8.14804 14.9149H7.64804C7.64804 15.5825 7.06861 16.1693 6.29619 16.1693V16.6693V17.1693C7.56926 17.1693 8.64804 16.1851 8.64804 14.9149H8.14804ZM6.29619 16.6693V16.1693C5.52376 16.1693 4.94434 15.5825 4.94434 14.9149H4.44434H3.94434C3.94434 16.1851 5.02311 17.1693 6.29619 17.1693V16.6693ZM4.44434 14.9149H4.94434C4.94434 14.2472 5.52376 13.6605 6.29619 13.6605V13.1605V12.6605C5.02311 12.6605 3.94434 13.6447 3.94434 14.9149H4.44434ZM6.29619 13.1605V13.6605C7.06861 13.6605 7.64804 14.2472 7.64804 14.9149H8.14804H8.64804C8.64804 13.6447 7.56926 12.6605 6.29619 12.6605V13.1605ZM15.5554 5.09032H15.0554C15.0554 5.75797 14.476 6.34471 13.7036 6.34471V6.84471V7.34471C14.9767 7.34471 16.0554 6.36051 16.0554 5.09032H15.5554ZM13.7036 6.84471V6.34471C12.9312 6.34471 12.3517 5.75797 12.3517 5.09032H11.8517H11.3517C11.3517 6.36051 12.4305 7.34471 13.7036 7.34471V6.84471ZM11.8517 5.09032H12.3517C12.3517 4.42267 12.9312 3.83594 13.7036 3.83594V3.33594V2.83594C12.4305 2.83594 11.3517 3.82013 11.3517 5.09032H11.8517ZM13.7036 3.33594V3.83594C14.476 3.83594 15.0554 4.42267 15.0554 5.09032H15.5554H16.0554C16.0554 3.82013 14.9767 2.83594 13.7036 2.83594V3.33594ZM13.7036 7.19559L13.2036 7.1913L13.1544 12.9277L13.6544 12.932L14.1544 12.9363L14.2036 7.19987L13.7036 7.19559ZM6.29619 6.84471H5.79619V13.1605H6.29619H6.79619V6.84471H6.29619ZM11.6545 14.9149V14.4149H8.14804V14.9149V15.4149H11.6545V14.9149ZM13.6544 12.932L13.1544 12.9277C13.1474 13.7511 12.4779 14.4149 11.6545 14.4149V14.9149V15.4149C13.0269 15.4149 14.1426 14.3086 14.1544 12.9363L13.6544 12.932Z" fill="currentColor"/>`,
edit: `<path d="M17.0832 17.0807V17.5807H17.5832V17.0807H17.0832ZM2.9165 17.0807H2.4165V17.5807H2.9165V17.0807ZM2.9165 2.91406V2.41406H2.4165V2.91406H2.9165ZM9.58317 3.41406H10.0832V2.41406H9.58317V2.91406V3.41406ZM17.5832 10.4141V9.91406H16.5832V10.4141H17.0832H17.5832ZM6.24984 11.2474L5.89628 10.8938L5.74984 11.0403V11.2474H6.24984ZM6.24984 13.7474H5.74984V14.2474H6.24984V13.7474ZM8.74984 13.7474V14.2474H8.95694L9.10339 14.101L8.74984 13.7474ZM15.2082 2.28906L15.5617 1.93551L15.2082 1.58196L14.8546 1.93551L15.2082 2.28906ZM17.7082 4.78906L18.0617 5.14262L18.4153 4.78906L18.0617 4.43551L17.7082 4.78906ZM17.0832 17.0807V16.5807H2.9165V17.0807V17.5807H17.0832V17.0807ZM2.9165 17.0807H3.4165V2.91406H2.9165H2.4165V17.0807H2.9165ZM2.9165 2.91406V3.41406H9.58317V2.91406V2.41406H2.9165V2.91406ZM17.0832 10.4141H16.5832V17.0807H17.0832H17.5832V10.4141H17.0832ZM6.24984 11.2474H5.74984V13.7474H6.24984H6.74984V11.2474H6.24984ZM6.24984 13.7474V14.2474H8.74984V13.7474V13.2474H6.24984V13.7474ZM6.24984 11.2474L6.60339 11.6009L15.5617 2.64262L15.2082 2.28906L14.8546 1.93551L5.89628 10.8938L6.24984 11.2474ZM15.2082 2.28906L14.8546 2.64262L17.3546 5.14262L17.7082 4.78906L18.0617 4.43551L15.5617 1.93551L15.2082 2.28906ZM17.7082 4.78906L17.3546 4.43551L8.39628 13.3938L8.74984 13.7474L9.10339 14.101L18.0617 5.14262L17.7082 4.78906Z" fill="currentColor"/>`,
help: `<path d="M7.91683 7.91927V6.2526H12.0835V8.7526L10.0002 10.0026V12.0859M10.0002 13.7526V13.7609M17.9168 10.0026C17.9168 14.3749 14.3724 17.9193 10.0002 17.9193C5.62791 17.9193 2.0835 14.3749 2.0835 10.0026C2.0835 5.63035 5.62791 2.08594 10.0002 2.08594C14.3724 2.08594 17.9168 5.63035 17.9168 10.0026Z" stroke="currentColor" stroke-linecap="square"/>`,
"settings-gear": `<path d="M7.62516 4.46094L5.05225 3.86719L3.86475 5.05469L4.4585 7.6276L2.0835 9.21094V10.7943L4.4585 12.3776L3.86475 14.9505L5.05225 16.138L7.62516 15.5443L9.2085 17.9193H10.7918L12.3752 15.5443L14.9481 16.138L16.1356 14.9505L15.5418 12.3776L17.9168 10.7943V9.21094L15.5418 7.6276L16.1356 5.05469L14.9481 3.86719L12.3752 4.46094L10.7918 2.08594H9.2085L7.62516 4.46094Z" stroke="currentColor"/><path d="M12.5002 10.0026C12.5002 11.3833 11.3809 12.5026 10.0002 12.5026C8.61945 12.5026 7.50016 11.3833 7.50016 10.0026C7.50016 8.62189 8.61945 7.5026 10.0002 7.5026C11.3809 7.5026 12.5002 8.62189 12.5002 10.0026Z" stroke="currentColor"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {

View File

@@ -10,9 +10,14 @@ const squares = Array.from({ length: 16 }, (_, i) => ({
outer: outerIndices.has(i),
}))
export function Spinner(props: { class?: string; classList?: ComponentProps<"div">["classList"] }) {
export function Spinner(props: {
class?: string
classList?: ComponentProps<"div">["classList"]
style?: ComponentProps<"div">["style"]
}) {
return (
<svg
{...props}
viewBox="0 0 15 15"
data-component="spinner"
classList={{

View File

@@ -19,6 +19,7 @@
@import "../components/dropdown-menu.css" layer(components);
@import "../components/dialog.css" layer(components);
@import "../components/file-icon.css" layer(components);
@import "../components/hover-card.css" layer(components);
@import "../components/provider-icon.css" layer(components);
@import "../components/icon.css" layer(components);
@import "../components/icon-button.css" layer(components);