wip: css/ui and desktop work

This commit is contained in:
Adam
2025-10-16 14:53:44 -05:00
parent fc18fc8a08
commit 47d9e01765
52 changed files with 539 additions and 1641 deletions

View File

@@ -1,6 +1,7 @@
import { For, Match, Show, Switch, createSignal, splitProps } from "solid-js"
import { Tabs } from "@/ui/tabs"
import { FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui"
import { Tabs, Tooltip } from "@opencode-ai/ui"
import { Icon } from "@opencode-ai/ui"
import { FileIcon, IconButton } from "@/ui"
import {
DragDropProvider,
DragDropSensors,
@@ -64,127 +65,119 @@ export default function EditorPane(props: EditorPaneProps): JSX.Element {
}
return (
<div class="relative flex h-full flex-col">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs
class="relative grow w-full flex flex-col h-full"
value={local.file.active()?.path}
onChange={handleTabChange}
>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List class="grow">
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
<For each={local.file.opened()}>
{(file) => (
<SortableTab file={file} onTabClick={localProps.onFileClick} onTabClose={handleTabClose} />
)}
</For>
</SortableProvider>
</Tabs.List>
<div class="shrink-0 h-full flex items-center gap-1 px-2 border-b border-border-subtle/40">
<Show when={local.file.active() && local.file.active()!.content?.diff}>
{(() => {
const activeFile = local.file.active()!
const view = local.file.view(activeFile.path)
return (
<div class="flex items-center gap-1">
<Show when={view !== "raw"}>
<div class="mr-1 flex items-center gap-1">
<Tooltip value="Previous change" placement="bottom">
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(-1)}>
<Icon name="arrow-up" size={14} />
</IconButton>
</Tooltip>
<Tooltip value="Next change" placement="bottom">
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(1)}>
<Icon name="arrow-down" size={14} />
</IconButton>
</Tooltip>
</div>
</Show>
<Tooltip value="Raw" placement="bottom">
<IconButton
size="xs"
variant="ghost"
classList={{
"text-text": view === "raw",
"text-text-muted/70": view !== "raw",
"bg-background-element": view === "raw",
}}
onClick={() => local.file.setView(activeFile.path, "raw")}
>
<Icon name="file-text" size={14} />
</IconButton>
</Tooltip>
<Tooltip value="Unified diff" placement="bottom">
<IconButton
size="xs"
variant="ghost"
classList={{
"text-text": view === "diff-unified",
"text-text-muted/70": view !== "diff-unified",
"bg-background-element": view === "diff-unified",
}}
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
>
<Icon name="checklist" size={14} />
</IconButton>
</Tooltip>
<Tooltip value="Split diff" placement="bottom">
<IconButton
size="xs"
variant="ghost"
classList={{
"text-text": view === "diff-split",
"text-text-muted/70": view !== "diff-split",
"bg-background-element": view === "diff-split",
}}
onClick={() => local.file.setView(activeFile.path, "diff-split")}
>
<Icon name="columns" size={14} />
</IconButton>
</Tooltip>
</div>
)
})()}
</Show>
</div>
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={local.file.active()?.path} onChange={handleTabChange}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
<For each={local.file.opened()}>
{(file) => <SortableTab file={file} onTabClick={localProps.onFileClick} onTabClose={handleTabClose} />}
</For>
</SortableProvider>
</Tabs.List>
<div class="hidden shrink-0 h-full _flex items-center gap-1 px-2 border-b border-border-subtle/40">
<Show when={local.file.active() && local.file.active()!.content?.diff}>
{(() => {
const activeFile = local.file.active()!
const view = local.file.view(activeFile.path)
return (
<div class="flex items-center gap-1">
<Show when={view !== "raw"}>
<div class="mr-1 flex items-center gap-1">
<Tooltip value="Previous change" placement="bottom">
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(-1)}>
<Icon name="arrow-up" size={14} />
</IconButton>
</Tooltip>
<Tooltip value="Next change" placement="bottom">
<IconButton size="xs" variant="ghost" onClick={() => navigateChange(1)}>
<Icon name="arrow-down" size={14} />
</IconButton>
</Tooltip>
</div>
</Show>
<Tooltip value="Raw" placement="bottom">
<IconButton
size="xs"
variant="ghost"
classList={{
"text-text": view === "raw",
"text-text-muted/70": view !== "raw",
"bg-background-element": view === "raw",
}}
onClick={() => local.file.setView(activeFile.path, "raw")}
>
<Icon name="file-text" size={14} />
</IconButton>
</Tooltip>
<Tooltip value="Unified diff" placement="bottom">
<IconButton
size="xs"
variant="ghost"
classList={{
"text-text": view === "diff-unified",
"text-text-muted/70": view !== "diff-unified",
"bg-background-element": view === "diff-unified",
}}
onClick={() => local.file.setView(activeFile.path, "diff-unified")}
>
<Icon name="checklist" size={14} />
</IconButton>
</Tooltip>
<Tooltip value="Split diff" placement="bottom">
<IconButton
size="xs"
variant="ghost"
classList={{
"text-text": view === "diff-split",
"text-text-muted/70": view !== "diff-split",
"bg-background-element": view === "diff-split",
}}
onClick={() => local.file.setView(activeFile.path, "diff-split")}
>
<Icon name="columns" size={14} />
</IconButton>
</Tooltip>
</div>
)
})()}
</Show>
</div>
<For each={local.file.opened()}>
{(file) => (
<Tabs.Content value={file.path} class="grow h-full pt-1 select-text">
{(() => {
const view = local.file.view(file.path)
const showRaw = view === "raw" || !file.content?.diff
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
return <Code path={file.path} code={code} class="[&_code]:pb-60" />
})()}
</Tabs.Content>
)}
</For>
</Tabs>
<DragOverlay>
{(() => {
const id = activeItem()
if (!id) return null
const draggedFile = local.file.node(id)
if (!draggedFile) return null
return (
<div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent">
<TabVisual file={draggedFile} />
</div>
)
})()}
</DragOverlay>
</DragDropProvider>
</div>
</div>
<For each={local.file.opened()}>
{(file) => (
<Tabs.Content value={file.path} class="select-text">
{(() => {
const view = local.file.view(file.path)
const showRaw = view === "raw" || !file.content?.diff
const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
return <Code path={file.path} code={code} class="[&_code]:pb-60" />
})()}
</Tabs.Content>
)}
</For>
</Tabs>
<DragOverlay>
{(() => {
const id = activeItem()
if (!id) return null
const draggedFile = local.file.node(id)
if (!draggedFile) return null
return (
<div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent">
<TabVisual file={draggedFile} />
</div>
)
})()}
</DragOverlay>
</DragDropProvider>
)
}

