Adam 4 месяцев назад
Родитель
Сommit
fe8f6d7a3e

+ 140 - 0
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} />
+}

+ 0 - 6
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,

+ 91 - 64
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>

+ 2 - 1
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

+ 46 - 0
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;
+  }
+}

+ 35 - 0
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,
+})

+ 1 - 0
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"

+ 35 - 1
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>
   )