summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-10-24 11:23:32 -0500
committerAdam <[email protected]>2025-10-24 12:16:33 -0500
commit86447b576490ee4cde0825418015652d8ed26794 (patch)
tree02f0a7780eb7c96d1fa7a801088743a29e265a9d /packages/desktop/src
parentfe8f6d7a3eef34e932bd43d244460d417865de88 (diff)
downloadopencode-86447b576490ee4cde0825418015652d8ed26794.tar.gz
opencode-86447b576490ee4cde0825418015652d8ed26794.zip
wip: desktop work
Diffstat (limited to 'packages/desktop/src')
-rw-r--r--packages/desktop/src/components/diff.tsx256
-rw-r--r--packages/desktop/src/components/prompt-input.tsx90
-rw-r--r--packages/desktop/src/pages/index.tsx69
3 files changed, 243 insertions, 172 deletions
diff --git a/packages/desktop/src/components/diff.tsx b/packages/desktop/src/components/diff.tsx
index 4667bbb3a..0facaba47 100644
--- a/packages/desktop/src/components/diff.tsx
+++ b/packages/desktop/src/components/diff.tsx
@@ -1,140 +1,150 @@
-import { type FileContents, FileDiff, type DiffLineAnnotation } from "@pierre/precision-diffs"
+import { type FileContents, FileDiff, type DiffLineAnnotation, DiffFileRendererOptions } from "@pierre/precision-diffs"
+import { ComponentProps, createEffect, splitProps } from "solid-js"
-export interface DiffProps {
+export type DiffProps<T = {}> = Omit<DiffFileRendererOptions<T>, "themes"> & {
before: FileContents
after: FileContents
+ annotations?: DiffLineAnnotation<T>[]
+ class?: string
+ classList?: ComponentProps<"div">["classList"]
}
-export function Diff(props: DiffProps) {
- let container!: HTMLDivElement
-
- console.log(props)
+// interface ThreadMetadata {
+// threadId: string
+// }
- interface ThreadMetadata {
- threadId: string
- }
+export function Diff<T>(props: DiffProps<T>) {
+ let container!: HTMLDivElement
+ const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"])
- 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
- },
- })
+ // 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" },
+ // },
+ // ]
// 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,
- })
+ // 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,
+ createEffect(() => {
+ const instance = new FileDiff<T>({
+ theme: "pierre-light",
+ // 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: "unified",
+ // 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: true,
+ // You can optionally pass a render function for rendering out line
+ // annotations. Just return the dom node to render
+ // renderAnnotation(annotation: DiffLineAnnotation<T>): 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
+ // },
+ ...others,
+ })
+
+ instance.render({
+ oldFile: local.before,
+ newFile: local.after,
+ lineAnnotations: local.annotations,
+ containerWrapper: container,
+ })
})
- return <div ref={container} />
+ return (
+ <div
+ style={{
+ "--pjs-font-family": "var(--font-family-mono)",
+ "--pjs-font-size": "var(--font-size-small)",
+ "--pjs-line-height": "24px",
+ "--pjs-tab-size": 4,
+ "--pjs-font-features": "var(--font-family-mono--font-feature-settings)",
+ "--pjs-header-font-family": "var(--font-family-sans)",
+ }}
+ ref={container}
+ />
+ )
}
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx
index 47893f44c..3838d19ba 100644
--- a/packages/desktop/src/components/prompt-input.tsx
+++ b/packages/desktop/src/components/prompt-input.tsx
@@ -1,6 +1,6 @@
import { Button, Icon, IconButton, Select, SelectDialog } from "@opencode-ai/ui"
import { useFilteredList } from "@opencode-ai/ui/hooks"
-import { createEffect, on, Component, createMemo, Show, For } from "solid-js"
+import { createEffect, on, Component, createMemo, Show, For, onMount, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { FileIcon } from "@/ui"
import { getDirectory, getFilename } from "@/utils"
@@ -46,6 +46,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts))
const isFocused = createFocusSignal(() => editorRef)
+ const handlePaste = (event: ClipboardEvent) => {
+ event.preventDefault()
+ event.stopPropagation()
+ // @ts-expect-error
+ const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? ""
+ addPart({ type: "text", content: plainText })
+ }
+
+ onMount(() => {
+ editorRef.addEventListener("paste", handlePaste)
+ })
+ onCleanup(() => {
+ editorRef.removeEventListener("paste", handlePaste)
+ })
+
createEffect(() => {
if (isFocused()) {
handleInput()
@@ -144,16 +159,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const rawText = store.contentParts.map((p) => p.content).join("")
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
- if (!atMatch) return
- const startIndex = atMatch.index!
- const endIndex = cursorPosition
+ const startIndex = atMatch ? atMatch.index! : cursorPosition
+ const endIndex = atMatch ? cursorPosition : cursorPosition
+
+ const pushText = (acc: { parts: ContentPart[] }, value: string) => {
+ if (!value) return
+ const last = acc.parts[acc.parts.length - 1]
+ if (last && last.type === "text") {
+ acc.parts[acc.parts.length - 1] = {
+ type: "text",
+ content: last.content + value,
+ }
+ return
+ }
+ acc.parts.push({ type: "text", content: value })
+ }
const {
parts: nextParts,
- cursorIndex,
- cursorOffset,
inserted,
+ cursorPositionAfter,
} = store.contentParts.reduce(
(acc, item) => {
if (acc.inserted) {
@@ -180,17 +206,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const head = item.content.slice(0, headLength)
const tail = item.content.slice(tailLength)
- if (head) acc.parts.push({ type: "text", content: head })
+ pushText(acc, head)
- acc.parts.push(part)
-
- const rest = /^\s/.test(tail) ? tail : ` ${tail}`
- if (rest) {
- acc.cursorIndex = acc.parts.length
- acc.cursorOffset = Math.min(1, rest.length)
- acc.parts.push({ type: "text", content: rest })
+ if (part.type === "text") {
+ pushText(acc, part.content)
+ }
+ if (part.type !== "text") {
+ acc.parts.push({ ...part })
}
+ const needsGap = Boolean(atMatch)
+ const rest = needsGap ? (tail ? (/^\s/.test(tail) ? tail : ` ${tail}`) : " ") : tail
+ pushText(acc, rest)
+
+ const baseCursor = startIndex + part.content.length
+ const cursorAddition = needsGap && rest.length > 0 ? 1 : 0
+ acc.cursorPositionAfter = baseCursor + cursorAddition
+
acc.inserted = true
acc.runningIndex = nextIndex
return acc
@@ -199,29 +231,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
parts: [] as ContentPart[],
runningIndex: 0,
inserted: false,
- cursorIndex: null as number | null,
- cursorOffset: 0,
+ cursorPositionAfter: cursorPosition + part.content.length,
},
)
- if (!inserted || cursorIndex === null) return
+ if (!inserted) {
+ const baseParts = store.contentParts.filter((item) => !(item.type === "text" && item.content === ""))
+ const appendedAcc = { parts: [...baseParts] as ContentPart[] }
+ if (part.type === "text") pushText(appendedAcc, part.content)
+ if (part.type !== "text") appendedAcc.parts.push({ ...part })
+ const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : defaultParts
+ setStore("contentParts", next)
+ setStore("popoverIsOpen", false)
+ const nextCursor = rawText.length + part.content.length
+ queueMicrotask(() => setCursorPosition(editorRef, nextCursor))
+ return
+ }
setStore("contentParts", nextParts)
setStore("popoverIsOpen", false)
- queueMicrotask(() => {
- const node = editorRef.childNodes[cursorIndex]
- if (node && node.nodeType === Node.TEXT_NODE) {
- const range = document.createRange()
- const selection = window.getSelection()
- const length = node.textContent ? node.textContent.length : 0
- const offset = cursorOffset > length ? length : cursorOffset
- range.setStart(node, offset)
- range.collapse(true)
- selection?.removeAllRanges()
- selection?.addRange(range)
- }
- })
+ queueMicrotask(() => setCursorPosition(editorRef, cursorPositionAfter))
}
const handleKeyDown = (event: KeyboardEvent) => {
diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx
index f6ea4cb90..f61e36549 100644
--- a/packages/desktop/src/pages/index.tsx
+++ b/packages/desktop/src/pages/index.tsx
@@ -1,4 +1,4 @@
-import { Button, List, SelectDialog, Tooltip, IconButton, Tabs, Icon } from "@opencode-ai/ui"
+import { Button, List, SelectDialog, Tooltip, IconButton, Tabs, Icon, Accordion } from "@opencode-ai/ui"
import { FileIcon } from "@/ui"
import FileTree from "@/components/file-tree"
import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
@@ -55,7 +55,6 @@ export default function Page() {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
event.preventDefault()
- // TODO: command palette
return
}
if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
@@ -571,7 +570,6 @@ export default function Page() {
<div class="flex flex-col items-start gap-50 pb-[800px]">
<For each={local.session.userMessages()}>
{(message) => {
- console.log(message)
return (
<div
data-message={message.id}
@@ -583,22 +581,55 @@ export default function Page() {
</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>
+ <Show when={message.summary?.diffs.length}>
+ <Accordion class="w-full" multiple>
+ <For each={message.summary?.diffs || []}>
+ {(diff) => (
+ <Accordion.Item value={diff.file}>
+ <Accordion.Header>
+ <Accordion.Trigger>
+ <div class="flex items-center justify-between w-full">
+ <div class="flex items-center gap-5">
+ <FileIcon
+ node={{ path: diff.file, type: "file" }}
+ class="shrink-0 size-4"
+ />
+ <div class="flex">
+ <Show when={diff.file.includes("/")}>
+ <span class="text-text-base">
+ {getDirectory(diff.file)}/
+ </span>
+ </Show>
+ <span class="text-text-strong">{getFilename(diff.file)}</span>
+ </div>
+ </div>
+ <div class="flex gap-4 items-center justify-end">
+ <div class="flex gap-2 justify-end items-center">
+ <span class="text-12-mono text-right text-text-diff-add-base">{`+${diff.additions}`}</span>
+ <span class="text-12-mono text-right text-text-diff-delete-base">{`-${diff.deletions}`}</span>
+ </div>
+ <Icon name="chevron-grabber-vertical" size="small" />
+ </div>
+ </div>
+ </Accordion.Trigger>
+ </Accordion.Header>
+ <Accordion.Content>
+ <Diff
+ before={{
+ name: diff.file!,
+ contents: diff.before!,
+ }}
+ after={{
+ name: diff.file!,
+ contents: diff.after!,
+ }}
+ />
+ </Accordion.Content>
+ </Accordion.Item>
+ )}
+ </For>
+ </Accordion>
+ </Show>
</div>
)
}}