diff options
| author | Adam <[email protected]> | 2025-10-24 08:26:17 -0500 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-10-24 12:16:33 -0500 |
| commit | fe8f6d7a3eef34e932bd43d244460d417865de88 (patch) | |
| tree | 3129fd2072fd3fd0dd842e33c59e0f1f256c1df3 | |
| parent | 59b5f5350950e9cac81676f45eaca8611514c4d1 (diff) | |
| download | opencode-fe8f6d7a3eef34e932bd43d244460d417865de88.tar.gz opencode-fe8f6d7a3eef34e932bd43d244460d417865de88.zip | |
wip: desktop work
| -rw-r--r-- | packages/desktop/src/components/diff.tsx | 140 | ||||
| -rw-r--r-- | packages/desktop/src/context/local.tsx | 6 | ||||
| -rw-r--r-- | packages/desktop/src/pages/index.tsx | 155 | ||||
| -rw-r--r-- | packages/opencode/test/fixture/fixture.ts | 3 | ||||
| -rw-r--r-- | packages/ui/src/components/collapsible.css | 46 | ||||
| -rw-r--r-- | packages/ui/src/components/collapsible.tsx | 35 | ||||
| -rw-r--r-- | packages/ui/src/components/index.ts | 1 | ||||
| -rw-r--r-- | packages/ui/src/demo.tsx | 36 |
8 files changed, 350 insertions, 72 deletions
diff --git a/packages/desktop/src/components/diff.tsx b/packages/desktop/src/components/diff.tsx new file mode 100644 index 000000000..4667bbb3a --- /dev/null +++ b/packages/desktop/src/components/diff.tsx @@ -0,0 +1,140 @@ +import { type FileContents, FileDiff, type DiffLineAnnotation } from "@pierre/precision-diffs" + +export interface DiffProps { + before: FileContents + after: FileContents +} + +export function Diff(props: DiffProps) { + let container!: HTMLDivElement + + console.log(props) + + interface ThreadMetadata { + threadId: string + } + + const lineAnnotations: DiffLineAnnotation<ThreadMetadata>[] = [ + { + side: "additions", + // The line number specified for an annotation is the visual line number + // you see in the number column of a diff + lineNumber: 16, + metadata: { threadId: "68b329da9893e34099c7d8ad5cb9c940" }, + }, + ] + + const instance = new FileDiff<ThreadMetadata>({ + // You can provide a 'theme' prop that maps to any + // built in shiki theme or you can register a custom + // theme. We also include 2 custom themes + // + // 'pierre-night' and 'pierre-light + // + // For the rest of the available shiki themes, check out: + // https://shiki.style/themes + theme: "none", + // Or can also provide a 'themes' prop, which allows the code to adapt + // to your OS light or dark theme + // themes: { dark: 'pierre-night', light: 'pierre-light' }, + // When using the 'themes' prop, 'themeType' allows you to force 'dark' + // or 'light' theme, or inherit from the OS ('system') theme. + themeType: "system", + // Disable the line numbers for your diffs, generally not recommended + disableLineNumbers: false, + // Whether code should 'wrap' with long lines or 'scroll'. + overflow: "scroll", + // Normally you shouldn't need this prop, but if you don't provide a + // valid filename or your file doesn't have an extension you may want to + // override the automatic detection. You can specify that language here: + // https://shiki.style/languages + // lang?: SupportedLanguages; + // 'diffStyle' controls whether the diff is presented side by side or + // in a unified (single column) view + diffStyle: "split", + // Line decorators to help highlight changes. + // 'bars' (default): + // Shows some red-ish or green-ish (theme dependent) bars on the left + // edge of relevant lines + // + // 'classic': + // shows '+' characters on additions and '-' characters on deletions + // + // 'none': + // No special diff indicators are shown + diffIndicators: "bars", + // By default green-ish or red-ish background are shown on added and + // deleted lines respectively. Disable that feature here + disableBackground: false, + // Diffs are split up into hunks, this setting customizes what to show + // between each hunk. + // + // 'line-info' (default): + // Shows a bar that tells you how many lines are collapsed. If you are + // using the oldFile/newFile API then you can click those bars to + // expand the content between them + // + // 'metadata': + // Shows the content you'd see in a normal patch file, usually in some + // format like '@@ -60,6 +60,22 @@'. You cannot use these to expand + // hidden content + // + // 'simple': + // Just a subtle bar separator between each hunk + hunkSeparators: "line-info", + // On lines that have both additions and deletions, we can run a + // separate diff check to mark parts of the lines that change. + // 'none': + // Do not show these secondary highlights + // + // 'char': + // Show changes at a per character granularity + // + // 'word': + // Show changes but rounded up to word boundaries + // + // 'word-alt' (default): + // Similar to 'word', however we attempt to minimize single character + // gaps between highlighted changes + lineDiffType: "word-alt", + // If lines exceed these character lengths then we won't perform the + // line lineDiffType check + maxLineDiffLength: 1000, + // If any line in the diff exceeds this value then we won't attempt to + // syntax highlight the diff + maxLineLengthForHighlighting: 1000, + // Enabling this property will hide the file header with file name and + // diff stats. + disableFileHeader: false, + // You can optionally pass a render function for rendering out line + // annotations. Just return the dom node to render + renderAnnotation(annotation: DiffLineAnnotation<ThreadMetadata>): HTMLElement { + // Despite the diff itself being rendered in the shadow dom, + // annotations are inserted via the web components 'slots' api and you + // can use all your normal normal css and styling for them + const element = document.createElement("div") + element.innerText = annotation.metadata.threadId + return element + }, + }) + + // If you ever want to update the options for an instance, simple call + // 'setOptions' with the new options. Bear in mind, this does NOT merge + // existing properties, it's a full replace + instance.setOptions({ + ...instance.options, + theme: "pierre-dark", + themes: undefined, + }) + + // When ready to render, simply call .render with old/new file, optional + // annotations and a container element to hold the diff + instance.render({ + oldFile: props.before, + newFile: props.after, + lineAnnotations, + containerWrapper: container, + }) + + return <div ref={container} /> +} diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 981039bb6..6ed8ec17b 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -467,11 +467,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ ) }) - 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] @@ -510,7 +505,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ active, activeMessage, activeAssistantMessages, - activeAssistantMessagesWithText, lastUserMessage, cost, last, diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx index 1542853ea..f6ea4cb90 100644 --- a/packages/desktop/src/pages/index.tsx +++ b/packages/desktop/src/pages/index.tsx @@ -1,7 +1,7 @@ import { Button, List, SelectDialog, Tooltip, IconButton, Tabs, Icon } from "@opencode-ai/ui" import { FileIcon } from "@/ui" import FileTree from "@/components/file-tree" -import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect } from "solid-js" +import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js" import { useLocal, type LocalFile, type TextSelection } from "@/context/local" import { createStore } from "solid-js/store" import { getDirectory, getFilename } from "@/utils" @@ -21,6 +21,7 @@ import type { JSX } from "solid-js" import { Code } from "@/components/code" import { useSync } from "@/context/sync" import { useSDK } from "@/context/sdk" +import { Diff } from "@/components/diff" export default function Page() { const local = useLocal() @@ -374,27 +375,36 @@ export default function Page() { onSelect={(s) => local.session.setActive(s?.id)} onHover={(s) => (!!s ? sync.session.sync(s?.id) : undefined)} > - {(session) => ( - <Tooltip placement="right" value={session.title}> - <div> - <div class="flex items-center self-stretch gap-6"> - <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> - {session.title} - </span> - <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> - {DateTime.fromMillis(session.time.updated).toRelative()} - </span> - </div> - <div class="flex justify-between items-center self-stretch"> - <span class="text-12-regular text-text-weak">2 files changed</span> - <div class="flex gap-2 justify-end items-center"> - <span class="text-12-mono text-right text-text-diff-add-base">+43</span> - <span class="text-12-mono text-right text-text-diff-delete-base">-2</span> + {(session) => { + const diffs = createMemo(() => session.summary?.diffs ?? []) + const filesChanged = createMemo(() => diffs().length) + const additions = createMemo(() => diffs().reduce((acc, diff) => (acc ?? 0) + (diff.additions ?? 0), 0)) + const deletions = createMemo(() => diffs().reduce((acc, diff) => (acc ?? 0) + (diff.deletions ?? 0), 0)) + + return ( + <Tooltip placement="right" value={session.title}> + <div> + <div class="flex items-center self-stretch gap-6"> + <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> + {session.title} + </span> + <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> + {DateTime.fromMillis(session.time.updated).toRelative()} + </span> + </div> + <div class="flex justify-between items-center self-stretch"> + <span class="text-12-regular text-text-weak">{`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}</span> + <Show when={additions() || deletions()}> + <div class="flex gap-2 justify-end items-center"> + <span class="text-12-mono text-right text-text-diff-add-base">{`+${additions()}`}</span> + <span class="text-12-mono text-right text-text-diff-delete-base">{`-${deletions()}`}</span> + </div> + </Show> </div> </div> - </div> - </Tooltip> - )} + </Tooltip> + ) + }} </List> </div> </div> @@ -521,60 +531,77 @@ export default function Page() { {(activeSession) => ( <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, - }} + <Show when={local.session.userMessages().length > 1}> + <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)} > - {local.session.getMessageText(message)} - </div> - </li> - )} - </For> - </ul> + <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> + </Show> <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)} + {(message) => { + console.log(message) + return ( + <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}</div> </div> - <div class="text-14-regular text-text-base"> - {message.summary?.text || - local.session.getMessageText(local.session.activeAssistantMessagesWithText())} + <div class=""> + <For each={message.summary?.diffs}> + {(diff) => ( + <Diff + before={{ + name: diff.file!, + contents: diff.before!, + }} + after={{ + name: diff.file!, + contents: diff.after!, + }} + /> + )} + </For> </div> </div> - <div class=""></div> - </div> - )} + ) + }} </For> </div> </div> diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 0b83bb316..0d3e0c917 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -1,4 +1,5 @@ import { $ } from "bun" +import { realpathSync } from "fs" import os from "os" import path from "path" @@ -17,7 +18,7 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) { await options?.dispose?.(dirpath) await $`rm -rf ${dirpath}`.quiet() }, - path: dirpath, + path: realpathSync(dirpath), extra: extra as T, } return result diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css new file mode 100644 index 000000000..441d0083f --- /dev/null +++ b/packages/ui/src/components/collapsible.css @@ -0,0 +1,46 @@ +[data-component="collapsible"] { + display: flex; + flex-direction: column; + + [data-slot="trigger"] { + cursor: pointer; + user-select: none; + + &:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 2px; + } + + &[data-disabled] { + cursor: not-allowed; + opacity: 0.5; + } + } + + [data-slot="content"] { + overflow: hidden; + /* animation: slideUp 250ms ease-out; */ + + /* &[data-expanded] { */ + /* animation: slideDown 250ms ease-out; */ + /* } */ + } +} + +@keyframes slideDown { + from { + height: 0; + } + to { + height: var(--kb-collapsible-content-height); + } +} + +@keyframes slideUp { + from { + height: var(--kb-collapsible-content-height); + } + to { + height: 0; + } +} diff --git a/packages/ui/src/components/collapsible.tsx b/packages/ui/src/components/collapsible.tsx new file mode 100644 index 000000000..011d103c1 --- /dev/null +++ b/packages/ui/src/components/collapsible.tsx @@ -0,0 +1,35 @@ +import { Collapsible as Kobalte, CollapsibleRootProps } from "@kobalte/core/collapsible" +import { ComponentProps, ParentProps, splitProps } from "solid-js" + +export interface CollapsibleProps extends ParentProps<CollapsibleRootProps> { + class?: string + classList?: ComponentProps<"div">["classList"] +} + +function CollapsibleRoot(props: CollapsibleProps) { + const [local, others] = splitProps(props, ["class", "classList"]) + + return ( + <Kobalte + data-component="collapsible" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + {...others} + /> + ) +} + +function CollapsibleTrigger(props: ComponentProps<typeof Kobalte.Trigger>) { + return <Kobalte.Trigger data-slot="trigger" {...props} /> +} + +function CollapsibleContent(props: ComponentProps<typeof Kobalte.Content>) { + return <Kobalte.Content data-slot="content" {...props} /> +} + +export const Collapsible = Object.assign(CollapsibleRoot, { + Trigger: CollapsibleTrigger, + Content: CollapsibleContent, +}) diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 71cfd3a89..3691363cd 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -1,4 +1,5 @@ export * from "./button" +export * from "./collapsible" export * from "./dialog" export * from "./icon" export * from "./icon-button" diff --git a/packages/ui/src/demo.tsx b/packages/ui/src/demo.tsx index 85fd20c0e..7c507d7d9 100644 --- a/packages/ui/src/demo.tsx +++ b/packages/ui/src/demo.tsx @@ -1,6 +1,19 @@ import type { Component } from "solid-js" import { createSignal } from "solid-js" -import { Button, Select, Tabs, Tooltip, Fonts, List, Dialog, Icon, IconButton, Input, SelectDialog } from "./components" +import { + Button, + Select, + Tabs, + Tooltip, + Fonts, + List, + Dialog, + Icon, + IconButton, + Input, + SelectDialog, + Collapsible, +} from "./components" import "./index.css" const Demo: Component = () => { @@ -180,6 +193,27 @@ const Demo: Component = () => { {(item) => <div>{item}</div>} </SelectDialog> </section> + <h3>Collapsible</h3> + <section> + <Collapsible> + <Collapsible.Trigger> + <Button variant="secondary">Toggle Content</Button> + </Collapsible.Trigger> + <Collapsible.Content> + <div + style={{ + padding: "16px", + "background-color": "var(--surface-base)", + "border-radius": "8px", + "margin-top": "8px", + }} + > + <p>This is collapsible content that can be toggled open and closed.</p> + <p>It animates smoothly using CSS animations.</p> + </div> + </Collapsible.Content> + </Collapsible> + </section> </div> ) |
