diff options
| author | Jay V <[email protected]> | 2025-05-30 13:58:32 -0400 |
|---|---|---|
| committer | Jay V <[email protected]> | 2025-05-30 13:58:34 -0400 |
| commit | a4e46e6e18140afbf376ef3baa26aa5e90c27d94 (patch) | |
| tree | 5024cfe0c94f650b95183a2781ba05dbeee6506b /app/packages/web/src | |
| parent | 680d52016c69887c331dbbb37de7109158ee9020 (diff) | |
| download | opencode-a4e46e6e18140afbf376ef3baa26aa5e90c27d94.tar.gz opencode-a4e46e6e18140afbf376ef3baa26aa5e90c27d94.zip | |
share page diff
Diffstat (limited to 'app/packages/web/src')
| -rw-r--r-- | app/packages/web/src/components/CodeBlock.tsx | 47 | ||||
| -rw-r--r-- | app/packages/web/src/components/DiffView.tsx | 66 | ||||
| -rw-r--r-- | app/packages/web/src/components/Share.tsx | 227 | ||||
| -rw-r--r-- | app/packages/web/src/components/diffview.module.css | 70 | ||||
| -rw-r--r-- | app/packages/web/src/components/share.module.css | 52 | ||||
| -rw-r--r-- | app/packages/web/src/styles/custom.css | 12 |
6 files changed, 407 insertions, 67 deletions
diff --git a/app/packages/web/src/components/CodeBlock.tsx b/app/packages/web/src/components/CodeBlock.tsx new file mode 100644 index 000000000..17559ece1 --- /dev/null +++ b/app/packages/web/src/components/CodeBlock.tsx @@ -0,0 +1,47 @@ +import { + type JSX, + onCleanup, + splitProps, + createEffect, + createResource, +} from "solid-js" +import { codeToHtml } from "shiki" +import { transformerNotationDiff } from '@shikijs/transformers' + +interface CodeBlockProps extends JSX.HTMLAttributes<HTMLDivElement> { + code: string + lang?: string +} +function CodeBlock(props: CodeBlockProps) { + const [local, rest] = splitProps(props, ["code", "lang"]) + let containerRef!: HTMLDivElement + + const [html] = createResource(async () => { + return (await codeToHtml(local.code, { + lang: local.lang || "text", + themes: { + light: 'github-light', + dark: 'github-dark', + }, + transformers: [ + transformerNotationDiff(), + ], + })) as string + }) + + onCleanup(() => { + if (containerRef) containerRef.innerHTML = "" + }) + + createEffect(() => { + if (html() && containerRef) { + containerRef.innerHTML = html() as string + } + }) + + return ( + <div ref={containerRef} {...rest}></div> + ) +} + +export default CodeBlock diff --git a/app/packages/web/src/components/DiffView.tsx b/app/packages/web/src/components/DiffView.tsx new file mode 100644 index 000000000..443fc6f47 --- /dev/null +++ b/app/packages/web/src/components/DiffView.tsx @@ -0,0 +1,66 @@ +import { type Component, createSignal, onMount } from "solid-js" +import { diffLines, type Change } from "diff" +import CodeBlock from "./CodeBlock" +import styles from "./diffView.module.css" + +type DiffRow = { + left: string + right: string + type: "added" | "removed" | "unchanged" +} + +interface DiffViewProps { + oldCode: string + newCode: string + lang?: string + class?: string +} + +const DiffView: Component<DiffViewProps> = (props) => { + const [rows, setRows] = createSignal<DiffRow[]>([]) + + onMount(() => { + const chunks = diffLines(props.oldCode, props.newCode) + const diffRows: DiffRow[] = [] + + chunks.forEach((chunk: Change) => { + const lines = chunk.value.split(/\r?\n/) + if (lines.at(-1) === "") lines.pop() + + lines.forEach((line) => { + diffRows.push({ + left: chunk.removed ? line : chunk.added ? "" : line, + right: chunk.added ? line : chunk.removed ? "" : line, + type: chunk.added ? "added" + : chunk.removed ? "removed" + : "unchanged", + }) + }) + }) + + setRows(diffRows) + }) + + return ( + <div class={`${styles.diff} ${props.class ?? ""}`}> + {rows().map((r) => ( + <div data-section="row"> + <CodeBlock + code={r.left} + lang={props.lang} + data-section="cell" + data-diff-type={r.type === "removed" ? "removed" : ""} + /> + <CodeBlock + code={r.right} + lang={props.lang} + data-section="cell" + data-diff-type={r.type === "added" ? "added" : ""} + /> + </div> + ))} + </div> + ) +} + +export default DiffView diff --git a/app/packages/web/src/components/Share.tsx b/app/packages/web/src/components/Share.tsx index 12619b600..c9fbb0680 100644 --- a/app/packages/web/src/components/Share.tsx +++ b/app/packages/web/src/components/Share.tsx @@ -6,6 +6,7 @@ import { Switch, onMount, onCleanup, + splitProps, createMemo, createEffect, createSignal, @@ -20,8 +21,13 @@ import { IconCpuChip, IconSparkles, IconUserCircle, + IconChevronDown, + IconChevronRight, + IconPencilSquare, IconWrenchScrewdriver, } from "./icons" +import CodeBlock from "./CodeBlock" +import DiffView from "./DiffView" import styles from "./share.module.css" import { type UIMessage } from "ai" import { createStore, reconcile } from "solid-js/store" @@ -59,6 +65,10 @@ type SessionInfo = { cost?: number } +function getFileType(path: string) { + return path.split('.').pop() +} + // Converts `{a:{b:{c:1}}` to `[['a.b.c', 1]]` function flattenToolArgs(obj: any, prefix: string = ""): Array<[string, any]> { const entries: Array<[string, any]> = []; @@ -111,18 +121,48 @@ function ProviderIcon(props: { provider: string, size?: number }) { ) } +interface ResultsButtonProps extends JSX.HTMLAttributes<HTMLButtonElement> { + results: boolean +} +function ResultsButton(props: ResultsButtonProps) { + const [local, rest] = splitProps(props, ["results"]) + return ( + <button + type="button" + data-element-button-text + data-element-button-more + {...rest} + > + <span> + {local.results ? "Hide results" : "Show results"} + </span> + <span data-button-icon> + <Show + when={local.results} + fallback={ + <IconChevronRight width={10} height={10} /> + } + > + <IconChevronDown width={10} height={10} /> + </Show> + </span> + </button> + ) +} + interface TextPartProps extends JSX.HTMLAttributes<HTMLDivElement> { text: string expand?: boolean highlight?: boolean } -function TextPart({ text, expand, highlight, ...props }: TextPartProps) { +function TextPart(props: TextPartProps) { + const [local, rest] = splitProps(props, ["text", "expand", "highlight"]) const [expanded, setExpanded] = createSignal(false) const [overflowed, setOverflowed] = createSignal(false) let preEl: HTMLPreElement | undefined function checkOverflow() { - if (preEl && !expand) { + if (preEl && !local.expand) { setOverflowed(preEl.scrollHeight > preEl.clientHeight + 1) } } @@ -133,7 +173,7 @@ function TextPart({ text, expand, highlight, ...props }: TextPartProps) { }) createEffect(() => { - text + local.text setTimeout(checkOverflow, 0) }) @@ -144,11 +184,11 @@ function TextPart({ text, expand, highlight, ...props }: TextPartProps) { return ( <div data-element-message-text - data-highlight={highlight} - data-expanded={expanded() || expand === true} - {...props} + data-highlight={local.highlight} + data-expanded={expanded() || local.expand === true} + {...rest} > - <pre ref={el => (preEl = el)}>{text}</pre> + <pre ref={el => (preEl = el)}>{local.text}</pre> {overflowed() && <button type="button" @@ -411,6 +451,7 @@ export default function Share(props: { api: string }) { {(part, partIndex) => { if (part.type === "step-start" && (partIndex() > 0 || !msg.metadata?.assistant)) return null + const [results, showResults] = createSignal(false) const isLastPart = createMemo(() => (messages().length === msgIndex() + 1) && (msg.parts.length === partIndex() + 1) @@ -488,16 +529,18 @@ export default function Share(props: { api: string }) { <div></div> </div> <div data-section="content"> - <span - data-size="md" - data-part-title - data-element-label - > - {assistant().providerID} - </span> - <span data-part-model> - {assistant().modelID} - </span> + <div data-part-tool-body> + <span + data-size="md" + data-part-title + data-element-label + > + {assistant().providerID} + </span> + <span data-part-model> + {assistant().modelID} + </span> + </div> </div> </> } @@ -517,19 +560,59 @@ export default function Share(props: { api: string }) { <div></div> </div> <div data-section="content"> - <span data-element-label data-part-title> - System - </span> - <TextPart - data-size="sm" - text={part().text} - data-color="dimmed" - /> + <div data-part-tool-body> + <span data-element-label data-part-title> + System + </span> + <TextPart + data-size="sm" + text={part().text} + data-color="dimmed" + /> + </div> <PartFooter time={time} /> </div> </> } </Match> + { /* Edit tool */} + <Match when={ + msg.role === "assistant" + && part.type === "tool-invocation" + && part.toolInvocation.toolName === "edit" + && part + }> + {part => { + const args = part().toolInvocation.args + const filePath = args.filePath + return ( + <> + <div data-section="decoration"> + <div> + <IconPencilSquare width={18} height={18} /> + </div> + <div></div> + </div> + <div data-section="content"> + <div data-part-tool-body> + <span data-part-title data-size="md"> + Edit {filePath} + </span> + <div data-part-tool-edit> + <DiffView + class={styles["code-block"]} + oldCode={args.oldString} + newCode={args.newString} + lang={getFileType(filePath)} + /> + </div> + </div> + <PartFooter time={time} /> + </div> + </> + ) + }} + </Match> { /* Tool call */} <Match when={ msg.role === "assistant" @@ -545,44 +628,54 @@ export default function Share(props: { api: string }) { <div></div> </div> <div data-section="content"> - <span data-part-title data-size="md"> - {part().toolInvocation.toolName} - </span> - <div data-part-tool-args> - <For each={ - flattenToolArgs(part().toolInvocation.args) - }> - {([name, value]) => - <> - <div></div> - <div>{name}</div> - <div>{value}</div> - </> - } - </For> + <div data-part-tool-body> + <span data-part-title data-size="md"> + {part().toolInvocation.toolName} + </span> + <div data-part-tool-args> + <For each={ + flattenToolArgs(part().toolInvocation.args) + }> + {([name, value]) => + <> + <div></div> + <div>{name}</div> + <div>{value}</div> + </> + } + </For> + </div> + <Switch> + <Match when={ + part().toolInvocation.state === "result" + && part().toolInvocation.result + }> + <div data-part-tool-result> + <ResultsButton + results={results()} + onClick={() => showResults(e => !e)} + /> + <Show when={results()}> + <TextPart + expand + data-size="sm" + data-color="dimmed" + text={part().toolInvocation.result} + /> + </Show> + </div> + </Match> + <Match when={ + part().toolInvocation.state === "call" + }> + <TextPart + data-size="sm" + data-color="dimmed" + text="Calling..." + /> + </Match> + </Switch> </div> - <Switch> - <Match when={ - part().toolInvocation.state === "result" - && part().toolInvocation.result - }> - <TextPart - data-size="sm" - data-color="dimmed" - text={part().toolInvocation.result} - expand={isLastPart()} - /> - </Match> - <Match when={ - part().toolInvocation.state === "call" - }> - <TextPart - data-size="sm" - data-color="dimmed" - text="Calling..." - /> - </Match> - </Switch> <PartFooter time={time} /> </div> </> @@ -609,10 +702,12 @@ export default function Share(props: { api: string }) { <div></div> </div> <div data-section="content"> - <span data-element-label data-part-title> - {part.type} - </span> - <TextPart text={JSON.stringify(part, null, 2)} /> + <div data-part-tool-body> + <span data-element-label data-part-title> + {part.type} + </span> + <TextPart text={JSON.stringify(part, null, 2)} /> + </div> <PartFooter time={time} /> </div> </Match> diff --git a/app/packages/web/src/components/diffview.module.css b/app/packages/web/src/components/diffview.module.css new file mode 100644 index 000000000..94911d06f --- /dev/null +++ b/app/packages/web/src/components/diffview.module.css @@ -0,0 +1,70 @@ +.diff { + display: grid; + row-gap: 0; + border: 1px solid var(--sl-color-divider); + background-color: var(--sl-color-bg-surface); + border-radius: 0.25rem; + + [data-section="row"] { + display: grid; + grid-template-columns: 1fr 1fr; + + &:first-child [data-section="cell"] { + padding-top: 0.375rem; + } + &:last-child [data-section="cell"] { + padding-bottom: 0.375rem; + } + } + + [data-section="cell"] { + position: relative; + padding-left: 1.5ch; + padding: 0.25rem 0.5rem 0.25rem 1.5ch; + overflow-x: auto; + margin: 0; + + pre { + background-color: var(--sl-color-bg-surface) !important; + } + + &:first-child { + border-right: 1px solid var(--sl-color-divider); + } + } + + [data-diff-type="removed"] { + background-color: var(--sl-color-red-low); + + & > pre { + --shiki-dark-bg: var(--sl-color-red-low) !important; + background-color: transparent !important; + } + + &::before { + content: "-"; + position: absolute; + left: 0.5ch; + user-select: none; + color: var(--sl-color-red-high); + } + } + + [data-diff-type="added"] { + background-color: var(--sl-color-green-low); + + & > pre { + --shiki-dark-bg: var(--sl-color-green-low) !important; + background-color: transparent !important; + } + + &::before { + content: "+"; + position: absolute; + left: 0.6ch; + user-select: none; + color: var(--sl-color-green-high); + } + } +} + diff --git a/app/packages/web/src/components/share.module.css b/app/packages/web/src/components/share.module.css index 1c0681623..e2ebccab2 100644 --- a/app/packages/web/src/components/share.module.css +++ b/app/packages/web/src/components/share.module.css @@ -19,6 +19,33 @@ } } +[data-element-button-text] { + cursor: pointer; + appearance: none; + background-color: transparent; + border: none; + padding: 0; + color: var(--sl-color-text-secondary); + + &:hover { + color: var(--sl-color-text); + } + + &[data-element-button-more] { + display: flex; + align-items: center; + gap: 0.125rem; + + span[data-button-icon] { + line-height: 1; + opacity: 0.85; + svg { + display: block; + } + } + } +} + [data-element-label] { text-transform: uppercase; letter-spacing: 0.05em; @@ -154,7 +181,13 @@ padding: 0 0 0.375rem; display: flex; flex-direction: column; - gap: 0.5rem; + gap: 1rem; + + [data-part-tool-body] { + display: flex; + flex-direction: column; + gap: 0.375rem; + } span[data-part-title] { line-height: 18px; @@ -203,7 +236,17 @@ padding-left: 0.125rem; color: var(--sl-color-text-dimmed); } + } + [data-part-tool-result] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + + button { + font-size: 0.75rem; + } } } } @@ -274,3 +317,10 @@ } } } + +.code-block { + pre { + line-height: 1.4; + font-size: 0.75rem; + } +} diff --git a/app/packages/web/src/styles/custom.css b/app/packages/web/src/styles/custom.css index 450be4319..9c4c71f00 100644 --- a/app/packages/web/src/styles/custom.css +++ b/app/packages/web/src/styles/custom.css @@ -2,3 +2,15 @@ --sl-color-bg-surface: var(--sl-color-bg-nav); --sl-color-divider: var(--sl-color-gray-5); } + +@media (prefers-color-scheme: dark) { + .shiki, + .shiki span { + color: var(--shiki-dark) !important; + background-color: var(--shiki-dark-bg) !important; + /* Optional, if you also want font styles */ + font-style: var(--shiki-dark-font-style) !important; + font-weight: var(--shiki-dark-font-weight) !important; + text-decoration: var(--shiki-dark-text-decoration) !important; + } +} |
