mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-21 09:44:21 +01:00
wip: desktop work
This commit is contained in:
140
packages/desktop/src/components/diff.tsx
Normal file
140
packages/desktop/src/components/diff.tsx
Normal file
@@ -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} />
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,7 +375,13 @@ export default function Page() {
|
||||
onSelect={(s) => local.session.setActive(s?.id)}
|
||||
onHover={(s) => (!!s ? sync.session.sync(s?.id) : undefined)}
|
||||
>
|
||||
{(session) => (
|
||||
{(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">
|
||||
@@ -386,15 +393,18 @@ export default function Page() {
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center self-stretch">
|
||||
<span class="text-12-regular text-text-weak">2 files changed</span>
|
||||
<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">+43</span>
|
||||
<span class="text-12-mono text-right text-text-diff-delete-base">-2</span>
|
||||
<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>
|
||||
</Tooltip>
|
||||
)}
|
||||
)
|
||||
}}
|
||||
</List>
|
||||
</div>
|
||||
</div>
|
||||
@@ -521,6 +531,7 @@ 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">
|
||||
<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) => (
|
||||
@@ -552,13 +563,16 @@ export default function Page() {
|
||||
)}
|
||||
</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) => (
|
||||
{(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"
|
||||
@@ -567,17 +581,30 @@ export default function Page() {
|
||||
<div class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
|
||||
{local.session.getMessageText(message)}
|
||||
</div>
|
||||
<div class="text-14-regular text-text-base">
|
||||
{message.summary?.text ||
|
||||
local.session.getMessageText(local.session.activeAssistantMessagesWithText())}
|
||||
</div>
|
||||
</div>
|
||||
<div class=""></div>
|
||||
<div class="text-14-regular text-text-base">{message.summary?.text}</div>
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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
packages/ui/src/components/collapsible.css
Normal file
46
packages/ui/src/components/collapsible.css
Normal file
@@ -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
packages/ui/src/components/collapsible.tsx
Normal file
35
packages/ui/src/components/collapsible.tsx
Normal file
@@ -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,4 +1,5 @@
|
||||
export * from "./button"
|
||||
export * from "./collapsible"
|
||||
export * from "./dialog"
|
||||
export * from "./icon"
|
||||
export * from "./icon-button"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user