diff --git a/packages/desktop/src/components/diff.tsx b/packages/desktop/src/components/diff.tsx index 4667bbb3..0facaba4 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 = Omit, "themes"> & { before: FileContents after: FileContents + annotations?: DiffLineAnnotation[] + class?: string + classList?: ComponentProps<"div">["classList"] } -export function Diff(props: DiffProps) { +// interface ThreadMetadata { +// threadId: string +// } + +export function Diff(props: DiffProps) { let container!: HTMLDivElement + const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"]) - console.log(props) - - interface ThreadMetadata { - threadId: string - } - - const lineAnnotations: DiffLineAnnotation[] = [ - { - 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({ - // 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): 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[] = [ + // { + // 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({ + 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): 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
+ return ( +
+ ) } diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 47893f44..3838d19b 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 = (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 = (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,16 +206,22 @@ export const PromptInput: Component = (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 @@ -199,29 +231,27 @@ export const PromptInput: Component = (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 f6ea4cb9..f61e3654 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() {
{(message) => { - console.log(message) return (
{message.summary?.text}
-
- - {(diff) => ( - - )} - -
+ + + + {(diff) => ( + + + +
+
+ +
+ + + {getDirectory(diff.file)}/ + + + {getFilename(diff.file)} +
+
+
+
+ {`+${diff.additions}`} + {`-${diff.deletions}`} +
+ +
+
+
+
+ + + +
+ )} +
+
+
) }} diff --git a/packages/ui/src/components/accordion.css b/packages/ui/src/components/accordion.css new file mode 100644 index 00000000..c8dfdfab --- /dev/null +++ b/packages/ui/src/components/accordion.css @@ -0,0 +1,102 @@ +[data-component="accordion"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0px; + align-self: stretch; + border-radius: 8px; + border: 1px solid var(--border-weak-base); + + [data-slot="accordion-item"] { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0px; + align-self: stretch; + + [data-slot="accordion-header"] { + width: 100%; + display: flex; + align-items: center; + margin: 0; + padding: 0; + + [data-slot="accordion-trigger"] { + width: 100%; + display: flex; + height: 40px; + padding: 8px 12px; + justify-content: space-between; + align-items: center; + align-self: stretch; + cursor: default; + user-select: none; + + background-color: var(--surface-base); + border-bottom: 1px solid var(--border-weak-base); + color: var(--text-strong); + transition: background-color 0.15s ease; + + /* text-12-regular */ + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); /* 166.667% */ + letter-spacing: var(--letter-spacing-normal); + + &:hover { + background-color: var(--surface-base); + } + + &:focus-visible { + outline: none; + } + + &[data-disabled] { + cursor: not-allowed; + } + } + } + + &:last-child { + [data-slot="accordion-trigger"] { + border-bottom: none; + } + } + + &[data-expanded] { + border-bottom: 1px solid var(--border-weak-base); + } + + [data-slot="accordion-content"] { + overflow: hidden; + width: 100%; + + /* animation: slideUp 250ms cubic-bezier(0.87, 0, 0.13, 1); */ + /**/ + /* &[data-expanded] { */ + /* animation: slideDown 250ms cubic-bezier(0.87, 0, 0.13, 1); */ + /* } */ + } + } +} + +@keyframes slideDown { + from { + height: 0; + } + to { + height: var(--kb-accordion-content-height); + } +} + +@keyframes slideUp { + from { + height: var(--kb-accordion-content-height); + } + to { + height: 0; + } +} diff --git a/packages/ui/src/components/accordion.tsx b/packages/ui/src/components/accordion.tsx new file mode 100644 index 00000000..535d38e3 --- /dev/null +++ b/packages/ui/src/components/accordion.tsx @@ -0,0 +1,92 @@ +import { Accordion as Kobalte } from "@kobalte/core/accordion" +import { splitProps } from "solid-js" +import type { ComponentProps, ParentProps } from "solid-js" + +export interface AccordionProps extends ComponentProps {} +export interface AccordionItemProps extends ComponentProps {} +export interface AccordionHeaderProps extends ComponentProps {} +export interface AccordionTriggerProps extends ComponentProps {} +export interface AccordionContentProps extends ComponentProps {} + +function AccordionRoot(props: AccordionProps) { + const [split, rest] = splitProps(props, ["class", "classList"]) + return ( + + ) +} + +function AccordionItem(props: AccordionItemProps) { + const [split, rest] = splitProps(props, ["class", "classList"]) + return ( + + ) +} + +function AccordionHeader(props: ParentProps) { + const [split, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {split.children} + + ) +} + +function AccordionTrigger(props: ParentProps) { + const [split, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {split.children} + + ) +} + +function AccordionContent(props: ParentProps) { + const [split, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {split.children} + + ) +} + +export const Accordion = Object.assign(AccordionRoot, { + Item: AccordionItem, + Header: AccordionHeader, + Trigger: AccordionTrigger, + Content: AccordionContent, +}) diff --git a/packages/ui/src/components/collapsible.tsx b/packages/ui/src/components/collapsible.tsx index 011d103c..f926192e 100644 --- a/packages/ui/src/components/collapsible.tsx +++ b/packages/ui/src/components/collapsible.tsx @@ -8,7 +8,6 @@ export interface CollapsibleProps extends ParentProps { function CollapsibleRoot(props: CollapsibleProps) { const [local, others] = splitProps(props, ["class", "classList"]) - return ( `, folder: ``, "pencil-line": ``, + "chevron-grabber-vertical": ``, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 3691363c..31672001 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -1,3 +1,4 @@ +export * from "./accordion" export * from "./button" export * from "./collapsible" export * from "./dialog" diff --git a/packages/ui/src/demo.tsx b/packages/ui/src/demo.tsx index 7c507d7d..79128181 100644 --- a/packages/ui/src/demo.tsx +++ b/packages/ui/src/demo.tsx @@ -1,6 +1,7 @@ import type { Component } from "solid-js" import { createSignal } from "solid-js" import { + Accordion, Button, Select, Tabs, @@ -214,6 +215,41 @@ const Demo: Component = () => { +

Accordion

+
+ + + + What is Kobalte? + + +
+

Kobalte is a UI toolkit for building accessible web apps and design systems with SolidJS.

+
+
+
+ + + Is it accessible? + + +
+

Yes. It adheres to the WAI-ARIA design patterns.

+
+
+
+ + + Can it be animated? + + +
+

Yes! You can animate the content height using CSS animations.

+
+
+
+
+
) diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index dc5335c4..7d426a83 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -5,7 +5,9 @@ @import "./base.css" layer(base); +@import "../components/accordion.css" layer(components); @import "../components/button.css" layer(components); +@import "../components/collapsible.css" layer(components); @import "../components/dialog.css" layer(components); @import "../components/icon.css" layer(components); @import "../components/icon-button.css" layer(components);