wip: desktop work

This commit is contained in:
Adam
2025-10-24 11:23:32 -05:00
parent fe8f6d7a3e
commit 86447b5764
10 changed files with 477 additions and 173 deletions

View File

@@ -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) {
// interface ThreadMetadata {
// threadId: string
// }
export function Diff<T>(props: DiffProps<T>) {
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"])
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
},
})
// 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}
/>
)
}

View File

@@ -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,16 +206,22 @@ 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
@@ -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) => {