wip: desktop work
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "shiki"
|
||||
import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js"
|
||||
import { useLocal, useShiki } from "@/context"
|
||||
import type { TextSelection } from "@/context/local"
|
||||
import { useLocal, type TextSelection } from "@/context/local"
|
||||
import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils"
|
||||
import { useShiki } from "@/context/shiki"
|
||||
|
||||
type DefinedSelection = Exclude<TextSelection, undefined>
|
||||
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
import { For, Match, Show, Switch, createSignal, splitProps } from "solid-js"
|
||||
import { IconButton, Tabs, Tooltip } from "@opencode-ai/ui"
|
||||
import { FileIcon } from "@/ui"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
DragOverlay,
|
||||
SortableProvider,
|
||||
closestCenter,
|
||||
createSortable,
|
||||
useDragDropContext,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||
import type { LocalFile } from "@/context/local"
|
||||
import { Code } from "@/components/code"
|
||||
import { useLocal } from "@/context"
|
||||
import type { JSX } from "solid-js"
|
||||
|
||||
interface EditorPaneProps {
|
||||
onFileClick: (file: LocalFile) => void
|
||||
}
|
||||
|
||||
export default function EditorPane(props: EditorPaneProps): JSX.Element {
|
||||
const [localProps] = splitProps(props, ["onFileClick"])
|
||||
const local = useLocal()
|
||||
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
|
||||
|
||||
const navigateChange = (dir: 1 | -1) => {
|
||||
const active = local.file.active()
|
||||
if (!active) return
|
||||
const current = local.file.changeIndex(active.path)
|
||||
const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
|
||||
local.file.setChangeIndex(active.path, next)
|
||||
}
|
||||
|
||||
const handleTabChange = (path: string) => {
|
||||
local.file.open(path)
|
||||
}
|
||||
|
||||
const handleTabClose = (file: LocalFile) => {
|
||||
local.file.close(file.path)
|
||||
}
|
||||
|
||||
const handleDragStart = (event: unknown) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
setActiveItem(id)
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
const { draggable, droppable } = event
|
||||
if (draggable && droppable) {
|
||||
const currentFiles = local.file.opened().map((file) => file.path)
|
||||
const fromIndex = currentFiles.indexOf(draggable.id.toString())
|
||||
const toIndex = currentFiles.indexOf(droppable.id.toString())
|
||||
if (fromIndex !== toIndex) {
|
||||
local.file.move(draggable.id.toString(), toIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setActiveItem(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<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 icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} />
|
||||
</Tooltip>
|
||||
<Tooltip value="Next change" placement="bottom">
|
||||
<IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Tooltip value="Raw" placement="bottom">
|
||||
<IconButton
|
||||
icon="file-text"
|
||||
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")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip value="Unified diff" placement="bottom">
|
||||
<IconButton
|
||||
icon="checklist"
|
||||
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")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip value="Split diff" placement="bottom">
|
||||
<IconButton
|
||||
icon="columns"
|
||||
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")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Show>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
function TabVisual(props: { file: LocalFile }): JSX.Element {
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<FileIcon node={props.file} class="" />
|
||||
<span classList={{ "text-xs": true, "text-primary": !!props.file.status?.status, italic: !props.file.pinned }}>
|
||||
{props.file.name}
|
||||
</span>
|
||||
<span class="text-xs opacity-70">
|
||||
<Switch>
|
||||
<Match when={props.file.status?.status === "modified"}>
|
||||
<span class="text-primary">M</span>
|
||||
</Match>
|
||||
<Match when={props.file.status?.status === "added"}>
|
||||
<span class="text-success">A</span>
|
||||
</Match>
|
||||
<Match when={props.file.status?.status === "deleted"}>
|
||||
<span class="text-error">D</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableTab(props: {
|
||||
file: LocalFile
|
||||
onTabClick: (file: LocalFile) => void
|
||||
onTabClose: (file: LocalFile) => void
|
||||
}): JSX.Element {
|
||||
const sortable = createSortable(props.file.path)
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-0": sortable.isActiveDraggable }}>
|
||||
<Tooltip value={props.file.path} placement="bottom">
|
||||
<div class="relative">
|
||||
<Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}>
|
||||
<TabVisual file={props.file} />
|
||||
</Tabs.Trigger>
|
||||
<IconButton
|
||||
icon="close"
|
||||
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
|
||||
variant="ghost"
|
||||
onClick={() => props.onTabClose(props.file)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConstrainDragYAxis(): JSX.Element {
|
||||
const context = useDragDropContext()
|
||||
if (!context) return <></>
|
||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
||||
const transformer: Transformer = {
|
||||
id: "constrain-y-axis",
|
||||
order: 100,
|
||||
callback: (transform) => ({ ...transform, y: 0 }),
|
||||
}
|
||||
onDragStart((event) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
addTransformer("draggables", id, transformer)
|
||||
})
|
||||
onDragEnd((event) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
removeTransformer("draggables", id, transformer.id)
|
||||
})
|
||||
return <></>
|
||||
}
|
||||
|
||||
const getDraggableId = (event: unknown): string | undefined => {
|
||||
if (typeof event !== "object" || event === null) return undefined
|
||||
if (!("draggable" in event)) return undefined
|
||||
const draggable = (event as { draggable?: { id?: unknown } }).draggable
|
||||
if (!draggable) return undefined
|
||||
return typeof draggable.id === "string" ? draggable.id : undefined
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useLocal } from "@/context"
|
||||
import type { LocalFile } from "@/context/local"
|
||||
import { useLocal, type LocalFile } from "@/context/local"
|
||||
import { Tooltip } from "@opencode-ai/ui"
|
||||
import { Collapsible, FileIcon } from "@/ui"
|
||||
import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMarked } from "@/context"
|
||||
import { useMarked } from "@/context/marked"
|
||||
import { createResource } from "solid-js"
|
||||
|
||||
function strip(text: string): string {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useLocal } from "@/context"
|
||||
import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui"
|
||||
import { Button, Icon, IconButton, Select, SelectDialog } from "@opencode-ai/ui"
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { createEffect, on, Component, createMemo, Show, Switch, Match, For } from "solid-js"
|
||||
import { createEffect, on, Component, createMemo, Show, For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FileIcon } from "@/ui"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||
import { TextSelection } from "@/context/local"
|
||||
import { TextSelection, useLocal } from "@/context/local"
|
||||
import { DateTime } from "luxon"
|
||||
|
||||
interface PartBase {
|
||||
@@ -245,7 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative size-full max-w-[640px] _max-h-[320px] flex flex-col gap-3">
|
||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
|
||||
<Show when={store.popoverIsOpen}>
|
||||
<div class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 overflow-y-auto flex flex-col p-2 pb-0 rounded-2xl border border-border-base bg-surface-raised-stronger-non-alpha shadow-md">
|
||||
<For each={flat()}>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useLocal, useSync } from "@/context"
|
||||
import { Icon, Tooltip } from "@opencode-ai/ui"
|
||||
import { Collapsible } from "@/ui"
|
||||
import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk"
|
||||
@@ -22,6 +21,8 @@ import { createElementSize } from "@solid-primitives/resize-observer"
|
||||
import { createScrollPosition } from "@solid-primitives/scroll"
|
||||
import { ProgressCircle } from "./progress-circle"
|
||||
import { pipe, sumBy } from "remeda"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLocal } from "@/context/local"
|
||||
|
||||
function Part(props: ParentProps & ComponentProps<"div">) {
|
||||
const [local, others] = splitProps(props, ["class", "classList", "children"])
|
||||
@@ -394,7 +395,7 @@ export default function SessionTimeline(props: { session: string; class?: string
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<div class="py-1.5 px-6 flex justify-end items-center self-stretch">
|
||||
<div class="flex justify-end items-center self-stretch">
|
||||
<div class="flex items-center gap-6">
|
||||
<Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5">
|
||||
<Show when={context()}>
|
||||
@@ -405,7 +406,7 @@ export default function SessionTimeline(props: { session: string; class?: string
|
||||
<div class="text-14-regular text-text-strong text-right">{cost()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul role="list" class="flex flex-col items-start self-stretch px-6 pt-2 pb-6 gap-1">
|
||||
<ul role="list" class="flex flex-col items-start self-stretch">
|
||||
<For each={messagesWithValidParts()}>
|
||||
{(message) => (
|
||||
<div
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
import { createEventBus } from "@solid-primitives/event-bus"
|
||||
import type { Event as SDKEvent } from "@opencode-ai/sdk"
|
||||
import { useSDK } from "@/context"
|
||||
|
||||
export type Event = SDKEvent // can extend with custom events later
|
||||
|
||||
function init() {
|
||||
const sdk = useSDK()
|
||||
const bus = createEventBus<Event>()
|
||||
sdk.event.subscribe().then(async (events) => {
|
||||
for await (const event of events.stream) {
|
||||
bus.emit(event)
|
||||
}
|
||||
})
|
||||
return bus
|
||||
}
|
||||
|
||||
type EventContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<EventContext>()
|
||||
|
||||
export function EventProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useEvent() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useEvent must be used within a EventProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createContext, Show, useContext, type ParentProps } from "solid-js"
|
||||
|
||||
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
|
||||
name: string
|
||||
init: ((input: Props) => T) | (() => T)
|
||||
}) {
|
||||
const ctx = createContext<T>()
|
||||
|
||||
return {
|
||||
provider: (props: ParentProps<Props>) => {
|
||||
const init = input.init(props)
|
||||
return (
|
||||
// @ts-expect-error
|
||||
<Show when={init.ready === undefined || init.ready === true}>
|
||||
<ctx.Provider value={init}>{props.children}</ctx.Provider>
|
||||
</Show>
|
||||
)
|
||||
},
|
||||
use() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
|
||||
return value
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export { EventProvider, useEvent } from "./event"
|
||||
export { LocalProvider, useLocal } from "./local"
|
||||
export { MarkedProvider, useMarked } from "./marked"
|
||||
export { SDKProvider, useSDK } from "./sdk"
|
||||
export { ShikiProvider, useShiki } from "./shiki"
|
||||
export { SyncProvider, useSync } from "./sync"
|
||||
@@ -1,8 +1,19 @@
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
|
||||
import { uniqueBy } from "remeda"
|
||||
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
|
||||
import { useSDK, useEvent, useSync } from "@/context"
|
||||
import { batch, createEffect, createMemo } from "solid-js"
|
||||
import { pipe, sumBy, uniqueBy } from "remeda"
|
||||
import type {
|
||||
FileContent,
|
||||
FileNode,
|
||||
Model,
|
||||
Provider,
|
||||
File as FileStatus,
|
||||
Part,
|
||||
Message,
|
||||
AssistantMessage,
|
||||
} from "@opencode-ai/sdk"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
|
||||
export type LocalFile = FileNode &
|
||||
Partial<{
|
||||
@@ -28,542 +39,567 @@ export type ModelKey = { providerID: string; modelID: string }
|
||||
export type FileContext = { type: "file"; path: string; selection?: TextSelection }
|
||||
export type ContextItem = FileContext
|
||||
|
||||
function init() {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
|
||||
const agent = (() => {
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
|
||||
const [store, setStore] = createStore<{
|
||||
current: string
|
||||
}>({
|
||||
current: list()[0].name,
|
||||
})
|
||||
return {
|
||||
list,
|
||||
current() {
|
||||
return list().find((x) => x.name === store.current)!
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
setStore("current", name ?? list()[0].name)
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
let next = list().findIndex((x) => x.name === store.current) + direction
|
||||
if (next < 0) next = list().length - 1
|
||||
if (next >= list().length) next = 0
|
||||
const value = list()[next]
|
||||
setStore("current", value.name)
|
||||
if (value.model)
|
||||
model.set({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const model = (() => {
|
||||
const list = createMemo(() =>
|
||||
sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
|
||||
)
|
||||
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
model: Record<string, ModelKey>
|
||||
recent: ModelKey[]
|
||||
}>({
|
||||
model: {},
|
||||
recent: [],
|
||||
})
|
||||
|
||||
const value = localStorage.getItem("model")
|
||||
setStore("recent", JSON.parse(value ?? "[]"))
|
||||
createEffect(() => {
|
||||
localStorage.setItem("model", JSON.stringify(store.recent))
|
||||
})
|
||||
|
||||
const fallback = createMemo(() => {
|
||||
if (store.recent.length) return store.recent[0]
|
||||
const provider = sync.data.provider[0]
|
||||
const model = Object.values(provider.models)[0]
|
||||
return { modelID: model.id, providerID: provider.id }
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
const a = agent.current()
|
||||
return find(store.model[agent.current().name]) ?? find(a.model ?? fallback())
|
||||
})
|
||||
|
||||
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
|
||||
|
||||
return {
|
||||
list,
|
||||
current,
|
||||
recent,
|
||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
setStore("model", agent.current().name, model ?? fallback())
|
||||
if (options?.recent && model) {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
if (uniq.length > 5) uniq.pop()
|
||||
setStore("recent", uniq)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const file = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
node: Record<string, LocalFile>
|
||||
opened: string[]
|
||||
active?: string
|
||||
}>({
|
||||
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
|
||||
opened: [],
|
||||
})
|
||||
|
||||
const active = createMemo(() => {
|
||||
if (!store.active) return undefined
|
||||
return store.node[store.active]
|
||||
})
|
||||
const opened = createMemo(() => store.opened.map((x) => store.node[x]))
|
||||
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
||||
|
||||
// createEffect((prev: FileStatus[]) => {
|
||||
// const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
|
||||
// for (const p of removed) {
|
||||
// setStore(
|
||||
// "node",
|
||||
// p.path,
|
||||
// produce((draft) => {
|
||||
// draft.status = undefined
|
||||
// draft.view = "raw"
|
||||
// }),
|
||||
// )
|
||||
// load(p.path)
|
||||
// }
|
||||
// for (const p of sync.data.changes) {
|
||||
// if (store.node[p.path] === undefined) {
|
||||
// fetch(p.path).then(() => {
|
||||
// if (store.node[p.path] === undefined) return
|
||||
// setStore("node", p.path, "status", p)
|
||||
// })
|
||||
// } else {
|
||||
// setStore("node", p.path, "status", p)
|
||||
// }
|
||||
// }
|
||||
// return sync.data.changes
|
||||
// }, sync.data.changes)
|
||||
|
||||
const changed = (path: string) => {
|
||||
const node = store.node[path]
|
||||
if (node?.status) return true
|
||||
const set = changeset()
|
||||
if (set.has(path)) return true
|
||||
for (const p of set) {
|
||||
if (p.startsWith(path ? path + "/" : "")) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const resetNode = (path: string) => {
|
||||
setStore("node", path, {
|
||||
loaded: undefined,
|
||||
pinned: undefined,
|
||||
content: undefined,
|
||||
selection: undefined,
|
||||
scrollTop: undefined,
|
||||
folded: undefined,
|
||||
view: undefined,
|
||||
selectedChange: undefined,
|
||||
const agent = (() => {
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
|
||||
const [store, setStore] = createStore<{
|
||||
current: string
|
||||
}>({
|
||||
current: list()[0].name,
|
||||
})
|
||||
}
|
||||
|
||||
const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
|
||||
|
||||
const load = async (path: string) => {
|
||||
const relativePath = relative(path)
|
||||
sdk.file.read({ query: { path: relativePath } }).then((x) => {
|
||||
setStore(
|
||||
"node",
|
||||
relativePath,
|
||||
produce((draft) => {
|
||||
draft.loaded = true
|
||||
draft.content = x.data
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const fetch = async (path: string) => {
|
||||
const relativePath = relative(path)
|
||||
const parent = relativePath.split("/").slice(0, -1).join("/")
|
||||
if (parent) {
|
||||
await list(parent)
|
||||
}
|
||||
}
|
||||
|
||||
const init = async (path: string) => {
|
||||
const relativePath = relative(path)
|
||||
if (!store.node[relativePath]) await fetch(path)
|
||||
if (store.node[relativePath].loaded) return
|
||||
return load(relativePath)
|
||||
}
|
||||
|
||||
const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
|
||||
const relativePath = relative(path)
|
||||
if (!store.node[relativePath]) await fetch(path)
|
||||
setStore("opened", (x) => {
|
||||
if (x.includes(relativePath)) return x
|
||||
return [
|
||||
...opened()
|
||||
.filter((x) => x.pinned)
|
||||
.map((x) => x.path),
|
||||
relativePath,
|
||||
]
|
||||
})
|
||||
setStore("active", relativePath)
|
||||
context.addActive()
|
||||
if (options?.pinned) setStore("node", path, "pinned", true)
|
||||
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
||||
if (store.node[relativePath].loaded) return
|
||||
return load(relativePath)
|
||||
}
|
||||
|
||||
const list = async (path: string) => {
|
||||
return sdk.file.list({ query: { path: path + "/" } }).then((x) => {
|
||||
setStore(
|
||||
"node",
|
||||
produce((draft) => {
|
||||
x.data!.forEach((node) => {
|
||||
if (node.path in draft) return
|
||||
draft[node.path] = node
|
||||
return {
|
||||
list,
|
||||
current() {
|
||||
return list().find((x) => x.name === store.current)!
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
setStore("current", name ?? list()[0].name)
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
let next = list().findIndex((x) => x.name === store.current) + direction
|
||||
if (next < 0) next = list().length - 1
|
||||
if (next >= list().length) next = 0
|
||||
const value = list()[next]
|
||||
setStore("current", value.name)
|
||||
if (value.model)
|
||||
model.set({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const model = (() => {
|
||||
const list = createMemo(() =>
|
||||
sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
|
||||
)
|
||||
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
model: Record<string, ModelKey>
|
||||
recent: ModelKey[]
|
||||
}>({
|
||||
model: {},
|
||||
recent: [],
|
||||
})
|
||||
}
|
||||
|
||||
const search = (query: string) => sdk.find.files({ query: { query } }).then((x) => x.data!)
|
||||
const value = localStorage.getItem("model")
|
||||
setStore("recent", JSON.parse(value ?? "[]"))
|
||||
createEffect(() => {
|
||||
localStorage.setItem("model", JSON.stringify(store.recent))
|
||||
})
|
||||
|
||||
const bus = useEvent()
|
||||
bus.listen((event) => {
|
||||
switch (event.type) {
|
||||
case "message.part.updated":
|
||||
const part = event.properties.part
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
switch (part.tool) {
|
||||
case "read":
|
||||
break
|
||||
case "edit":
|
||||
// load(part.state.input["filePath"] as string)
|
||||
break
|
||||
default:
|
||||
break
|
||||
const fallback = createMemo(() => {
|
||||
if (store.recent.length) return store.recent[0]
|
||||
const provider = sync.data.provider[0]
|
||||
const model = Object.values(provider.models)[0]
|
||||
return { modelID: model.id, providerID: provider.id }
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
const a = agent.current()
|
||||
return find(store.model[agent.current().name]) ?? find(a.model ?? fallback())
|
||||
})
|
||||
|
||||
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
|
||||
|
||||
return {
|
||||
list,
|
||||
current,
|
||||
recent,
|
||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
setStore("model", agent.current().name, model ?? fallback())
|
||||
if (options?.recent && model) {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
if (uniq.length > 5) uniq.pop()
|
||||
setStore("recent", uniq)
|
||||
}
|
||||
}
|
||||
break
|
||||
case "file.watcher.updated":
|
||||
setTimeout(sync.load.changes, 1000)
|
||||
const relativePath = relative(event.properties.file)
|
||||
if (relativePath.startsWith(".git/")) return
|
||||
load(relativePath)
|
||||
break
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
||||
return {
|
||||
active,
|
||||
opened,
|
||||
node: (path: string) => store.node[path],
|
||||
update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
|
||||
open,
|
||||
load,
|
||||
init,
|
||||
close(path: string) {
|
||||
setStore("opened", (opened) => opened.filter((x) => x !== path))
|
||||
if (store.active === path) {
|
||||
const index = store.opened.findIndex((f) => f === path)
|
||||
const previous = store.opened[Math.max(0, index - 1)]
|
||||
setStore("active", previous)
|
||||
}
|
||||
resetNode(path)
|
||||
},
|
||||
expand(path: string) {
|
||||
setStore("node", path, "expanded", true)
|
||||
if (store.node[path].loaded) return
|
||||
setStore("node", path, "loaded", true)
|
||||
list(path)
|
||||
},
|
||||
collapse(path: string) {
|
||||
setStore("node", path, "expanded", false)
|
||||
},
|
||||
select(path: string, selection: TextSelection | undefined) {
|
||||
setStore("node", path, "selection", selection)
|
||||
},
|
||||
scroll(path: string, scrollTop: number) {
|
||||
setStore("node", path, "scrollTop", scrollTop)
|
||||
},
|
||||
move(path: string, to: number) {
|
||||
const index = store.opened.findIndex((f) => f === path)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"opened",
|
||||
produce((opened) => {
|
||||
opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||
}),
|
||||
)
|
||||
setStore("node", path, "pinned", true)
|
||||
},
|
||||
view(path: string): View {
|
||||
const n = store.node[path]
|
||||
return n && n.view ? n.view : "raw"
|
||||
},
|
||||
setView(path: string, view: View) {
|
||||
setStore("node", path, "view", view)
|
||||
},
|
||||
unfold(path: string, key: string) {
|
||||
setStore("node", path, "folded", (xs) => {
|
||||
const a = xs ?? []
|
||||
if (a.includes(key)) return a
|
||||
return [...a, key]
|
||||
})
|
||||
},
|
||||
fold(path: string, key: string) {
|
||||
setStore("node", path, "folded", (xs) => (xs ?? []).filter((k) => k !== key))
|
||||
},
|
||||
folded(path: string) {
|
||||
const n = store.node[path]
|
||||
return n && n.folded ? n.folded : []
|
||||
},
|
||||
changeIndex(path: string) {
|
||||
return store.node[path]?.selectedChange
|
||||
},
|
||||
setChangeIndex(path: string, index: number | undefined) {
|
||||
setStore("node", path, "selectedChange", index)
|
||||
},
|
||||
changes,
|
||||
changed,
|
||||
children(path: string) {
|
||||
return Object.values(store.node).filter(
|
||||
(x) =>
|
||||
x.path.startsWith(path) &&
|
||||
x.path !== path &&
|
||||
!x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
|
||||
)
|
||||
},
|
||||
search,
|
||||
relative,
|
||||
}
|
||||
})()
|
||||
|
||||
const layout = (() => {
|
||||
type PaneState = { size: number; visible: boolean }
|
||||
type LayoutState = { panes: Record<string, PaneState>; order: string[] }
|
||||
type PaneDefault = number | { size: number; visible?: boolean }
|
||||
|
||||
const [store, setStore] = createStore<Record<string, LayoutState>>({})
|
||||
|
||||
const raw = localStorage.getItem("layout")
|
||||
if (raw) {
|
||||
const data = JSON.parse(raw)
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
const first = Object.values(data)[0] as LayoutState
|
||||
if (first && typeof first === "object" && "panes" in first) {
|
||||
setStore(() => data as Record<string, LayoutState>)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
localStorage.setItem("layout", JSON.stringify(store))
|
||||
})
|
||||
|
||||
const normalize = (value: PaneDefault): PaneState => {
|
||||
if (typeof value === "number") return { size: value, visible: true }
|
||||
return { size: value.size, visible: value.visible ?? true }
|
||||
}
|
||||
|
||||
const ensure = (id: string, defaults: Record<string, PaneDefault>) => {
|
||||
const entries = Object.entries(defaults)
|
||||
if (!entries.length) return
|
||||
setStore(id, (current) => {
|
||||
if (current) return current
|
||||
return {
|
||||
panes: Object.fromEntries(entries.map(([pane, config]) => [pane, normalize(config)])),
|
||||
order: entries.map(([pane]) => pane),
|
||||
}
|
||||
const file = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
node: Record<string, LocalFile>
|
||||
opened: string[]
|
||||
active?: string
|
||||
}>({
|
||||
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
|
||||
opened: [],
|
||||
})
|
||||
for (const [pane, config] of entries) {
|
||||
if (!store[id]?.panes[pane]) {
|
||||
setStore(id, "panes", pane, () => normalize(config))
|
||||
|
||||
const active = createMemo(() => {
|
||||
if (!store.active) return undefined
|
||||
return store.node[store.active]
|
||||
})
|
||||
const opened = createMemo(() => store.opened.map((x) => store.node[x]))
|
||||
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
||||
|
||||
// createEffect((prev: FileStatus[]) => {
|
||||
// const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
|
||||
// for (const p of removed) {
|
||||
// setStore(
|
||||
// "node",
|
||||
// p.path,
|
||||
// produce((draft) => {
|
||||
// draft.status = undefined
|
||||
// draft.view = "raw"
|
||||
// }),
|
||||
// )
|
||||
// load(p.path)
|
||||
// }
|
||||
// for (const p of sync.data.changes) {
|
||||
// if (store.node[p.path] === undefined) {
|
||||
// fetch(p.path).then(() => {
|
||||
// if (store.node[p.path] === undefined) return
|
||||
// setStore("node", p.path, "status", p)
|
||||
// })
|
||||
// } else {
|
||||
// setStore("node", p.path, "status", p)
|
||||
// }
|
||||
// }
|
||||
// return sync.data.changes
|
||||
// }, sync.data.changes)
|
||||
|
||||
const changed = (path: string) => {
|
||||
const node = store.node[path]
|
||||
if (node?.status) return true
|
||||
const set = changeset()
|
||||
if (set.has(path)) return true
|
||||
for (const p of set) {
|
||||
if (p.startsWith(path ? path + "/" : "")) return true
|
||||
}
|
||||
if (!(store[id]?.order ?? []).includes(pane)) {
|
||||
setStore(id, "order", (list) => [...list, pane])
|
||||
return false
|
||||
}
|
||||
|
||||
const resetNode = (path: string) => {
|
||||
setStore("node", path, {
|
||||
loaded: undefined,
|
||||
pinned: undefined,
|
||||
content: undefined,
|
||||
selection: undefined,
|
||||
scrollTop: undefined,
|
||||
folded: undefined,
|
||||
view: undefined,
|
||||
selectedChange: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
|
||||
|
||||
const load = async (path: string) => {
|
||||
const relativePath = relative(path)
|
||||
sdk.client.file.read({ query: { path: relativePath } }).then((x) => {
|
||||
setStore(
|
||||
"node",
|
||||
relativePath,
|
||||
produce((draft) => {
|
||||
draft.loaded = true
|
||||
draft.content = x.data
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const fetch = async (path: string) => {
|
||||
const relativePath = relative(path)
|
||||
const parent = relativePath.split("/").slice(0, -1).join("/")
|
||||
if (parent) {
|
||||
await list(parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ensurePane = (id: string, pane: string, fallback?: PaneDefault) => {
|
||||
if (!store[id]) {
|
||||
const value = normalize(fallback ?? { size: 0, visible: true })
|
||||
setStore(id, () => ({
|
||||
panes: { [pane]: value },
|
||||
order: [pane],
|
||||
}))
|
||||
return
|
||||
const init = async (path: string) => {
|
||||
const relativePath = relative(path)
|
||||
if (!store.node[relativePath]) await fetch(path)
|
||||
if (store.node[relativePath].loaded) return
|
||||
return load(relativePath)
|
||||
}
|
||||
if (!store[id].panes[pane]) {
|
||||
const value = normalize(fallback ?? { size: 0, visible: true })
|
||||
setStore(id, "panes", pane, () => value)
|
||||
|
||||
const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
|
||||
const relativePath = relative(path)
|
||||
if (!store.node[relativePath]) await fetch(path)
|
||||
setStore("opened", (x) => {
|
||||
if (x.includes(relativePath)) return x
|
||||
return [
|
||||
...opened()
|
||||
.filter((x) => x.pinned)
|
||||
.map((x) => x.path),
|
||||
relativePath,
|
||||
]
|
||||
})
|
||||
setStore("active", relativePath)
|
||||
context.addActive()
|
||||
if (options?.pinned) setStore("node", path, "pinned", true)
|
||||
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
|
||||
if (store.node[relativePath].loaded) return
|
||||
return load(relativePath)
|
||||
}
|
||||
if (!store[id].order.includes(pane)) {
|
||||
setStore(id, "order", (list) => [...list, pane])
|
||||
|
||||
const list = async (path: string) => {
|
||||
return sdk.client.file.list({ query: { path: path + "/" } }).then((x) => {
|
||||
setStore(
|
||||
"node",
|
||||
produce((draft) => {
|
||||
x.data!.forEach((node) => {
|
||||
if (node.path in draft) return
|
||||
draft[node.path] = node
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const size = (id: string, pane: string) => store[id]?.panes[pane]?.size ?? 0
|
||||
const visible = (id: string, pane: string) => store[id]?.panes[pane]?.visible ?? false
|
||||
const search = (query: string) => sdk.client.find.files({ query: { query } }).then((x) => x.data!)
|
||||
|
||||
const setSize = (id: string, pane: string, value: number) => {
|
||||
if (!store[id]?.panes[pane]) return
|
||||
const next = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
|
||||
setStore(id, "panes", pane, "size", next)
|
||||
}
|
||||
|
||||
const setVisible = (id: string, pane: string, value: boolean) => {
|
||||
if (!store[id]?.panes[pane]) return
|
||||
setStore(id, "panes", pane, "visible", value)
|
||||
}
|
||||
|
||||
const toggle = (id: string, pane: string) => {
|
||||
setVisible(id, pane, !visible(id, pane))
|
||||
}
|
||||
|
||||
const show = (id: string, pane: string) => setVisible(id, pane, true)
|
||||
const hide = (id: string, pane: string) => setVisible(id, pane, false)
|
||||
const order = (id: string) => store[id]?.order ?? []
|
||||
|
||||
return {
|
||||
ensure,
|
||||
ensurePane,
|
||||
size,
|
||||
visible,
|
||||
setSize,
|
||||
setVisible,
|
||||
toggle,
|
||||
show,
|
||||
hide,
|
||||
order,
|
||||
}
|
||||
})()
|
||||
|
||||
const session = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
active?: string
|
||||
}>({})
|
||||
|
||||
const active = createMemo(() => {
|
||||
if (!store.active) return undefined
|
||||
return sync.session.get(store.active)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.active) return
|
||||
sync.session.sync(store.active)
|
||||
})
|
||||
|
||||
return {
|
||||
active,
|
||||
setActive(sessionId: string | undefined) {
|
||||
setStore("active", sessionId)
|
||||
},
|
||||
clearActive() {
|
||||
setStore("active", undefined)
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const context = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
activeTab: boolean
|
||||
files: string[]
|
||||
activeFile?: string
|
||||
items: (ContextItem & { key: string })[]
|
||||
}>({
|
||||
activeTab: true,
|
||||
files: [],
|
||||
items: [],
|
||||
})
|
||||
const files = createMemo(() => store.files.map((x) => file.node(x)))
|
||||
const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined))
|
||||
|
||||
return {
|
||||
all() {
|
||||
return store.items
|
||||
},
|
||||
active() {
|
||||
return store.activeTab ? file.active() : undefined
|
||||
},
|
||||
addActive() {
|
||||
setStore("activeTab", true)
|
||||
},
|
||||
removeActive() {
|
||||
setStore("activeTab", false)
|
||||
},
|
||||
add(item: ContextItem) {
|
||||
let key = item.type
|
||||
switch (item.type) {
|
||||
case "file":
|
||||
key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
|
||||
sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
switch (event.type) {
|
||||
case "message.part.updated":
|
||||
const part = event.properties.part
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
switch (part.tool) {
|
||||
case "read":
|
||||
break
|
||||
case "edit":
|
||||
// load(part.state.input["filePath"] as string)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
case "file.watcher.updated":
|
||||
setTimeout(sync.load.changes, 1000)
|
||||
const relativePath = relative(event.properties.file)
|
||||
if (relativePath.startsWith(".git/")) return
|
||||
load(relativePath)
|
||||
break
|
||||
}
|
||||
if (store.items.find((x) => x.key === key)) return
|
||||
setStore("items", (x) => [...x, { key, ...item }])
|
||||
},
|
||||
remove(key: string) {
|
||||
setStore("items", (x) => x.filter((x) => x.key !== key))
|
||||
},
|
||||
files,
|
||||
openFile(path: string) {
|
||||
file.init(path).then(() => {
|
||||
setStore("files", (x) => [...x, path])
|
||||
})
|
||||
|
||||
return {
|
||||
active,
|
||||
opened,
|
||||
node: (path: string) => store.node[path],
|
||||
update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
|
||||
open,
|
||||
load,
|
||||
init,
|
||||
close(path: string) {
|
||||
setStore("opened", (opened) => opened.filter((x) => x !== path))
|
||||
if (store.active === path) {
|
||||
const index = store.opened.findIndex((f) => f === path)
|
||||
const previous = store.opened[Math.max(0, index - 1)]
|
||||
setStore("active", previous)
|
||||
}
|
||||
resetNode(path)
|
||||
},
|
||||
expand(path: string) {
|
||||
setStore("node", path, "expanded", true)
|
||||
if (store.node[path].loaded) return
|
||||
setStore("node", path, "loaded", true)
|
||||
list(path)
|
||||
},
|
||||
collapse(path: string) {
|
||||
setStore("node", path, "expanded", false)
|
||||
},
|
||||
select(path: string, selection: TextSelection | undefined) {
|
||||
setStore("node", path, "selection", selection)
|
||||
},
|
||||
scroll(path: string, scrollTop: number) {
|
||||
setStore("node", path, "scrollTop", scrollTop)
|
||||
},
|
||||
move(path: string, to: number) {
|
||||
const index = store.opened.findIndex((f) => f === path)
|
||||
if (index === -1) return
|
||||
setStore(
|
||||
"opened",
|
||||
produce((opened) => {
|
||||
opened.splice(to, 0, opened.splice(index, 1)[0])
|
||||
}),
|
||||
)
|
||||
setStore("node", path, "pinned", true)
|
||||
},
|
||||
view(path: string): View {
|
||||
const n = store.node[path]
|
||||
return n && n.view ? n.view : "raw"
|
||||
},
|
||||
setView(path: string, view: View) {
|
||||
setStore("node", path, "view", view)
|
||||
},
|
||||
unfold(path: string, key: string) {
|
||||
setStore("node", path, "folded", (xs) => {
|
||||
const a = xs ?? []
|
||||
if (a.includes(key)) return a
|
||||
return [...a, key]
|
||||
})
|
||||
},
|
||||
fold(path: string, key: string) {
|
||||
setStore("node", path, "folded", (xs) => (xs ?? []).filter((k) => k !== key))
|
||||
},
|
||||
folded(path: string) {
|
||||
const n = store.node[path]
|
||||
return n && n.folded ? n.folded : []
|
||||
},
|
||||
changeIndex(path: string) {
|
||||
return store.node[path]?.selectedChange
|
||||
},
|
||||
setChangeIndex(path: string, index: number | undefined) {
|
||||
setStore("node", path, "selectedChange", index)
|
||||
},
|
||||
changes,
|
||||
changed,
|
||||
children(path: string) {
|
||||
return Object.values(store.node).filter(
|
||||
(x) =>
|
||||
x.path.startsWith(path) &&
|
||||
x.path !== path &&
|
||||
!x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
|
||||
)
|
||||
},
|
||||
search,
|
||||
relative,
|
||||
}
|
||||
})()
|
||||
|
||||
const session = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
active?: string
|
||||
activeMessage?: string
|
||||
}>({})
|
||||
|
||||
const active = createMemo(() => {
|
||||
if (!store.active) return undefined
|
||||
return sync.session.get(store.active)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.active) return
|
||||
sync.session.sync(store.active)
|
||||
})
|
||||
|
||||
const valid = (part: Part) => {
|
||||
if (!part) return false
|
||||
switch (part.type) {
|
||||
case "step-start":
|
||||
case "step-finish":
|
||||
case "file":
|
||||
case "patch":
|
||||
return false
|
||||
case "text":
|
||||
return !part.synthetic && part.text.trim()
|
||||
case "reasoning":
|
||||
return part.text.trim()
|
||||
case "tool":
|
||||
switch (part.tool) {
|
||||
case "todoread":
|
||||
case "todowrite":
|
||||
case "list":
|
||||
case "grep":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const hasValidParts = (message: Message) => {
|
||||
return sync.data.part[message.id]?.filter(valid).length > 0
|
||||
}
|
||||
// const hasTextPart = (message: Message) => {
|
||||
// return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
|
||||
// }
|
||||
|
||||
const messages = createMemo(() => (store.active ? (sync.data.message[store.active] ?? []) : []))
|
||||
const messagesWithValidParts = createMemo(() => messages().filter(hasValidParts) ?? [])
|
||||
const userMessages = createMemo(() =>
|
||||
messages()
|
||||
.filter((m) => m.role === "user")
|
||||
.sort((a, b) => b.id.localeCompare(a.id)),
|
||||
)
|
||||
|
||||
const working = createMemo(() => {
|
||||
const last = messages()[messages().length - 1]
|
||||
if (!last) return false
|
||||
if (last.role === "user") return true
|
||||
return !last.time.completed
|
||||
})
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const total = pipe(
|
||||
messages(),
|
||||
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
|
||||
)
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
})
|
||||
|
||||
const last = createMemo(() => {
|
||||
return messages().findLast((x) => x.role === "assistant") as AssistantMessage
|
||||
})
|
||||
|
||||
const lastUserMessage = createMemo(() => {
|
||||
return userMessages()?.at(0)
|
||||
})
|
||||
|
||||
const activeMessage = createMemo(() => {
|
||||
if (!store.active || !store.activeMessage) return lastUserMessage()
|
||||
return sync.data.message[store.active]?.find((m) => m.id === store.activeMessage)
|
||||
})
|
||||
|
||||
const activeAssistantMessages = createMemo(() => {
|
||||
if (!store.active || !activeMessage()) return []
|
||||
return sync.data.message[store.active]?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == activeMessage()?.id,
|
||||
)
|
||||
})
|
||||
|
||||
const activeAssistantMessagesWithText = createMemo(() => {
|
||||
if (!store.active || !activeAssistantMessages()) return []
|
||||
return activeAssistantMessages()?.filter((m) => sync.data.part[m.id].find((p) => p.type === "text"))
|
||||
})
|
||||
|
||||
const model = createMemo(() => {
|
||||
if (!last()) return
|
||||
const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
|
||||
return model
|
||||
})
|
||||
|
||||
const tokens = createMemo(() => {
|
||||
if (!last()) return
|
||||
const tokens = last().tokens
|
||||
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
compactDisplay: "short",
|
||||
}).format(total)
|
||||
})
|
||||
|
||||
const context = createMemo(() => {
|
||||
if (!last()) return
|
||||
if (!model()?.limit.context) return 0
|
||||
const tokens = last().tokens
|
||||
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
return Math.round((total / model()!.limit.context) * 100)
|
||||
})
|
||||
|
||||
const getMessageText = (message: Message | Message[] | undefined): string => {
|
||||
if (!message) return ""
|
||||
if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ")
|
||||
return sync.data.part[message.id]
|
||||
?.filter((p) => p.type === "text")
|
||||
?.filter((p) => !p.synthetic)
|
||||
.map((p) => p.text)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
return {
|
||||
active,
|
||||
activeMessage,
|
||||
activeAssistantMessages,
|
||||
activeAssistantMessagesWithText,
|
||||
lastUserMessage,
|
||||
cost,
|
||||
last,
|
||||
model,
|
||||
tokens,
|
||||
context,
|
||||
messages,
|
||||
messagesWithValidParts,
|
||||
userMessages,
|
||||
working,
|
||||
getMessageText,
|
||||
setActive(sessionId: string | undefined) {
|
||||
setStore("active", sessionId)
|
||||
setStore("activeMessage", undefined)
|
||||
},
|
||||
clearActive() {
|
||||
setStore("active", undefined)
|
||||
setStore("activeMessage", undefined)
|
||||
},
|
||||
setActiveMessage(messageId: string | undefined) {
|
||||
setStore("activeMessage", messageId)
|
||||
},
|
||||
clearActiveMessage() {
|
||||
setStore("activeMessage", undefined)
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const context = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
activeTab: boolean
|
||||
files: string[]
|
||||
activeFile?: string
|
||||
items: (ContextItem & { key: string })[]
|
||||
}>({
|
||||
activeTab: true,
|
||||
files: [],
|
||||
items: [],
|
||||
})
|
||||
const files = createMemo(() => store.files.map((x) => file.node(x)))
|
||||
const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined))
|
||||
|
||||
return {
|
||||
all() {
|
||||
return store.items
|
||||
},
|
||||
active() {
|
||||
return store.activeTab ? file.active() : undefined
|
||||
},
|
||||
addActive() {
|
||||
setStore("activeTab", true)
|
||||
},
|
||||
removeActive() {
|
||||
setStore("activeTab", false)
|
||||
},
|
||||
add(item: ContextItem) {
|
||||
let key = item.type
|
||||
switch (item.type) {
|
||||
case "file":
|
||||
key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
|
||||
break
|
||||
}
|
||||
if (store.items.find((x) => x.key === key)) return
|
||||
setStore("items", (x) => [...x, { key, ...item }])
|
||||
},
|
||||
remove(key: string) {
|
||||
setStore("items", (x) => x.filter((x) => x.key !== key))
|
||||
},
|
||||
files,
|
||||
openFile(path: string) {
|
||||
file.init(path).then(() => {
|
||||
setStore("files", (x) => [...x, path])
|
||||
setStore("activeFile", path)
|
||||
})
|
||||
},
|
||||
activeFile,
|
||||
setActiveFile(path: string | undefined) {
|
||||
setStore("activeFile", path)
|
||||
})
|
||||
},
|
||||
activeFile,
|
||||
setActiveFile(path: string | undefined) {
|
||||
setStore("activeFile", path)
|
||||
},
|
||||
},
|
||||
}
|
||||
})()
|
||||
|
||||
const result = {
|
||||
model,
|
||||
agent,
|
||||
file,
|
||||
session,
|
||||
context,
|
||||
}
|
||||
})()
|
||||
|
||||
const result = {
|
||||
model,
|
||||
agent,
|
||||
file,
|
||||
layout,
|
||||
session,
|
||||
context,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type LocalContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<LocalContext>()
|
||||
|
||||
export function LocalProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useLocal() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useLocal must be used within a LocalProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,43 +1,30 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
import { useShiki } from "@/context"
|
||||
import { marked } from "marked"
|
||||
import markedShiki from "marked-shiki"
|
||||
import { bundledLanguages, type BundledLanguage } from "shiki"
|
||||
|
||||
function init(highlighter: ReturnType<typeof useShiki>) {
|
||||
return marked.use(
|
||||
markedShiki({
|
||||
async highlight(code, lang) {
|
||||
if (!(lang in bundledLanguages)) {
|
||||
lang = "text"
|
||||
}
|
||||
if (!highlighter.getLoadedLanguages().includes(lang)) {
|
||||
await highlighter.loadLanguage(lang as BundledLanguage)
|
||||
}
|
||||
return highlighter.codeToHtml(code, {
|
||||
lang: lang || "text",
|
||||
theme: "opencode",
|
||||
tabindex: false,
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useShiki } from "./shiki"
|
||||
|
||||
type MarkedContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<MarkedContext>()
|
||||
|
||||
export function MarkedProvider(props: ParentProps) {
|
||||
const highlighter = useShiki()
|
||||
const value = init(highlighter)
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useMarked() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useMarked must be used within a MarkedProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({
|
||||
name: "Marked",
|
||||
init: () => {
|
||||
const highlighter = useShiki()
|
||||
return marked.use(
|
||||
markedShiki({
|
||||
async highlight(code, lang) {
|
||||
if (!(lang in bundledLanguages)) {
|
||||
lang = "text"
|
||||
}
|
||||
if (!highlighter.getLoadedLanguages().includes(lang)) {
|
||||
await highlighter.loadLanguage(lang as BundledLanguage)
|
||||
}
|
||||
return highlighter.codeToHtml(code, {
|
||||
lang: lang || "text",
|
||||
theme: "opencode",
|
||||
tabindex: false,
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/client"
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { onCleanup } 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"
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
init: (props: { url: string }) => {
|
||||
const abort = new AbortController()
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: props.url,
|
||||
signal: abort.signal,
|
||||
fetch: (req) => {
|
||||
// @ts-ignore
|
||||
req.timeout = false
|
||||
return fetch(req)
|
||||
},
|
||||
})
|
||||
|
||||
function init() {
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: `http://${host}:${port}`,
|
||||
})
|
||||
return client
|
||||
}
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
}>()
|
||||
|
||||
type SDKContext = ReturnType<typeof init>
|
||||
sdk.event.subscribe().then(async (events) => {
|
||||
for await (const event of events.stream) {
|
||||
console.log("event", event.type)
|
||||
emitter.emit(event.type, event)
|
||||
}
|
||||
})
|
||||
|
||||
const ctx = createContext<SDKContext>()
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
export function SDKProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useSDK() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useSDK must be used within a SDKProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
return { client: sdk, event: emitter }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { createHighlighter, type ThemeInput } from "shiki"
|
||||
import { createContext, useContext, type ParentProps } from "solid-js"
|
||||
|
||||
const theme: ThemeInput = {
|
||||
colors: {
|
||||
@@ -559,24 +559,14 @@ const theme: ThemeInput = {
|
||||
],
|
||||
type: "dark",
|
||||
}
|
||||
|
||||
const highlighter = await createHighlighter({
|
||||
themes: [theme],
|
||||
langs: [],
|
||||
})
|
||||
|
||||
type ShikiContext = typeof highlighter
|
||||
|
||||
const ctx = createContext<ShikiContext>()
|
||||
|
||||
export function ShikiProvider(props: ParentProps) {
|
||||
return <ctx.Provider value={highlighter}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useShiki() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useShiki must be used within a ShikiProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
export const { use: useShiki, provider: ShikiProvider } = createSimpleContext({
|
||||
name: "Shiki",
|
||||
init: () => {
|
||||
return highlighter
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,177 +1,162 @@
|
||||
import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode, Project } from "@opencode-ai/sdk"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createContext, createMemo, Show, useContext, type ParentProps } from "solid-js"
|
||||
import { useSDK, useEvent } from "@/context"
|
||||
import { createMemo } from "solid-js"
|
||||
import { Binary } from "@/utils/binary"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
function init() {
|
||||
const [store, setStore] = createStore<{
|
||||
ready: boolean
|
||||
provider: Provider[]
|
||||
agent: Agent[]
|
||||
project: Project
|
||||
config: Config
|
||||
path: Path
|
||||
session: Session[]
|
||||
message: {
|
||||
[sessionID: string]: Message[]
|
||||
}
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
node: FileNode[]
|
||||
changes: File[]
|
||||
}>({
|
||||
project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "", directory: "" },
|
||||
ready: false,
|
||||
agent: [],
|
||||
provider: [],
|
||||
session: [],
|
||||
message: {},
|
||||
part: {},
|
||||
node: [],
|
||||
changes: [],
|
||||
})
|
||||
|
||||
const bus = useEvent()
|
||||
bus.listen((event) => {
|
||||
switch (event.type) {
|
||||
case "session.updated": {
|
||||
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
setStore("session", result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
init: () => {
|
||||
const [store, setStore] = createStore<{
|
||||
ready: boolean
|
||||
provider: Provider[]
|
||||
agent: Agent[]
|
||||
project: Project
|
||||
config: Config
|
||||
path: Path
|
||||
session: Session[]
|
||||
message: {
|
||||
[sessionID: string]: Message[]
|
||||
}
|
||||
case "message.updated": {
|
||||
const messages = store.message[event.properties.info.sessionID]
|
||||
if (!messages) {
|
||||
setStore("message", event.properties.info.sessionID, [event.properties.info])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
|
||||
if (result.found) {
|
||||
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"message",
|
||||
event.properties.info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const parts = store.part[event.properties.part.messageID]
|
||||
if (!parts) {
|
||||
setStore("part", event.properties.part.messageID, [event.properties.part])
|
||||
node: FileNode[]
|
||||
changes: File[]
|
||||
}>({
|
||||
project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "", directory: "" },
|
||||
ready: false,
|
||||
agent: [],
|
||||
provider: [],
|
||||
session: [],
|
||||
message: {},
|
||||
part: {},
|
||||
node: [],
|
||||
changes: [],
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
switch (event.type) {
|
||||
case "session.updated": {
|
||||
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
setStore("session", result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
|
||||
case "message.updated": {
|
||||
const messages = store.message[event.properties.info.sessionID]
|
||||
if (!messages) {
|
||||
setStore("message", event.properties.info.sessionID, [event.properties.info])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
|
||||
if (result.found) {
|
||||
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"message",
|
||||
event.properties.info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const parts = store.part[event.properties.part.messageID]
|
||||
if (!parts) {
|
||||
setStore("part", event.properties.part.messageID, [event.properties.part])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"part",
|
||||
event.properties.part.messageID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.part)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"part",
|
||||
event.properties.part.messageID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.part)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
|
||||
const load = {
|
||||
project: () => sdk.project.current().then((x) => setStore("project", x.data!)),
|
||||
provider: () => sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
|
||||
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
|
||||
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||
session: () =>
|
||||
sdk.session.list().then((x) =>
|
||||
setStore(
|
||||
"session",
|
||||
(x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
|
||||
const load = {
|
||||
project: () => sdk.client.project.current().then((x) => setStore("project", x.data!)),
|
||||
provider: () => sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)),
|
||||
path: () => sdk.client.path.get().then((x) => setStore("path", x.data!)),
|
||||
agent: () => sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||
session: () =>
|
||||
sdk.client.session.list().then((x) =>
|
||||
setStore(
|
||||
"session",
|
||||
(x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
|
||||
),
|
||||
),
|
||||
),
|
||||
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||
changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
|
||||
node: () => sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
|
||||
}
|
||||
config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)),
|
||||
changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)),
|
||||
node: () => sdk.client.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
|
||||
}
|
||||
|
||||
Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
|
||||
Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
|
||||
|
||||
const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
|
||||
const sanitize = (text: string) => text.replace(sanitizer(), "")
|
||||
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
||||
const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
|
||||
const sanitize = (text: string) => text.replace(sanitizer(), "")
|
||||
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
|
||||
|
||||
return {
|
||||
data: store,
|
||||
set: setStore,
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
if (match.found) return store.session[match.index]
|
||||
return undefined
|
||||
return {
|
||||
data: store,
|
||||
set: setStore,
|
||||
get ready() {
|
||||
return store.ready
|
||||
},
|
||||
async sync(sessionID: string) {
|
||||
const [session, messages] = await Promise.all([
|
||||
sdk.session.get({ path: { id: sessionID } }),
|
||||
sdk.session.messages({ path: { id: sessionID } }),
|
||||
])
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||
draft.session[match.index] = session.data!
|
||||
draft.message[sessionID] = messages
|
||||
.data!.map((x) => x.info)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
for (const message of messages.data!) {
|
||||
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
}),
|
||||
)
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
if (match.found) return store.session[match.index]
|
||||
return undefined
|
||||
},
|
||||
async sync(sessionID: string) {
|
||||
const [session, messages] = await Promise.all([
|
||||
sdk.client.session.get({ path: { id: sessionID } }),
|
||||
sdk.client.session.messages({ path: { id: sessionID } }),
|
||||
])
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||
draft.session[match.index] = session.data!
|
||||
draft.message[sessionID] = messages
|
||||
.data!.map((x) => x.info)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
for (const message of messages.data!) {
|
||||
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
load,
|
||||
absolute,
|
||||
sanitize,
|
||||
}
|
||||
}
|
||||
|
||||
type SyncContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<SyncContext>()
|
||||
|
||||
export function SyncProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return (
|
||||
<Show when={value.data.ready}>
|
||||
<ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSync() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useSync must be used within a SyncProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
load,
|
||||
absolute,
|
||||
sanitize,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3,10 +3,17 @@ import "@/index.css"
|
||||
import { render } from "solid-js/web"
|
||||
import { Router, Route } from "@solidjs/router"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { EventProvider, SDKProvider, SyncProvider, LocalProvider, ShikiProvider, MarkedProvider } from "@/context"
|
||||
import { Fonts } from "@opencode-ai/ui"
|
||||
import { ShikiProvider } from "./context/shiki"
|
||||
import { MarkedProvider } from "./context/marked"
|
||||
import { SDKProvider } from "./context/sdk"
|
||||
import { SyncProvider } from "./context/sync"
|
||||
import { LocalProvider } from "./context/local"
|
||||
import Home from "@/pages"
|
||||
|
||||
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
|
||||
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
throw new Error(
|
||||
@@ -18,19 +25,17 @@ render(
|
||||
() => (
|
||||
<ShikiProvider>
|
||||
<MarkedProvider>
|
||||
<SDKProvider>
|
||||
<EventProvider>
|
||||
<SyncProvider>
|
||||
<LocalProvider>
|
||||
<MetaProvider>
|
||||
<Fonts />
|
||||
<Router>
|
||||
<Route path="/" component={Home} />
|
||||
</Router>
|
||||
</MetaProvider>
|
||||
</LocalProvider>
|
||||
</SyncProvider>
|
||||
</EventProvider>
|
||||
<SDKProvider url={`http://${host}:${port}`}>
|
||||
<SyncProvider>
|
||||
<LocalProvider>
|
||||
<MetaProvider>
|
||||
<Fonts />
|
||||
<Router>
|
||||
<Route path="/" component={Home} />
|
||||
</Router>
|
||||
</MetaProvider>
|
||||
</LocalProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</MarkedProvider>
|
||||
</ShikiProvider>
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { Button, Icon, List, SelectDialog, Tooltip } from "@opencode-ai/ui"
|
||||
import { Button, List, SelectDialog, Tooltip, IconButton, Tabs } from "@opencode-ai/ui"
|
||||
import { FileIcon } from "@/ui"
|
||||
import FileTree from "@/components/file-tree"
|
||||
import EditorPane from "@/components/editor-pane"
|
||||
import { For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useSync, useSDK, useLocal } from "@/context"
|
||||
import type { LocalFile, TextSelection } from "@/context/local"
|
||||
import SessionTimeline from "@/components/session-timeline"
|
||||
import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect } from "solid-js"
|
||||
import { useLocal, type LocalFile, type TextSelection } from "@/context/local"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { getDirectory, getFilename } from "@/utils"
|
||||
import { ContentPart, PromptInput } from "@/components/prompt-input"
|
||||
import { DateTime } from "luxon"
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
DragOverlay,
|
||||
SortableProvider,
|
||||
closestCenter,
|
||||
createSortable,
|
||||
useDragDropContext,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||
import type { JSX } from "solid-js"
|
||||
import { Code } from "@/components/code"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
|
||||
export default function Page() {
|
||||
const local = useLocal()
|
||||
@@ -17,10 +28,18 @@ export default function Page() {
|
||||
const sdk = useSDK()
|
||||
const [store, setStore] = createStore({
|
||||
clickTimer: undefined as number | undefined,
|
||||
modelSelectOpen: false,
|
||||
fileSelectOpen: false,
|
||||
})
|
||||
let inputRef!: HTMLDivElement
|
||||
let messageScrollElement!: HTMLDivElement
|
||||
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
|
||||
|
||||
createEffect(() => {
|
||||
if (!local.session.activeMessage()) return
|
||||
if (!messageScrollElement) return
|
||||
const element = messageScrollElement.querySelector(`[data-message="${local.session.activeMessage()?.id}"]`)
|
||||
element?.scrollIntoView({ block: "start", behavior: "instant" })
|
||||
})
|
||||
|
||||
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
|
||||
|
||||
@@ -101,11 +120,50 @@ export default function Page() {
|
||||
}
|
||||
}
|
||||
|
||||
const navigateChange = (dir: 1 | -1) => {
|
||||
const active = local.file.active()
|
||||
if (!active) return
|
||||
const current = local.file.changeIndex(active.path)
|
||||
const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
|
||||
local.file.setChangeIndex(active.path, next)
|
||||
}
|
||||
|
||||
const handleTabChange = (path: string) => {
|
||||
if (path === "chat" || path === "review") return
|
||||
local.file.open(path)
|
||||
}
|
||||
|
||||
const handleTabClose = (file: LocalFile) => {
|
||||
local.file.close(file.path)
|
||||
}
|
||||
|
||||
const handleDragStart = (event: unknown) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
setActiveItem(id)
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
const { draggable, droppable } = event
|
||||
if (draggable && droppable) {
|
||||
const currentFiles = local.file.opened().map((file) => file.path)
|
||||
const fromIndex = currentFiles.indexOf(draggable.id.toString())
|
||||
const toIndex = currentFiles.indexOf(droppable.id.toString())
|
||||
if (fromIndex !== toIndex) {
|
||||
local.file.move(draggable.id.toString(), toIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setActiveItem(undefined)
|
||||
}
|
||||
|
||||
const handlePromptSubmit = async (parts: ContentPart[]) => {
|
||||
const existingSession = local.session.active()
|
||||
let session = existingSession
|
||||
if (!session) {
|
||||
const created = await sdk.session.create()
|
||||
const created = await sdk.client.session.create()
|
||||
session = created.data ?? undefined
|
||||
}
|
||||
if (!session) return
|
||||
@@ -187,7 +245,7 @@ export default function Page() {
|
||||
}
|
||||
})
|
||||
|
||||
await sdk.session.prompt({
|
||||
await sdk.client.session.prompt({
|
||||
path: { id: session.id },
|
||||
body: {
|
||||
agent: local.agent.current()!.name,
|
||||
@@ -211,6 +269,93 @@ export default function Page() {
|
||||
inputRef?.focus()
|
||||
}
|
||||
|
||||
const TabVisual = (props: { file: LocalFile }): JSX.Element => {
|
||||
return (
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<FileIcon node={props.file} class="_grayscale-100" />
|
||||
<span
|
||||
classList={{
|
||||
"text-14-medium": true,
|
||||
"text-primary": !!props.file.status?.status,
|
||||
italic: !props.file.pinned,
|
||||
}}
|
||||
>
|
||||
{props.file.name}
|
||||
</span>
|
||||
<span class="hidden opacity-70">
|
||||
<Switch>
|
||||
<Match when={props.file.status?.status === "modified"}>
|
||||
<span class="text-primary">M</span>
|
||||
</Match>
|
||||
<Match when={props.file.status?.status === "added"}>
|
||||
<span class="text-success">A</span>
|
||||
</Match>
|
||||
<Match when={props.file.status?.status === "deleted"}>
|
||||
<span class="text-error">D</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SortableTab = (props: {
|
||||
file: LocalFile
|
||||
onTabClick: (file: LocalFile) => void
|
||||
onTabClose: (file: LocalFile) => void
|
||||
}): JSX.Element => {
|
||||
const sortable = createSortable(props.file.path)
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
||||
<Tooltip value={props.file.path} placement="bottom" class="h-full">
|
||||
<div class="relative h-full">
|
||||
<Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}>
|
||||
<TabVisual file={props.file} />
|
||||
</Tabs.Trigger>
|
||||
<IconButton
|
||||
icon="close"
|
||||
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
|
||||
variant="ghost"
|
||||
onClick={() => props.onTabClose(props.file)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ConstrainDragYAxis = (): JSX.Element => {
|
||||
const context = useDragDropContext()
|
||||
if (!context) return <></>
|
||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
||||
const transformer: Transformer = {
|
||||
id: "constrain-y-axis",
|
||||
order: 100,
|
||||
callback: (transform) => ({ ...transform, y: 0 }),
|
||||
}
|
||||
onDragStart((event) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
addTransformer("draggables", id, transformer)
|
||||
})
|
||||
onDragEnd((event) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
removeTransformer("draggables", id, transformer.id)
|
||||
})
|
||||
return <></>
|
||||
}
|
||||
|
||||
const getDraggableId = (event: unknown): string | undefined => {
|
||||
if (typeof event !== "object" || event === null) return undefined
|
||||
if (!("draggable" in event)) return undefined
|
||||
const draggable = (event as { draggable?: { id?: unknown } }).draggable
|
||||
if (!draggable) return undefined
|
||||
return typeof draggable.id === "string" ? draggable.id : undefined
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="relative h-screen flex flex-col">
|
||||
<header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header>
|
||||
@@ -253,22 +398,203 @@ export default function Page() {
|
||||
</List>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative grid grid-cols-2 bg-background-base w-full">
|
||||
<div class="pt-1.5 min-w-0 overflow-y-auto no-scrollbar flex justify-center">
|
||||
<Show when={local.session.active()}>
|
||||
{(activeSession) => <SessionTimeline session={activeSession().id} class="w-full" />}
|
||||
</Show>
|
||||
</div>
|
||||
<div class="p-1.5 pl-px flex flex-col items-center justify-center overflow-y-auto no-scrollbar">
|
||||
<Show when={local.session.active()}>
|
||||
<EditorPane onFileClick={handleFileClick} />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="relative bg-background-base w-full h-full overflow-x-hidden">
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<Tabs onChange={handleTabChange}>
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="chat" class="flex gap-x-1.5 items-center">
|
||||
<div>Chat</div>
|
||||
<Show when={local.session.active()}>
|
||||
<div class="flex flex-col h-4 px-2 -mr-2 justify-center items-center rounded-full bg-surface-base text-12-medium text-text-strong">
|
||||
{local.session.context()}%
|
||||
</div>
|
||||
</Show>
|
||||
</Tabs.Trigger>
|
||||
{/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
|
||||
<SortableProvider ids={local.file.opened().map((file) => file.path)}>
|
||||
<For each={local.file.opened()}>
|
||||
{(file) => <SortableTab file={file} onTabClick={handleFileClick} onTabClose={handleTabClose} />}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={() => setStore("fileSelectOpen", true)}
|
||||
/>
|
||||
</div>
|
||||
</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 icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} />
|
||||
</Tooltip>
|
||||
<Tooltip value="Next change" placement="bottom">
|
||||
<IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Tooltip value="Raw" placement="bottom">
|
||||
<IconButton
|
||||
icon="file-text"
|
||||
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")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip value="Unified diff" placement="bottom">
|
||||
<IconButton
|
||||
icon="checklist"
|
||||
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")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip value="Split diff" placement="bottom">
|
||||
<IconButton
|
||||
icon="columns"
|
||||
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")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs.Content value="chat" class="select-text flex flex-col flex-1 min-h-0">
|
||||
<Show when={local.session.active()} fallback={<div>No active session</div>}>
|
||||
{(activeSession) => (
|
||||
<div class="p-6 pt-12 max-w-[904px] mx-auto flex flex-col flex-1 min-h-0">
|
||||
<div class="py-3 flex flex-col flex-1 min-h-0">
|
||||
<div class="flex items-start gap-8 flex-1 min-h-0">
|
||||
<ul role="list" class="w-60 shrink-0 flex flex-col items-start gap-1">
|
||||
<For each={local.session.userMessages()}>
|
||||
{(message) => (
|
||||
<li
|
||||
class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
|
||||
onClick={() => local.session.setActiveMessage(message.id)}
|
||||
>
|
||||
<div class="w-[18px] shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none">
|
||||
<g>
|
||||
<rect x="0" width="2" height="12" rx="1" fill="#CFCECD" />
|
||||
<rect x="4" width="2" height="12" rx="1" fill="#CFCECD" />
|
||||
<rect x="8" width="2" height="12" rx="1" fill="#CFCECD" />
|
||||
<rect x="12" width="2" height="12" rx="1" fill="#CFCECD" />
|
||||
<rect x="16" width="2" height="12" rx="1" fill="#CFCECD" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
data-active={local.session.activeMessage()?.id === message.id}
|
||||
classList={{
|
||||
"text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
|
||||
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
|
||||
}}
|
||||
>
|
||||
{local.session.getMessageText(message)}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
<div
|
||||
ref={messageScrollElement}
|
||||
class="grow min-w-0 h-full overflow-y-auto no-scrollbar snap-y"
|
||||
>
|
||||
<div class="flex flex-col items-start gap-50 pb-[800px]">
|
||||
<For each={local.session.userMessages()}>
|
||||
{(message) => (
|
||||
<div
|
||||
data-message={message.id}
|
||||
class="flex flex-col items-start self-stretch gap-8 pt-1.5 snap-start"
|
||||
>
|
||||
<div class="flex flex-col items-start gap-4">
|
||||
<div class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
|
||||
{local.session.getMessageText(message)}
|
||||
</div>
|
||||
<div class="text-14-regular text-text-base">
|
||||
{message.summary?.text ||
|
||||
local.session.getMessageText(local.session.activeAssistantMessagesWithText())}
|
||||
</div>
|
||||
</div>
|
||||
<div class=""></div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
{/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
|
||||
<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>
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-x-0 px-8 flex flex-col justify-center items-center z-50": true,
|
||||
"bottom-8": !!local.session.active(),
|
||||
"bottom-1/2 translate-y-1/2": !local.session.active(),
|
||||
"absolute inset-x-0 px-6 max-w-[904px] flex flex-col justify-center items-center z-50 mx-auto": true,
|
||||
"bottom-8": true,
|
||||
// "bottom-8": !!local.session.active(),
|
||||
// "bottom-1/2 translate-y-1/2": !local.session.active(),
|
||||
}}
|
||||
>
|
||||
<PromptInput
|
||||
|
||||
@@ -5,11 +5,12 @@ import { Icon, IconProps } from "./icon"
|
||||
export interface IconButtonProps {
|
||||
icon: IconProps["name"]
|
||||
size?: "normal" | "large"
|
||||
iconSize?: IconProps["size"]
|
||||
variant?: "primary" | "secondary" | "ghost"
|
||||
}
|
||||
|
||||
export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
|
||||
const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
|
||||
const [split, rest] = splitProps(props, ["variant", "size", "iconSize", "class", "classList"])
|
||||
return (
|
||||
<Kobalte
|
||||
{...rest}
|
||||
@@ -21,7 +22,7 @@ export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
<Icon data-slot="icon" name={props.icon} size={split.size === "large" ? "normal" : "small"} />
|
||||
<Icon data-slot="icon" name={props.icon} size={split.iconSize ?? (split.size === "large" ? "normal" : "small")} />
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
}
|
||||
|
||||
&[data-size="large"] {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
[data-slot="svg"] {
|
||||
|
||||
@@ -3,14 +3,11 @@
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-radius: var(--radius-sm);
|
||||
border-color: var(--border-weak-base);
|
||||
background-color: var(--background-stronger);
|
||||
overflow: clip;
|
||||
|
||||
[data-slot="list"] {
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -32,7 +29,6 @@
|
||||
height: 100%;
|
||||
border-bottom: 1px solid var(--border-weak-base);
|
||||
background-color: var(--background-base);
|
||||
border-top-right-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
&:empty::after {
|
||||
@@ -42,19 +38,25 @@
|
||||
|
||||
[data-slot="trigger"] {
|
||||
position: relative;
|
||||
height: 36px;
|
||||
padding: 8px 12px;
|
||||
height: 100%;
|
||||
padding: 8px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-base);
|
||||
|
||||
/* text-14-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-weak);
|
||||
line-height: var(--line-height-large); /* 142.857% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-weak-base);
|
||||
border-right: 1px solid var(--border-weak-base);
|
||||
background-color: var(--background-weak);
|
||||
background-color: var(--background-base);
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
@@ -68,7 +70,7 @@
|
||||
box-shadow: 0 0 0 2px var(--border-focus);
|
||||
}
|
||||
&[data-selected] {
|
||||
color: var(--text-base);
|
||||
color: var(--text-strong);
|
||||
background-color: transparent;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user