diff --git a/packages/desktop/src/components/code.tsx b/packages/desktop/src/components/code.tsx index b4dd216e..11518e73 100644 --- a/packages/desktop/src/components/code.tsx +++ b/packages/desktop/src/components/code.tsx @@ -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 diff --git a/packages/desktop/src/components/editor-pane.tsx b/packages/desktop/src/components/editor-pane.tsx deleted file mode 100644 index a97a0ef7..00000000 --- a/packages/desktop/src/components/editor-pane.tsx +++ /dev/null @@ -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(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 ( - - - - -
- - file.path)}> - - {(file) => } - - - - -
- - {(file) => ( - - {(() => { - const view = local.file.view(file.path) - const showRaw = view === "raw" || !file.content?.diff - const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") - return - })()} - - )} - -
- - {(() => { - const id = activeItem() - if (!id) return null - const draggedFile = local.file.node(id) - if (!draggedFile) return null - return ( -
- -
- ) - })()} -
-
- ) -} - -function TabVisual(props: { file: LocalFile }): JSX.Element { - return ( -
- - - {props.file.name} - - - - - M - - - A - - - D - - - -
- ) -} - -function SortableTab(props: { - file: LocalFile - onTabClick: (file: LocalFile) => void - onTabClose: (file: LocalFile) => void -}): JSX.Element { - const sortable = createSortable(props.file.path) - - return ( - // @ts-ignore -
- -
- props.onTabClick(props.file)}> - - - props.onTabClose(props.file)} - /> -
-
-
- ) -} - -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 -} diff --git a/packages/desktop/src/components/file-tree.tsx b/packages/desktop/src/components/file-tree.tsx index 7e4b1abc..d1032813 100644 --- a/packages/desktop/src/components/file-tree.tsx +++ b/packages/desktop/src/components/file-tree.tsx @@ -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" diff --git a/packages/desktop/src/components/markdown.tsx b/packages/desktop/src/components/markdown.tsx index 30e3831e..e0f185f5 100644 --- a/packages/desktop/src/components/markdown.tsx +++ b/packages/desktop/src/components/markdown.tsx @@ -1,4 +1,4 @@ -import { useMarked } from "@/context" +import { useMarked } from "@/context/marked" import { createResource } from "solid-js" function strip(text: string): string { diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 55a41051..47893f44 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -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 = (props) => { } return ( -
+
diff --git a/packages/desktop/src/components/session-timeline.tsx b/packages/desktop/src/components/session-timeline.tsx index 0d8a7cd3..b751f294 100644 --- a/packages/desktop/src/components/session-timeline.tsx +++ b/packages/desktop/src/components/session-timeline.tsx @@ -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, }} > -
+
@@ -405,7 +406,7 @@ export default function SessionTimeline(props: { session: string; class?: string
{cost()}
-
    +
      {(message) => (
      () - sdk.event.subscribe().then(async (events) => { - for await (const event of events.stream) { - bus.emit(event) - } - }) - return bus -} - -type EventContext = ReturnType - -const ctx = createContext() - -export function EventProvider(props: ParentProps) { - const value = init() - return {props.children} -} - -export function useEvent() { - const value = useContext(ctx) - if (!value) { - throw new Error("useEvent must be used within a EventProvider") - } - return value -} diff --git a/packages/desktop/src/context/helper.tsx b/packages/desktop/src/context/helper.tsx new file mode 100644 index 00000000..6be88e77 --- /dev/null +++ b/packages/desktop/src/context/helper.tsx @@ -0,0 +1,25 @@ +import { createContext, Show, useContext, type ParentProps } from "solid-js" + +export function createSimpleContext>(input: { + name: string + init: ((input: Props) => T) | (() => T) +}) { + const ctx = createContext() + + return { + provider: (props: ParentProps) => { + const init = input.init(props) + return ( + // @ts-expect-error + + {props.children} + + ) + }, + use() { + const value = useContext(ctx) + if (!value) throw new Error(`${input.name} context must be used within a context provider`) + return value + }, + } +} diff --git a/packages/desktop/src/context/index.ts b/packages/desktop/src/context/index.ts deleted file mode 100644 index 6ca3bbf9..00000000 --- a/packages/desktop/src/context/index.ts +++ /dev/null @@ -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" diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index c60e4520..981039bb 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -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 - 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 - 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 + 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; order: string[] } - type PaneDefault = number | { size: number; visible?: boolean } - - const [store, setStore] = createStore>({}) - - 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) - } - } - } - - 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) => { - 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 + 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 - -const ctx = createContext() - -export function LocalProvider(props: ParentProps) { - const value = init() - return {props.children} -} - -export function useLocal() { - const value = useContext(ctx) - if (!value) { - throw new Error("useLocal must be used within a LocalProvider") - } - return value -} + return result + }, +}) diff --git a/packages/desktop/src/context/marked.tsx b/packages/desktop/src/context/marked.tsx index 550a0456..18ce4280 100644 --- a/packages/desktop/src/context/marked.tsx +++ b/packages/desktop/src/context/marked.tsx @@ -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) { - 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 - -const ctx = createContext() - -export function MarkedProvider(props: ParentProps) { - const highlighter = useShiki() - const value = init(highlighter) - return {props.children} -} - -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, + }) + }, + }), + ) + }, +}) diff --git a/packages/desktop/src/context/sdk.tsx b/packages/desktop/src/context/sdk.tsx index 48595cf9..7ffa3049 100644 --- a/packages/desktop/src/context/sdk.tsx +++ b/packages/desktop/src/context/sdk.tsx @@ -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 + }>() -type SDKContext = ReturnType + 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() + onCleanup(() => { + abort.abort() + }) -export function SDKProvider(props: ParentProps) { - const value = init() - return {props.children} -} - -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 } + }, +}) diff --git a/packages/desktop/src/context/shiki.tsx b/packages/desktop/src/context/shiki.tsx index 1930b907..e7002841 100644 --- a/packages/desktop/src/context/shiki.tsx +++ b/packages/desktop/src/context/shiki.tsx @@ -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() - -export function ShikiProvider(props: ParentProps) { - return {props.children} -} - -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 + }, +}) diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx index 5ba6b1af..0fea4a42 100644 --- a/packages/desktop/src/context/sync.tsx +++ b/packages/desktop/src/context/sync.tsx @@ -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 - -const ctx = createContext() - -export function SyncProvider(props: ParentProps) { - const value = init() - return ( - - {props.children} - - ) -} - -export function useSync() { - const value = useContext(ctx) - if (!value) { - throw new Error("useSync must be used within a SyncProvider") - } - return value -} + load, + absolute, + sanitize, + } + }, +}) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 5fa840f0..b1c57bd6 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -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( () => ( - - - - - - - - - - - - - + + + + + + + + + + + diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 2ddf7c18..e7f94ad8 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -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(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 ( +
      + + + {props.file.name} + + +
      + ) + } + + const SortableTab = (props: { + file: LocalFile + onTabClick: (file: LocalFile) => void + onTabClose: (file: LocalFile) => void + }): JSX.Element => { + const sortable = createSortable(props.file.path) + + return ( + // @ts-ignore +
      + +
      + props.onTabClick(props.file)}> + + + props.onTabClose(props.file)} + /> +
      +
      +
      + ) + } + + 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 (
      @@ -253,22 +398,203 @@ export default function Page() {
      -
      -
      - - {(activeSession) => } - -
      -
      - - - -
      +
      + + + + +
      + + +
      Chat
      + +
      + {local.session.context()}% +
      +
      +
      + {/* Review */} + file.path)}> + + {(file) => } + + +
      + setStore("fileSelectOpen", true)} + /> +
      +
      + +
      + + No active session
      }> + {(activeSession) => ( +
      +
      +
      +
        + + {(message) => ( +
      • local.session.setActiveMessage(message.id)} + > +
        + + + + + + + + + +
        +
        + {local.session.getMessageText(message)} +
        +
      • + )} +
        +
      +
      +
      + + {(message) => ( +
      +
      +
      + {local.session.getMessageText(message)} +
      +
      + {message.summary?.text || + local.session.getMessageText(local.session.activeAssistantMessagesWithText())} +
      +
      +
      +
      + )} +
      +
      +
      +
      +
      +
      + )} + + + {/* */} + + {(file) => ( + + {(() => { + const view = local.file.view(file.path) + const showRaw = view === "raw" || !file.content?.diff + const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") + return + })()} + + )} + + + + {(() => { + const id = activeItem() + if (!id) return null + const draggedFile = local.file.node(id) + if (!draggedFile) return null + return ( +
      + +
      + ) + })()} +
      +
      & IconButtonProps) { - const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"]) + const [split, rest] = splitProps(props, ["variant", "size", "iconSize", "class", "classList"]) return ( & IconButtonProps) { [split.class ?? ""]: !!split.class, }} > - + ) } diff --git a/packages/ui/src/components/icon.css b/packages/ui/src/components/icon.css index 59c644b7..7f1f1833 100644 --- a/packages/ui/src/components/icon.css +++ b/packages/ui/src/components/icon.css @@ -18,8 +18,8 @@ } &[data-size="large"] { - width: 32px; - height: 32px; + width: 24px; + height: 24px; } [data-slot="svg"] { diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index 70d7b03e..29057fc8 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -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; }