View File

@@ -1,6 +1,7 @@
import { useLocal } from "@/context"
import type { LocalFile } from "@/context/local"
import { Collapsible, FileIcon, Tooltip } from "@/ui"
import { Tooltip } from "@opencode-ai/ui"
import { Collapsible, FileIcon } from "@/ui"
import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web"

View File

@@ -1,8 +1,8 @@
import { For, Show, createMemo, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Popover } from "@kobalte/core/popover"
import { Button, FileIcon, Icon, IconButton, Tooltip } from "@/ui"
import { Select } from "@/components/select"
import { Tooltip, Button, Icon, Select } from "@opencode-ai/ui"
import { FileIcon, IconButton } from "@/ui"
import { useLocal } from "@/context"
import type { FileContext, LocalFile } from "@/context/local"
import { getDirectory, getFilename } from "@/utils"

View File

@@ -1,217 +0,0 @@
import { batch, createContext, createMemo, createSignal, onCleanup, Show, useContext } from "solid-js"
import type { ComponentProps, JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context"
type PaneDefault = number | { size: number; visible?: boolean }
type LayoutContextValue = {
id: string
register: (pane: string, options: { min?: number | string; max?: number | string }) => void
size: (pane: string) => number
visible: (pane: string) => boolean
percent: (pane: string) => number
next: (pane: string) => string | undefined
startDrag: (left: string, right: string | undefined, event: MouseEvent) => void
dragging: () => string | undefined
}
const LayoutContext = createContext<LayoutContextValue | undefined>(undefined)
export interface ResizeableLayoutProps {
id: string
defaults: Record<string, PaneDefault>
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
children: JSX.Element
}
export interface ResizeablePaneProps {
id: string
minSize?: number | string
maxSize?: number | string
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
children: JSX.Element
}
export function ResizeableLayout(props: ResizeableLayoutProps) {
const local = useLocal()
const [meta, setMeta] = createStore<Record<string, { min: number; max: number; minPx?: number; maxPx?: number }>>({})
const [dragging, setDragging] = createSignal<string>()
let container: HTMLDivElement | undefined
local.layout.ensure(props.id, props.defaults)
const order = createMemo(() => local.layout.order(props.id))
const visibleOrder = createMemo(() => order().filter((pane) => local.layout.visible(props.id, pane)))
const totalVisible = createMemo(() => {
const panes = visibleOrder()
if (!panes.length) return 0
return panes.reduce((total, pane) => total + local.layout.size(props.id, pane), 0)
})
const percent = (pane: string) => {
const panes = visibleOrder()
if (!panes.length) return 0
const total = totalVisible()
if (!total) return 100 / panes.length
return (local.layout.size(props.id, pane) / total) * 100
}
const nextPane = (pane: string) => {
const panes = visibleOrder()
const index = panes.indexOf(pane)
if (index === -1) return undefined
return panes[index + 1]
}
const minMax = (pane: string) => meta[pane] ?? { min: 5, max: 95 }
const pxToPercent = (px: number, total: number) => (px / total) * 100
const boundsForPair = (left: string, right: string, total: number) => {
const leftMeta = minMax(left)
const rightMeta = minMax(right)
const containerWidth = container?.getBoundingClientRect().width ?? 0
let minLeft = leftMeta.min
let maxLeft = leftMeta.max
let minRight = rightMeta.min
let maxRight = rightMeta.max
if (containerWidth && leftMeta.minPx !== undefined) minLeft = pxToPercent(leftMeta.minPx, containerWidth)
if (containerWidth && leftMeta.maxPx !== undefined) maxLeft = pxToPercent(leftMeta.maxPx, containerWidth)
if (containerWidth && rightMeta.minPx !== undefined) minRight = pxToPercent(rightMeta.minPx, containerWidth)
if (containerWidth && rightMeta.maxPx !== undefined) maxRight = pxToPercent(rightMeta.maxPx, containerWidth)
const finalMinLeft = Math.max(minLeft, total - maxRight)
const finalMaxLeft = Math.min(maxLeft, total - minRight)
return {
min: Math.min(finalMinLeft, finalMaxLeft),
max: Math.max(finalMinLeft, finalMaxLeft),
}
}
const setPair = (left: string, right: string, leftSize: number, rightSize: number) => {
batch(() => {
local.layout.setSize(props.id, left, leftSize)
local.layout.setSize(props.id, right, rightSize)
})
}
const startDrag = (left: string, right: string | undefined, event: MouseEvent) => {
if (!right) return
if (!container) return
const rect = container.getBoundingClientRect()
if (!rect.width) return
event.preventDefault()
const startX = event.clientX
const startLeft = local.layout.size(props.id, left)
const startRight = local.layout.size(props.id, right)
const total = startLeft + startRight
const bounds = boundsForPair(left, right, total)
const move = (moveEvent: MouseEvent) => {
const delta = ((moveEvent.clientX - startX) / rect.width) * 100
const nextLeft = Math.max(bounds.min, Math.min(bounds.max, startLeft + delta))
const nextRight = total - nextLeft
setPair(left, right, nextLeft, nextRight)
}
const stop = () => {
setDragging()
document.removeEventListener("mousemove", move)
document.removeEventListener("mouseup", stop)
}
setDragging(left)
document.addEventListener("mousemove", move)
document.addEventListener("mouseup", stop)
onCleanup(() => stop())
}
const register = (pane: string, options: { min?: number | string; max?: number | string }) => {
let min = 5
let max = 95
let minPx: number | undefined
let maxPx: number | undefined
if (typeof options.min === "string" && options.min.endsWith("px")) {
minPx = parseInt(options.min)
min = 0
} else if (typeof options.min === "number") {
min = options.min
}
if (typeof options.max === "string" && options.max.endsWith("px")) {
maxPx = parseInt(options.max)
max = 100
} else if (typeof options.max === "number") {
max = options.max
}
setMeta(pane, () => ({ min, max, minPx, maxPx }))
const fallback = props.defaults[pane]
local.layout.ensurePane(props.id, pane, fallback ?? { size: min, visible: true })
}
const contextValue: LayoutContextValue = {
id: props.id,
register,
size: (pane) => local.layout.size(props.id, pane),
visible: (pane) => local.layout.visible(props.id, pane),
percent,
next: nextPane,
startDrag,
dragging,
}
return (
<LayoutContext.Provider value={contextValue}>
<div
ref={(node) => {
container = node ?? undefined
}}
class={props.class ? `relative flex h-full w-full ${props.class}` : "relative flex h-full w-full"}
classList={props.classList}
>
{props.children}
</div>
</LayoutContext.Provider>
)
}
export function ResizeablePane(props: ResizeablePaneProps) {
const context = useContext(LayoutContext)!
context.register(props.id, { min: props.minSize, max: props.maxSize })
const visible = () => context.visible(props.id)
const width = () => context.percent(props.id)
const next = () => context.next(props.id)
const dragging = () => context.dragging() === props.id
return (
<Show when={visible()}>
<div
class={props.class ? `relative flex h-full flex-col ${props.class}` : "relative flex h-full flex-col"}
classList={props.classList}
style={{
width: `${width()}%`,
flex: `0 0 ${width()}%`,
}}
>
{props.children}
<Show when={next()}>
<div
class="absolute top-0 -right-1 h-full w-1.5 cursor-col-resize z-50 group"
onMouseDown={(event) => context.startDrag(props.id, next(), event)}
>
<div
classList={{
"w-0.5 h-full bg-transparent transition-colors group-hover:bg-border-active": true,
"bg-border-active!": dragging(),
}}
/>
</div>
</Show>
</div>
</Show>
)
}

View File

@@ -1,6 +1,7 @@
import { createEffect, Show, For, createMemo, type JSX, createResource } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import { Icon, IconButton } from "@/ui"
import { Icon } from "@opencode-ai/ui"
import { IconButton } from "@/ui"
import { createStore } from "solid-js/store"
import { entries, flatMap, groupBy, map, pipe } from "remeda"
import { createList } from "solid-list"

View File

@@ -1,108 +0,0 @@
import { Select as KobalteSelect } from "@kobalte/core/select"
import { createMemo } from "solid-js"
import type { ComponentProps } from "solid-js"
import { Icon } from "@/ui/icon"
import { pipe, groupBy, entries, map } from "remeda"
import { Button, type ButtonProps } from "@/ui"
export interface SelectProps<T> {
placeholder?: string
options: T[]
current?: T
value?: (x: T) => string
label?: (x: T) => string
groupBy?: (x: T) => string
onSelect?: (value: T | undefined) => void
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
}
export function Select<T>(props: SelectProps<T> & ButtonProps) {
const grouped = createMemo(() => {
const result = pipe(
props.options,
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
entries(),
map(([k, v]) => ({ category: k, options: v })),
)
return result
})
return (
<KobalteSelect<T, { category: string; options: T[] }>
value={props.current}
options={grouped()}
optionValue={(x) => (props.value ? props.value(x) : (x as string))}
optionTextValue={(x) => (props.label ? props.label(x) : (x as string))}
optionGroupChildren="options"
placeholder={props.placeholder}
sectionComponent={(props) => (
<KobalteSelect.Section class="text-xs uppercase text-text-muted/60 font-light mt-3 first:mt-0 ml-2">
{props.section.rawValue.category}
</KobalteSelect.Section>
)}
itemComponent={(itemProps) => (
<KobalteSelect.Item
classList={{
"relative flex cursor-pointer select-none items-center": true,
"rounded-sm px-2 py-0.5 text-xs outline-none text-text": true,
"transition-colors data-[disabled]:pointer-events-none": true,
"data-[highlighted]:bg-background-element data-[disabled]:opacity-50": true,
[props.class ?? ""]: !!props.class,
}}
{...itemProps}
>
<KobalteSelect.ItemLabel>
{props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
</KobalteSelect.ItemLabel>
<KobalteSelect.ItemIndicator class="ml-auto">
<Icon name="checkmark" size={16} />
</KobalteSelect.ItemIndicator>
</KobalteSelect.Item>
)}
onChange={(v) => {
props.onSelect?.(v ?? undefined)
}}
>
<KobalteSelect.Trigger
as={Button}
size={props.size || "sm"}
variant={props.variant || "secondary"}
classList={{
...(props.classList ?? {}),
[props.class ?? ""]: !!props.class,
}}
>
<KobalteSelect.Value<T> class="truncate">
{(state) => {
const selected = state.selectedOption() ?? props.current
if (!selected) return props.placeholder || ""
if (props.label) return props.label(selected)
return selected as string
}}
</KobalteSelect.Value>
<KobalteSelect.Icon
classList={{
"group size-fit shrink-0 text-text-muted transition-transform duration-100": true,
}}
>
<Icon name="chevron-up" size={16} class="-my-2 group-data-[expanded]:rotate-180" />
<Icon name="chevron-down" size={16} class="-my-2 group-data-[expanded]:rotate-180" />
</KobalteSelect.Icon>
</KobalteSelect.Trigger>
<KobalteSelect.Portal>
<KobalteSelect.Content
classList={{
"min-w-32 overflow-hidden rounded-md border border-border-subtle/40": true,
"bg-background-panel p-1 shadow-md z-50": true,
"data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:zoom-out-95": true,
"data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true,
}}
>
<KobalteSelect.Listbox class="overflow-y-auto max-h-48 whitespace-nowrap overflow-x-hidden" />
</KobalteSelect.Content>
</KobalteSelect.Portal>
</KobalteSelect>
)
}

View File

@@ -1,5 +1,5 @@
import { useSync, useLocal } from "@/context"
import { Tooltip } from "@/ui"
import { Tooltip } from "@opencode-ai/ui"
import { DateTime } from "luxon"
import { VList } from "virtua/solid"

View File

@@ -1,5 +1,6 @@
import { useLocal, useSync } from "@/context"
import { Collapsible, Icon } from "@/ui"
import { Icon } from "@opencode-ai/ui"
import { Collapsible } from "@/ui"
import type { Part, ToolPart } from "@opencode-ai/sdk"
import { DateTime } from "luxon"
import {

View File

@@ -1,48 +0,0 @@
import { For } from "solid-js"
import { Icon, Link, Logo, Tooltip } from "@/ui"
import { useLocation } from "@solidjs/router"
const navigation = [
{ name: "Sessions", href: "/sessions", icon: "dashboard" as const },
{ name: "Commands", href: "/commands", icon: "slash" as const },
{ name: "Agents", href: "/agents", icon: "bolt" as const },
{ name: "Providers", href: "/providers", icon: "cloud" as const },
{ name: "Tools (MCP)", href: "/tools", icon: "hammer" as const },
{ name: "LSP", href: "/lsp", icon: "code" as const },
{ name: "Settings", href: "/settings", icon: "settings" as const },
]
export default function SidebarNav() {
const location = useLocation()
return (
<div class="hidden md:fixed md:inset-y-0 md:left-0 md:z-50 md:block md:w-16 md:overflow-y-auto md:bg-background-panel md:pb-4">
<div class="flex h-16 shrink-0 items-center justify-center">
<Logo variant="mark" size={28} />
</div>
<nav class="mt-5">
<ul role="list" class="flex flex-col items-center space-y-1">
<For each={navigation}>
{(item) => (
<li>
<Tooltip placement="right" value={item.name}>
<Link
href={item.href}
classList={{
"bg-background-element text-text": location.pathname.startsWith(item.href),
"text-text-muted hover:bg-background-element hover:text-text": location.pathname !== item.href,
"flex gap-x-3 rounded-md p-3 text-sm font-semibold": true,
"focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-border-active": true,
}}
>
<Icon name={item.icon} size={20} />
<span class="sr-only">{item.name}</span>
</Link>
</Tooltip>
</li>
)}
</For>
</ul>
</nav>
</div>
)
}