mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-03 15:43:45 +00:00
feat(app): better diff/code comments (#14621)
Co-authored-by: adamelmore <2363879+adamdottv@users.noreply.github.com> Co-authored-by: David Hill <iamdavidhill@gmail.com>
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
[data-component="code"] {
|
||||
content-visibility: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,317 +0,0 @@
|
||||
import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
import { Dynamic, isServer } from "solid-js/web"
|
||||
import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { useWorkerPool } from "../context/worker-pool"
|
||||
|
||||
export type SSRDiffProps<T = {}> = DiffProps<T> & {
|
||||
preloadedDiff: PreloadMultiFileDiffResult<T>
|
||||
}
|
||||
|
||||
export function Diff<T>(props: SSRDiffProps<T>) {
|
||||
let container!: HTMLDivElement
|
||||
let fileDiffRef!: HTMLElement
|
||||
const [local, others] = splitProps(props, [
|
||||
"before",
|
||||
"after",
|
||||
"class",
|
||||
"classList",
|
||||
"annotations",
|
||||
"selectedLines",
|
||||
"commentedLines",
|
||||
])
|
||||
const workerPool = useWorkerPool(props.diffStyle)
|
||||
|
||||
let fileDiffInstance: FileDiff<T> | undefined
|
||||
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||
const cleanupFunctions: Array<() => void> = []
|
||||
|
||||
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
|
||||
|
||||
const getVirtualizer = () => {
|
||||
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
|
||||
|
||||
const result = acquireVirtualizer(container)
|
||||
if (!result) return
|
||||
|
||||
sharedVirtualizer = result
|
||||
return result.virtualizer
|
||||
}
|
||||
|
||||
const applyScheme = () => {
|
||||
const scheme = document.documentElement.dataset.colorScheme
|
||||
if (scheme === "dark" || scheme === "light") {
|
||||
fileDiffRef.dataset.colorScheme = scheme
|
||||
return
|
||||
}
|
||||
|
||||
fileDiffRef.removeAttribute("data-color-scheme")
|
||||
}
|
||||
|
||||
const lineIndex = (split: boolean, element: HTMLElement) => {
|
||||
const raw = element.dataset.lineIndex
|
||||
if (!raw) return
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((value) => parseInt(value, 10))
|
||||
.filter((value) => !Number.isNaN(value))
|
||||
if (values.length === 0) return
|
||||
if (!split) return values[0]
|
||||
if (values.length === 2) return values[1]
|
||||
return values[0]
|
||||
}
|
||||
|
||||
const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: "additions" | "deletions" | undefined) => {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (nodes.length === 0) return
|
||||
|
||||
const targetSide = side ?? "additions"
|
||||
|
||||
for (const node of nodes) {
|
||||
if (findSide(node) === targetSide) return lineIndex(split, node)
|
||||
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
|
||||
}
|
||||
}
|
||||
|
||||
const fixSelection = (range: SelectedLineRange | null) => {
|
||||
if (!range) return range
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
|
||||
const start = rowIndex(root, split, range.start, range.side)
|
||||
const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
|
||||
|
||||
if (start === undefined || end === undefined) {
|
||||
if (root.querySelector("[data-line], [data-alt-line]") == null) return
|
||||
return null
|
||||
}
|
||||
if (start <= end) return range
|
||||
|
||||
const side = range.endSide ?? range.side
|
||||
const swapped: SelectedLineRange = {
|
||||
start: range.end,
|
||||
end: range.start,
|
||||
}
|
||||
if (side) swapped.side = side
|
||||
if (range.endSide && range.side) swapped.endSide = range.side
|
||||
|
||||
return swapped
|
||||
}
|
||||
|
||||
const setSelectedLines = (range: SelectedLineRange | null, attempt = 0) => {
|
||||
const diff = fileDiffInstance
|
||||
if (!diff) return
|
||||
|
||||
const fixed = fixSelection(range)
|
||||
if (fixed === undefined) {
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => setSelectedLines(range, attempt + 1))
|
||||
return
|
||||
}
|
||||
|
||||
diff.setSelectedLines(fixed)
|
||||
}
|
||||
|
||||
const findSide = (element: HTMLElement): "additions" | "deletions" => {
|
||||
const line = element.closest("[data-line], [data-alt-line]")
|
||||
if (line instanceof HTMLElement) {
|
||||
const type = line.dataset.lineType
|
||||
if (type === "change-deletion") return "deletions"
|
||||
if (type === "change-addition" || type === "change-additions") return "additions"
|
||||
}
|
||||
|
||||
const code = element.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return "additions"
|
||||
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
|
||||
}
|
||||
|
||||
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
|
||||
for (const node of existing) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.removeAttribute("data-comment-selected")
|
||||
}
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
|
||||
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
|
||||
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
const lineIndex = (element: HTMLElement) => {
|
||||
const raw = element.dataset.lineIndex
|
||||
if (!raw) return
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((value) => parseInt(value, 10))
|
||||
.filter((value) => !Number.isNaN(value))
|
||||
if (values.length === 0) return
|
||||
if (!split) return values[0]
|
||||
if (values.length === 2) return values[1]
|
||||
return values[0]
|
||||
}
|
||||
|
||||
const rowIndex = (line: number, side: "additions" | "deletions" | undefined) => {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (nodes.length === 0) return
|
||||
|
||||
const targetSide = side ?? "additions"
|
||||
|
||||
for (const node of nodes) {
|
||||
if (findSide(node) === targetSide) return lineIndex(node)
|
||||
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node)
|
||||
}
|
||||
}
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = rowIndex(range.start, range.side)
|
||||
if (start === undefined) continue
|
||||
|
||||
const end = (() => {
|
||||
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
|
||||
if (same) return start
|
||||
return rowIndex(range.end, range.endSide ?? range.side)
|
||||
})()
|
||||
if (end === undefined) continue
|
||||
|
||||
const first = Math.min(start, end)
|
||||
const last = Math.max(start, end)
|
||||
|
||||
for (const row of rows) {
|
||||
const idx = lineIndex(row)
|
||||
if (idx === undefined) continue
|
||||
if (idx < first || idx > last) continue
|
||||
row.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
|
||||
if (Number.isNaN(idx)) continue
|
||||
if (idx < first || idx > last) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (isServer || !props.preloadedDiff) return
|
||||
|
||||
applyScheme()
|
||||
|
||||
if (typeof MutationObserver !== "undefined") {
|
||||
const root = document.documentElement
|
||||
const monitor = new MutationObserver(() => applyScheme())
|
||||
monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
|
||||
onCleanup(() => monitor.disconnect())
|
||||
}
|
||||
|
||||
const virtualizer = getVirtualizer()
|
||||
|
||||
fileDiffInstance = virtualizer
|
||||
? new VirtualizedFileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...props.preloadedDiff,
|
||||
},
|
||||
virtualizer,
|
||||
virtualMetrics,
|
||||
workerPool,
|
||||
)
|
||||
: new FileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...props.preloadedDiff,
|
||||
},
|
||||
workerPool,
|
||||
)
|
||||
// @ts-expect-error - fileContainer is private but needed for SSR hydration
|
||||
fileDiffInstance.fileContainer = fileDiffRef
|
||||
fileDiffInstance.hydrate({
|
||||
oldFile: local.before,
|
||||
newFile: local.after,
|
||||
lineAnnotations: local.annotations,
|
||||
fileContainer: fileDiffRef,
|
||||
containerWrapper: container,
|
||||
})
|
||||
|
||||
setSelectedLines(local.selectedLines ?? null)
|
||||
|
||||
createEffect(() => {
|
||||
fileDiffInstance?.setLineAnnotations(local.annotations ?? [])
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setSelectedLines(local.selectedLines ?? null)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const ranges = local.commentedLines ?? []
|
||||
requestAnimationFrame(() => applyCommentedLines(ranges))
|
||||
})
|
||||
|
||||
// Hydrate annotation slots with interactive SolidJS components
|
||||
// if (props.annotations.length > 0 && props.renderAnnotation != null) {
|
||||
// for (const annotation of props.annotations) {
|
||||
// const slotName = `annotation-${annotation.side}-${annotation.lineNumber}`;
|
||||
// const slotElement = fileDiffRef.querySelector(
|
||||
// `[slot="${slotName}"]`
|
||||
// ) as HTMLElement;
|
||||
//
|
||||
// if (slotElement != null) {
|
||||
// // Clear the static server-rendered content from the slot
|
||||
// slotElement.innerHTML = '';
|
||||
//
|
||||
// // Mount a fresh SolidJS component into this slot using render().
|
||||
// // This enables full SolidJS reactivity (signals, effects, etc.)
|
||||
// const dispose = render(
|
||||
// () => props.renderAnnotation!(annotation),
|
||||
// slotElement
|
||||
// );
|
||||
// cleanupFunctions.push(dispose);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
// Clean up FileDiff event handlers and dispose SolidJS components
|
||||
fileDiffInstance?.cleanUp()
|
||||
cleanupFunctions.forEach((dispose) => dispose())
|
||||
sharedVirtualizer?.release()
|
||||
sharedVirtualizer = undefined
|
||||
})
|
||||
|
||||
return (
|
||||
<div data-component="diff" style={styleVariables} ref={container}>
|
||||
<Dynamic component={DIFFS_TAG_NAME} ref={fileDiffRef} id="ssr-diff">
|
||||
<Show when={isServer}>
|
||||
<template shadowrootmode="open" innerHTML={props.preloadedDiff.prerenderedHTML} />
|
||||
</Show>
|
||||
</Dynamic>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,652 +0,0 @@
|
||||
import { sampledChecksum } from "@opencode-ai/util/encode"
|
||||
import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
|
||||
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { getWorkerPool } from "../pierre/worker"
|
||||
|
||||
type SelectionSide = "additions" | "deletions"
|
||||
|
||||
function findElement(node: Node | null): HTMLElement | undefined {
|
||||
if (!node) return
|
||||
if (node instanceof HTMLElement) return node
|
||||
return node.parentElement ?? undefined
|
||||
}
|
||||
|
||||
function findLineNumber(node: Node | null): number | undefined {
|
||||
const element = findElement(node)
|
||||
if (!element) return
|
||||
|
||||
const line = element.closest("[data-line], [data-alt-line]")
|
||||
if (!(line instanceof HTMLElement)) return
|
||||
|
||||
const value = (() => {
|
||||
const primary = parseInt(line.dataset.line ?? "", 10)
|
||||
if (!Number.isNaN(primary)) return primary
|
||||
|
||||
const alt = parseInt(line.dataset.altLine ?? "", 10)
|
||||
if (!Number.isNaN(alt)) return alt
|
||||
})()
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function findSide(node: Node | null): SelectionSide | undefined {
|
||||
const element = findElement(node)
|
||||
if (!element) return
|
||||
|
||||
const line = element.closest("[data-line], [data-alt-line]")
|
||||
if (line instanceof HTMLElement) {
|
||||
const type = line.dataset.lineType
|
||||
if (type === "change-deletion") return "deletions"
|
||||
if (type === "change-addition" || type === "change-additions") return "additions"
|
||||
}
|
||||
|
||||
const code = element.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return
|
||||
|
||||
if (code.hasAttribute("data-deletions")) return "deletions"
|
||||
return "additions"
|
||||
}
|
||||
|
||||
export function Diff<T>(props: DiffProps<T>) {
|
||||
let container!: HTMLDivElement
|
||||
let observer: MutationObserver | undefined
|
||||
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||
let renderToken = 0
|
||||
let selectionFrame: number | undefined
|
||||
let dragFrame: number | undefined
|
||||
let dragStart: number | undefined
|
||||
let dragEnd: number | undefined
|
||||
let dragSide: SelectionSide | undefined
|
||||
let dragEndSide: SelectionSide | undefined
|
||||
let dragMoved = false
|
||||
let lastSelection: SelectedLineRange | null = null
|
||||
let pendingSelectionEnd = false
|
||||
|
||||
const [local, others] = splitProps(props, [
|
||||
"before",
|
||||
"after",
|
||||
"class",
|
||||
"classList",
|
||||
"annotations",
|
||||
"selectedLines",
|
||||
"commentedLines",
|
||||
"onRendered",
|
||||
])
|
||||
|
||||
const mobile = createMediaQuery("(max-width: 640px)")
|
||||
|
||||
const large = createMemo(() => {
|
||||
const before = typeof local.before?.contents === "string" ? local.before.contents : ""
|
||||
const after = typeof local.after?.contents === "string" ? local.after.contents : ""
|
||||
return Math.max(before.length, after.length) > 500_000
|
||||
})
|
||||
|
||||
const largeOptions = {
|
||||
lineDiffType: "none",
|
||||
maxLineDiffLength: 0,
|
||||
tokenizeMaxLineLength: 1,
|
||||
} satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
|
||||
|
||||
const options = createMemo<FileDiffOptions<T>>(() => {
|
||||
const base = {
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
}
|
||||
|
||||
const perf = large() ? { ...base, ...largeOptions } : base
|
||||
if (!mobile()) return perf
|
||||
|
||||
return {
|
||||
...perf,
|
||||
disableLineNumbers: true,
|
||||
}
|
||||
})
|
||||
|
||||
let instance: FileDiff<T> | undefined
|
||||
const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined)
|
||||
const [rendered, setRendered] = createSignal(0)
|
||||
|
||||
const getVirtualizer = () => {
|
||||
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
|
||||
|
||||
const result = acquireVirtualizer(container)
|
||||
if (!result) return
|
||||
|
||||
sharedVirtualizer = result
|
||||
return result.virtualizer
|
||||
}
|
||||
|
||||
const getRoot = () => {
|
||||
const host = container.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
|
||||
const root = host.shadowRoot
|
||||
if (!root) return
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
const applyScheme = () => {
|
||||
const host = container.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
|
||||
const scheme = document.documentElement.dataset.colorScheme
|
||||
if (scheme === "dark" || scheme === "light") {
|
||||
host.dataset.colorScheme = scheme
|
||||
return
|
||||
}
|
||||
|
||||
host.removeAttribute("data-color-scheme")
|
||||
}
|
||||
|
||||
const lineIndex = (split: boolean, element: HTMLElement) => {
|
||||
const raw = element.dataset.lineIndex
|
||||
if (!raw) return
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((value) => parseInt(value, 10))
|
||||
.filter((value) => !Number.isNaN(value))
|
||||
if (values.length === 0) return
|
||||
if (!split) return values[0]
|
||||
if (values.length === 2) return values[1]
|
||||
return values[0]
|
||||
}
|
||||
|
||||
const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: SelectionSide | undefined) => {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (nodes.length === 0) return
|
||||
|
||||
const targetSide = side ?? "additions"
|
||||
|
||||
for (const node of nodes) {
|
||||
if (findSide(node) === targetSide) return lineIndex(split, node)
|
||||
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
|
||||
}
|
||||
}
|
||||
|
||||
const fixSelection = (range: SelectedLineRange | null) => {
|
||||
if (!range) return range
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
|
||||
const start = rowIndex(root, split, range.start, range.side)
|
||||
const end = rowIndex(root, split, range.end, range.endSide ?? range.side)
|
||||
if (start === undefined || end === undefined) {
|
||||
if (root.querySelector("[data-line], [data-alt-line]") == null) return
|
||||
return null
|
||||
}
|
||||
if (start <= end) return range
|
||||
|
||||
const side = range.endSide ?? range.side
|
||||
const swapped: SelectedLineRange = {
|
||||
start: range.end,
|
||||
end: range.start,
|
||||
}
|
||||
|
||||
if (side) swapped.side = side
|
||||
if (range.endSide && range.side) swapped.endSide = range.side
|
||||
|
||||
return swapped
|
||||
}
|
||||
|
||||
const notifyRendered = () => {
|
||||
observer?.disconnect()
|
||||
observer = undefined
|
||||
renderToken++
|
||||
|
||||
const token = renderToken
|
||||
let settle = 0
|
||||
|
||||
const isReady = (root: ShadowRoot) => root.querySelector("[data-line]") != null
|
||||
|
||||
const notify = () => {
|
||||
if (token !== renderToken) return
|
||||
|
||||
observer?.disconnect()
|
||||
observer = undefined
|
||||
requestAnimationFrame(() => {
|
||||
if (token !== renderToken) return
|
||||
setSelectedLines(lastSelection)
|
||||
local.onRendered?.()
|
||||
})
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
settle++
|
||||
const current = settle
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (token !== renderToken) return
|
||||
if (current !== settle) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (token !== renderToken) return
|
||||
if (current !== settle) return
|
||||
|
||||
notify()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const observeRoot = (root: ShadowRoot) => {
|
||||
observer?.disconnect()
|
||||
observer = new MutationObserver(() => {
|
||||
if (token !== renderToken) return
|
||||
if (!isReady(root)) return
|
||||
|
||||
schedule()
|
||||
})
|
||||
|
||||
observer.observe(root, { childList: true, subtree: true })
|
||||
|
||||
if (!isReady(root)) return
|
||||
schedule()
|
||||
}
|
||||
|
||||
const root = getRoot()
|
||||
if (typeof MutationObserver === "undefined") {
|
||||
if (!root || !isReady(root)) return
|
||||
setSelectedLines(lastSelection)
|
||||
local.onRendered?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (root) {
|
||||
observeRoot(root)
|
||||
return
|
||||
}
|
||||
|
||||
observer = new MutationObserver(() => {
|
||||
if (token !== renderToken) return
|
||||
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
observeRoot(root)
|
||||
})
|
||||
|
||||
observer.observe(container, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
|
||||
for (const node of existing) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.removeAttribute("data-comment-selected")
|
||||
}
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
|
||||
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
|
||||
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = rowIndex(root, split, range.start, range.side)
|
||||
if (start === undefined) continue
|
||||
|
||||
const end = (() => {
|
||||
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
|
||||
if (same) return start
|
||||
return rowIndex(root, split, range.end, range.endSide ?? range.side)
|
||||
})()
|
||||
if (end === undefined) continue
|
||||
|
||||
const first = Math.min(start, end)
|
||||
const last = Math.max(start, end)
|
||||
|
||||
for (const row of rows) {
|
||||
const idx = lineIndex(split, row)
|
||||
if (idx === undefined) continue
|
||||
if (idx < first || idx > last) continue
|
||||
row.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
|
||||
if (Number.isNaN(idx)) continue
|
||||
if (idx < first || idx > last) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setSelectedLines = (range: SelectedLineRange | null) => {
|
||||
const active = current()
|
||||
if (!active) return
|
||||
|
||||
const fixed = fixSelection(range)
|
||||
if (fixed === undefined) {
|
||||
lastSelection = range
|
||||
return
|
||||
}
|
||||
|
||||
lastSelection = fixed
|
||||
active.setSelectedLines(fixed)
|
||||
}
|
||||
|
||||
const updateSelection = () => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const selection =
|
||||
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
|
||||
if (!selection || selection.isCollapsed) return
|
||||
|
||||
const domRange =
|
||||
(
|
||||
selection as unknown as {
|
||||
getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[]
|
||||
}
|
||||
).getComposedRanges?.({ shadowRoots: [root] })?.[0] ??
|
||||
(selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
|
||||
|
||||
const startNode = domRange?.startContainer ?? selection.anchorNode
|
||||
const endNode = domRange?.endContainer ?? selection.focusNode
|
||||
if (!startNode || !endNode) return
|
||||
|
||||
if (!root.contains(startNode) || !root.contains(endNode)) return
|
||||
|
||||
const start = findLineNumber(startNode)
|
||||
const end = findLineNumber(endNode)
|
||||
if (start === undefined || end === undefined) return
|
||||
|
||||
const startSide = findSide(startNode)
|
||||
const endSide = findSide(endNode)
|
||||
const side = startSide ?? endSide
|
||||
|
||||
const selected: SelectedLineRange = {
|
||||
start,
|
||||
end,
|
||||
}
|
||||
|
||||
if (side) selected.side = side
|
||||
if (endSide && side && endSide !== side) selected.endSide = endSide
|
||||
|
||||
setSelectedLines(selected)
|
||||
}
|
||||
|
||||
const scheduleSelectionUpdate = () => {
|
||||
if (selectionFrame !== undefined) return
|
||||
|
||||
selectionFrame = requestAnimationFrame(() => {
|
||||
selectionFrame = undefined
|
||||
updateSelection()
|
||||
|
||||
if (!pendingSelectionEnd) return
|
||||
pendingSelectionEnd = false
|
||||
props.onLineSelectionEnd?.(lastSelection)
|
||||
})
|
||||
}
|
||||
|
||||
const updateDragSelection = () => {
|
||||
if (dragStart === undefined || dragEnd === undefined) return
|
||||
|
||||
const selected: SelectedLineRange = {
|
||||
start: dragStart,
|
||||
end: dragEnd,
|
||||
}
|
||||
|
||||
if (dragSide) selected.side = dragSide
|
||||
if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide
|
||||
|
||||
setSelectedLines(selected)
|
||||
}
|
||||
|
||||
const scheduleDragUpdate = () => {
|
||||
if (dragFrame !== undefined) return
|
||||
|
||||
dragFrame = requestAnimationFrame(() => {
|
||||
dragFrame = undefined
|
||||
updateDragSelection()
|
||||
})
|
||||
}
|
||||
|
||||
const lineFromMouseEvent = (event: MouseEvent) => {
|
||||
const path = event.composedPath()
|
||||
|
||||
let numberColumn = false
|
||||
let line: number | undefined
|
||||
let side: SelectionSide | undefined
|
||||
|
||||
for (const item of path) {
|
||||
if (!(item instanceof HTMLElement)) continue
|
||||
|
||||
numberColumn = numberColumn || item.dataset.columnNumber != null
|
||||
|
||||
if (side === undefined) {
|
||||
const type = item.dataset.lineType
|
||||
if (type === "change-deletion") side = "deletions"
|
||||
if (type === "change-addition" || type === "change-additions") side = "additions"
|
||||
}
|
||||
|
||||
if (side === undefined && item.dataset.code != null) {
|
||||
side = item.hasAttribute("data-deletions") ? "deletions" : "additions"
|
||||
}
|
||||
|
||||
if (line === undefined) {
|
||||
const primary = item.dataset.line ? parseInt(item.dataset.line, 10) : Number.NaN
|
||||
if (!Number.isNaN(primary)) {
|
||||
line = primary
|
||||
} else {
|
||||
const alt = item.dataset.altLine ? parseInt(item.dataset.altLine, 10) : Number.NaN
|
||||
if (!Number.isNaN(alt)) line = alt
|
||||
}
|
||||
}
|
||||
|
||||
if (numberColumn && line !== undefined && side !== undefined) break
|
||||
}
|
||||
|
||||
return { line, numberColumn, side }
|
||||
}
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
if (event.button !== 0) return
|
||||
|
||||
const { line, numberColumn, side } = lineFromMouseEvent(event)
|
||||
if (numberColumn) return
|
||||
if (line === undefined) return
|
||||
|
||||
dragStart = line
|
||||
dragEnd = line
|
||||
dragSide = side
|
||||
dragEndSide = side
|
||||
dragMoved = false
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
if (dragStart === undefined) return
|
||||
|
||||
if ((event.buttons & 1) === 0) {
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragSide = undefined
|
||||
dragEndSide = undefined
|
||||
dragMoved = false
|
||||
return
|
||||
}
|
||||
|
||||
const { line, side } = lineFromMouseEvent(event)
|
||||
if (line === undefined) return
|
||||
|
||||
dragEnd = line
|
||||
dragEndSide = side
|
||||
dragMoved = true
|
||||
scheduleDragUpdate()
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
if (dragStart === undefined) return
|
||||
|
||||
if (!dragMoved) {
|
||||
pendingSelectionEnd = false
|
||||
const line = dragStart
|
||||
const selected: SelectedLineRange = {
|
||||
start: line,
|
||||
end: line,
|
||||
}
|
||||
if (dragSide) selected.side = dragSide
|
||||
setSelectedLines(selected)
|
||||
props.onLineSelectionEnd?.(lastSelection)
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragSide = undefined
|
||||
dragEndSide = undefined
|
||||
dragMoved = false
|
||||
return
|
||||
}
|
||||
|
||||
pendingSelectionEnd = true
|
||||
scheduleDragUpdate()
|
||||
scheduleSelectionUpdate()
|
||||
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragSide = undefined
|
||||
dragEndSide = undefined
|
||||
dragMoved = false
|
||||
}
|
||||
|
||||
const handleSelectionChange = () => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
if (dragStart === undefined) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.isCollapsed) return
|
||||
|
||||
scheduleSelectionUpdate()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const opts = options()
|
||||
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
|
||||
const virtualizer = getVirtualizer()
|
||||
const annotations = local.annotations
|
||||
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
|
||||
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
|
||||
|
||||
const cacheKey = (contents: string) => {
|
||||
if (!large()) return sampledChecksum(contents, contents.length)
|
||||
return sampledChecksum(contents)
|
||||
}
|
||||
|
||||
instance?.cleanUp()
|
||||
instance = virtualizer
|
||||
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
|
||||
: new FileDiff<T>(opts, workerPool)
|
||||
setCurrent(instance)
|
||||
|
||||
container.innerHTML = ""
|
||||
instance.render({
|
||||
oldFile: {
|
||||
...local.before,
|
||||
contents: beforeContents,
|
||||
cacheKey: cacheKey(beforeContents),
|
||||
},
|
||||
newFile: {
|
||||
...local.after,
|
||||
contents: afterContents,
|
||||
cacheKey: cacheKey(afterContents),
|
||||
},
|
||||
lineAnnotations: annotations,
|
||||
containerWrapper: container,
|
||||
})
|
||||
|
||||
applyScheme()
|
||||
|
||||
setRendered((value) => value + 1)
|
||||
notifyRendered()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
if (typeof MutationObserver === "undefined") return
|
||||
|
||||
const root = document.documentElement
|
||||
const monitor = new MutationObserver(() => applyScheme())
|
||||
monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
|
||||
applyScheme()
|
||||
|
||||
onCleanup(() => monitor.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
rendered()
|
||||
const ranges = local.commentedLines ?? []
|
||||
requestAnimationFrame(() => applyCommentedLines(ranges))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const selected = local.selectedLines ?? null
|
||||
setSelectedLines(selected)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
|
||||
container.addEventListener("mousedown", handleMouseDown)
|
||||
container.addEventListener("mousemove", handleMouseMove)
|
||||
window.addEventListener("mouseup", handleMouseUp)
|
||||
document.addEventListener("selectionchange", handleSelectionChange)
|
||||
|
||||
onCleanup(() => {
|
||||
container.removeEventListener("mousedown", handleMouseDown)
|
||||
container.removeEventListener("mousemove", handleMouseMove)
|
||||
window.removeEventListener("mouseup", handleMouseUp)
|
||||
document.removeEventListener("selectionchange", handleSelectionChange)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
observer?.disconnect()
|
||||
|
||||
if (selectionFrame !== undefined) {
|
||||
cancelAnimationFrame(selectionFrame)
|
||||
selectionFrame = undefined
|
||||
}
|
||||
|
||||
if (dragFrame !== undefined) {
|
||||
cancelAnimationFrame(dragFrame)
|
||||
dragFrame = undefined
|
||||
}
|
||||
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragSide = undefined
|
||||
dragEndSide = undefined
|
||||
dragMoved = false
|
||||
lastSelection = null
|
||||
pendingSelectionEnd = false
|
||||
|
||||
instance?.cleanUp()
|
||||
setCurrent(undefined)
|
||||
sharedVirtualizer?.release()
|
||||
sharedVirtualizer = undefined
|
||||
})
|
||||
|
||||
return <div data-component="diff" style={styleVariables} ref={container} />
|
||||
}
|
||||
265
packages/ui/src/components/file-media.tsx
Normal file
265
packages/ui/src/components/file-media.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
import { createEffect, createMemo, createResource, Match, on, Show, Switch, type JSX } from "solid-js"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import {
|
||||
dataUrlFromMediaValue,
|
||||
hasMediaValue,
|
||||
isBinaryContent,
|
||||
mediaKindFromPath,
|
||||
normalizeMimeType,
|
||||
svgTextFromValue,
|
||||
} from "../pierre/media"
|
||||
|
||||
export type FileMediaOptions = {
|
||||
mode?: "auto" | "off"
|
||||
path?: string
|
||||
current?: unknown
|
||||
before?: unknown
|
||||
after?: unknown
|
||||
readFile?: (path: string) => Promise<FileContent | undefined>
|
||||
onLoad?: () => void
|
||||
onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
|
||||
}
|
||||
|
||||
function mediaValue(cfg: FileMediaOptions, mode: "image" | "audio") {
|
||||
if (cfg.current !== undefined) return cfg.current
|
||||
if (mode === "image") return cfg.after ?? cfg.before
|
||||
return cfg.after ?? cfg.before
|
||||
}
|
||||
|
||||
export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX.Element }) {
|
||||
const i18n = useI18n()
|
||||
const cfg = () => props.media
|
||||
const kind = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || media.mode === "off") return
|
||||
return mediaKindFromPath(media.path)
|
||||
})
|
||||
|
||||
const isBinary = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || media.mode === "off") return false
|
||||
if (kind()) return false
|
||||
return isBinaryContent(media.current as any)
|
||||
})
|
||||
|
||||
const onLoad = () => props.media?.onLoad?.()
|
||||
|
||||
const deleted = createMemo(() => {
|
||||
const media = cfg()
|
||||
const k = kind()
|
||||
if (!media || !k) return false
|
||||
if (k === "svg") return false
|
||||
if (media.current !== undefined) return false
|
||||
return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any)
|
||||
})
|
||||
|
||||
const direct = createMemo(() => {
|
||||
const media = cfg()
|
||||
const k = kind()
|
||||
if (!media || (k !== "image" && k !== "audio")) return
|
||||
return dataUrlFromMediaValue(mediaValue(media, k), k)
|
||||
})
|
||||
|
||||
const request = createMemo(() => {
|
||||
const media = cfg()
|
||||
const k = kind()
|
||||
if (!media || (k !== "image" && k !== "audio")) return
|
||||
if (media.current !== undefined) return
|
||||
if (deleted()) return
|
||||
if (direct()) return
|
||||
if (!media.path || !media.readFile) return
|
||||
|
||||
return {
|
||||
key: `${k}:${media.path}`,
|
||||
kind: k,
|
||||
path: media.path,
|
||||
readFile: media.readFile,
|
||||
onError: media.onError,
|
||||
}
|
||||
})
|
||||
|
||||
const [loaded] = createResource(request, async (input) => {
|
||||
return input.readFile(input.path).then(
|
||||
(result) => {
|
||||
const src = dataUrlFromMediaValue(result as any, input.kind)
|
||||
if (!src) {
|
||||
input.onError?.({ kind: input.kind })
|
||||
return { key: input.key, error: true as const }
|
||||
}
|
||||
|
||||
return {
|
||||
key: input.key,
|
||||
src,
|
||||
mime: input.kind === "audio" ? normalizeMimeType(result?.mimeType) : undefined,
|
||||
}
|
||||
},
|
||||
() => {
|
||||
input.onError?.({ kind: input.kind })
|
||||
return { key: input.key, error: true as const }
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const remote = createMemo(() => {
|
||||
const input = request()
|
||||
const value = loaded()
|
||||
if (!input || !value || value.key !== input.key) return
|
||||
return value
|
||||
})
|
||||
|
||||
const src = createMemo(() => {
|
||||
const value = remote()
|
||||
return direct() ?? (value && "src" in value ? value.src : undefined)
|
||||
})
|
||||
const status = createMemo(() => {
|
||||
if (direct()) return "ready" as const
|
||||
if (!request()) return "idle" as const
|
||||
if (loaded.loading) return "loading" as const
|
||||
if (remote()?.error) return "error" as const
|
||||
if (src()) return "ready" as const
|
||||
return "idle" as const
|
||||
})
|
||||
const audioMime = createMemo(() => {
|
||||
const value = remote()
|
||||
return value && "mime" in value ? value.mime : undefined
|
||||
})
|
||||
|
||||
const svgSource = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || kind() !== "svg") return
|
||||
return svgTextFromValue(media.current as any)
|
||||
})
|
||||
const svgSrc = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || kind() !== "svg") return
|
||||
return dataUrlFromMediaValue(media.current as any, "svg")
|
||||
})
|
||||
const svgInvalid = createMemo(() => {
|
||||
const media = cfg()
|
||||
if (!media || kind() !== "svg") return
|
||||
if (svgSource() !== undefined) return
|
||||
if (!hasMediaValue(media.current as any)) return
|
||||
return [media.path, media.current] as const
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
svgInvalid,
|
||||
(value) => {
|
||||
if (!value) return
|
||||
cfg()?.onError?.({ kind: "svg" })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const kindLabel = (value: "image" | "audio") =>
|
||||
i18n.t(value === "image" ? "ui.fileMedia.kind.image" : "ui.fileMedia.kind.audio")
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={kind() === "image" || kind() === "audio"}>
|
||||
<Show
|
||||
when={src()}
|
||||
fallback={(() => {
|
||||
const media = cfg()
|
||||
const k = kind()
|
||||
if (!media || (k !== "image" && k !== "audio")) return props.fallback()
|
||||
const label = kindLabel(k)
|
||||
|
||||
if (deleted()) {
|
||||
return (
|
||||
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
|
||||
{i18n.t("ui.fileMedia.state.removed", { kind: label })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status() === "loading") {
|
||||
return (
|
||||
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
|
||||
{i18n.t("ui.fileMedia.state.loading", { kind: label })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status() === "error") {
|
||||
return (
|
||||
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
|
||||
{i18n.t("ui.fileMedia.state.error", { kind: label })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
|
||||
{i18n.t("ui.fileMedia.state.unavailable", { kind: label })}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
>
|
||||
{(value) => {
|
||||
const k = kind()
|
||||
if (k !== "image" && k !== "audio") return props.fallback()
|
||||
if (k === "image") {
|
||||
return (
|
||||
<div class="flex justify-center bg-background-stronger px-6 py-4">
|
||||
<img
|
||||
src={value()}
|
||||
alt={cfg()?.path}
|
||||
class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex justify-center bg-background-stronger px-6 py-4">
|
||||
<audio class="w-full max-w-xl" controls preload="metadata" onLoadedMetadata={onLoad}>
|
||||
<source src={value()} type={audioMime()} />
|
||||
</audio>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={kind() === "svg"}>
|
||||
{(() => {
|
||||
if (svgSource() === undefined && svgSrc() == null) return props.fallback()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-4 px-6 py-4">
|
||||
<Show when={svgSource() !== undefined}>{props.fallback()}</Show>
|
||||
<Show when={svgSrc()}>
|
||||
{(value) => (
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src={value()}
|
||||
alt={cfg()?.path}
|
||||
class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Match>
|
||||
<Match when={isBinary()}>
|
||||
<div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 py-10 text-center">
|
||||
<div class="text-14-semibold text-text-strong">
|
||||
{cfg()?.path?.split("/").pop() ?? i18n.t("ui.fileMedia.binary.title")}
|
||||
</div>
|
||||
<div class="text-14-regular text-text-weak">
|
||||
{(() => {
|
||||
const path = cfg()?.path
|
||||
if (!path) return i18n.t("ui.fileMedia.binary.description.default")
|
||||
return i18n.t("ui.fileMedia.binary.description.path", { path })
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>{props.fallback()}</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
69
packages/ui/src/components/file-search.tsx
Normal file
69
packages/ui/src/components/file-search.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Portal } from "solid-js/web"
|
||||
import { Icon } from "./icon"
|
||||
|
||||
export function FileSearchBar(props: {
|
||||
pos: () => { top: number; right: number }
|
||||
query: () => string
|
||||
index: () => number
|
||||
count: () => number
|
||||
setInput: (el: HTMLInputElement) => void
|
||||
onInput: (value: string) => void
|
||||
onKeyDown: (event: KeyboardEvent) => void
|
||||
onClose: () => void
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
}) {
|
||||
return (
|
||||
<Portal>
|
||||
<div
|
||||
class="fixed z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
|
||||
style={{
|
||||
top: `${props.pos().top}px`,
|
||||
right: `${props.pos().right}px`,
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
|
||||
<input
|
||||
ref={props.setInput}
|
||||
placeholder="Find"
|
||||
value={props.query()}
|
||||
class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
|
||||
onInput={(e) => props.onInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => props.onKeyDown(e as KeyboardEvent)}
|
||||
/>
|
||||
<div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
|
||||
{props.count() ? `${props.index() + 1}/${props.count()}` : "0/0"}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
|
||||
disabled={props.count() === 0}
|
||||
aria-label="Previous match"
|
||||
onClick={props.onPrev}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" class="rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
|
||||
disabled={props.count() === 0}
|
||||
aria-label="Next match"
|
||||
onClick={props.onNext}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
|
||||
aria-label="Close search"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<Icon name="close-small" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
178
packages/ui/src/components/file-ssr.tsx
Normal file
178
packages/ui/src/components/file-ssr.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
|
||||
import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
import { Dynamic, isServer } from "solid-js/web"
|
||||
import { useWorkerPool } from "../context/worker-pool"
|
||||
import { createDefaultOptions, styleVariables } from "../pierre"
|
||||
import { markCommentedDiffLines } from "../pierre/commented-lines"
|
||||
import { fixDiffSelection } from "../pierre/diff-selection"
|
||||
import {
|
||||
applyViewerScheme,
|
||||
clearReadyWatcher,
|
||||
createReadyWatcher,
|
||||
notifyShadowReady,
|
||||
observeViewerScheme,
|
||||
} from "../pierre/file-runtime"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { File, type DiffFileProps, type FileProps } from "./file"
|
||||
|
||||
type SSRDiffFileProps<T> = DiffFileProps<T> & {
|
||||
preloadedDiff: PreloadMultiFileDiffResult<T>
|
||||
}
|
||||
|
||||
function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
|
||||
let container!: HTMLDivElement
|
||||
let fileDiffRef!: HTMLElement
|
||||
let fileDiffInstance: FileDiff<T> | undefined
|
||||
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||
|
||||
const ready = createReadyWatcher()
|
||||
const workerPool = useWorkerPool(props.diffStyle)
|
||||
|
||||
const [local, others] = splitProps(props, [
|
||||
"mode",
|
||||
"media",
|
||||
"before",
|
||||
"after",
|
||||
"class",
|
||||
"classList",
|
||||
"annotations",
|
||||
"selectedLines",
|
||||
"commentedLines",
|
||||
"onLineSelected",
|
||||
"onLineSelectionEnd",
|
||||
"onLineNumberSelectionEnd",
|
||||
"onRendered",
|
||||
"preloadedDiff",
|
||||
])
|
||||
|
||||
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
|
||||
|
||||
const getVirtualizer = () => {
|
||||
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
|
||||
const result = acquireVirtualizer(container)
|
||||
if (!result) return
|
||||
sharedVirtualizer = result
|
||||
return result.virtualizer
|
||||
}
|
||||
|
||||
const setSelectedLines = (range: DiffFileProps<T>["selectedLines"], attempt = 0) => {
|
||||
const diff = fileDiffInstance
|
||||
if (!diff) return
|
||||
|
||||
const fixed = fixDiffSelection(getRoot(), range ?? null)
|
||||
if (fixed === undefined) {
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => setSelectedLines(range ?? null, attempt + 1))
|
||||
return
|
||||
}
|
||||
|
||||
diff.setSelectedLines(fixed)
|
||||
}
|
||||
|
||||
const notifyRendered = () => {
|
||||
notifyShadowReady({
|
||||
state: ready,
|
||||
container,
|
||||
getRoot,
|
||||
isReady: (root) => root.querySelector("[data-line]") != null,
|
||||
settleFrames: 1,
|
||||
onReady: () => {
|
||||
setSelectedLines(local.selectedLines ?? null)
|
||||
local.onRendered?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (isServer) return
|
||||
|
||||
onCleanup(observeViewerScheme(() => fileDiffRef))
|
||||
|
||||
const virtualizer = getVirtualizer()
|
||||
fileDiffInstance = virtualizer
|
||||
? new VirtualizedFileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...local.preloadedDiff,
|
||||
},
|
||||
virtualizer,
|
||||
virtualMetrics,
|
||||
workerPool,
|
||||
)
|
||||
: new FileDiff<T>(
|
||||
{
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
...local.preloadedDiff,
|
||||
},
|
||||
workerPool,
|
||||
)
|
||||
|
||||
applyViewerScheme(fileDiffRef)
|
||||
|
||||
// @ts-expect-error private field required for hydration
|
||||
fileDiffInstance.fileContainer = fileDiffRef
|
||||
fileDiffInstance.hydrate({
|
||||
oldFile: local.before,
|
||||
newFile: local.after,
|
||||
lineAnnotations: local.annotations ?? [],
|
||||
fileContainer: fileDiffRef,
|
||||
containerWrapper: container,
|
||||
})
|
||||
|
||||
notifyRendered()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const diff = fileDiffInstance
|
||||
if (!diff) return
|
||||
diff.setLineAnnotations(local.annotations ?? [])
|
||||
diff.rerender()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setSelectedLines(local.selectedLines ?? null)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const ranges = local.commentedLines ?? []
|
||||
requestAnimationFrame(() => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
markCommentedDiffLines(root, ranges)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearReadyWatcher(ready)
|
||||
fileDiffInstance?.cleanUp()
|
||||
sharedVirtualizer?.release()
|
||||
sharedVirtualizer = undefined
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="file"
|
||||
data-mode="diff"
|
||||
style={styleVariables}
|
||||
class={local.class}
|
||||
classList={local.classList}
|
||||
ref={container}
|
||||
>
|
||||
<Dynamic component={DIFFS_TAG_NAME} ref={fileDiffRef} id="ssr-diff">
|
||||
<Show when={isServer}>
|
||||
<template shadowrootmode="open" innerHTML={local.preloadedDiff.prerenderedHTML} />
|
||||
</Show>
|
||||
</Dynamic>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type FileSSRProps<T = {}> = FileProps<T>
|
||||
|
||||
export function FileSSR<T>(props: FileSSRProps<T>) {
|
||||
if (props.mode !== "diff" || !props.preloadedDiff) return File(props)
|
||||
return DiffSSRViewer(props as SSRDiffFileProps<T>)
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
[data-component="diff"] {
|
||||
[data-component="file"] {
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
[data-component="file"][data-mode="text"] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="file"][data-mode="diff"] {
|
||||
[data-slot="diff-hunk-separator-line-number"] {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
@@ -17,6 +23,7 @@
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="diff-hunk-separator-content"] {
|
||||
position: sticky;
|
||||
background-color: var(--surface-diff-hidden-base);
|
||||
1176
packages/ui/src/components/file.tsx
Normal file
1176
packages/ui/src/components/file.tsx
Normal file
File diff suppressed because it is too large
Load Diff
586
packages/ui/src/components/line-comment-annotations.tsx
Normal file
586
packages/ui/src/components/line-comment-annotations.tsx
Normal file
@@ -0,0 +1,586 @@
|
||||
import { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { render as renderSolid } from "solid-js/web"
|
||||
import { createHoverCommentUtility } from "../pierre/comment-hover"
|
||||
import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
|
||||
import { LineComment, LineCommentEditor } from "./line-comment"
|
||||
|
||||
export type LineCommentAnnotationMeta<T> =
|
||||
| { kind: "comment"; key: string; comment: T }
|
||||
| { kind: "draft"; key: string; range: SelectedLineRange }
|
||||
|
||||
export type LineCommentAnnotation<T> = {
|
||||
lineNumber: number
|
||||
side?: "additions" | "deletions"
|
||||
metadata: LineCommentAnnotationMeta<T>
|
||||
}
|
||||
|
||||
type LineCommentAnnotationsProps<T> = {
|
||||
comments: Accessor<T[]>
|
||||
getCommentId: (comment: T) => string
|
||||
getCommentSelection: (comment: T) => SelectedLineRange
|
||||
draftRange: Accessor<SelectedLineRange | null>
|
||||
draftKey: Accessor<string>
|
||||
}
|
||||
|
||||
type LineCommentAnnotationsWithSideProps<T> = LineCommentAnnotationsProps<T> & {
|
||||
getSide: (range: SelectedLineRange) => "additions" | "deletions"
|
||||
}
|
||||
|
||||
type HoverCommentLine = {
|
||||
lineNumber: number
|
||||
side?: "additions" | "deletions"
|
||||
}
|
||||
|
||||
type LineCommentStateProps<T> = {
|
||||
opened: Accessor<T | null>
|
||||
setOpened: (id: T | null) => void
|
||||
selected: Accessor<SelectedLineRange | null>
|
||||
setSelected: (range: SelectedLineRange | null) => void
|
||||
commenting: Accessor<SelectedLineRange | null>
|
||||
setCommenting: (range: SelectedLineRange | null) => void
|
||||
syncSelected?: (range: SelectedLineRange | null) => void
|
||||
hoverSelected?: (range: SelectedLineRange) => void
|
||||
}
|
||||
|
||||
type LineCommentShape = {
|
||||
id: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
}
|
||||
|
||||
type LineCommentControllerProps<T extends LineCommentShape> = {
|
||||
comments: Accessor<T[]>
|
||||
draftKey: Accessor<string>
|
||||
label: string
|
||||
state: LineCommentStateProps<string>
|
||||
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
|
||||
onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
|
||||
onDelete?: (comment: T) => void
|
||||
renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element
|
||||
editSubmitLabel?: string
|
||||
onDraftPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
|
||||
getHoverSelectedRange?: Accessor<SelectedLineRange | null>
|
||||
cancelDraftOnCommentToggle?: boolean
|
||||
clearSelectionOnSelectionEndNull?: boolean
|
||||
}
|
||||
|
||||
type LineCommentControllerWithSideProps<T extends LineCommentShape> = LineCommentControllerProps<T> & {
|
||||
getSide: (range: SelectedLineRange) => "additions" | "deletions"
|
||||
}
|
||||
|
||||
type CommentProps = {
|
||||
id?: string
|
||||
open: boolean
|
||||
comment: JSX.Element
|
||||
selection: JSX.Element
|
||||
actions?: JSX.Element
|
||||
editor?: DraftProps
|
||||
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
}
|
||||
|
||||
type DraftProps = {
|
||||
value: string
|
||||
selection: JSX.Element
|
||||
onInput: (value: string) => void
|
||||
onCancel: VoidFunction
|
||||
onSubmit: (value: string) => void
|
||||
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
|
||||
cancelLabel?: string
|
||||
submitLabel?: string
|
||||
}
|
||||
|
||||
export function createLineCommentAnnotationRenderer<T>(props: {
|
||||
renderComment: (comment: T) => CommentProps
|
||||
renderDraft: (range: SelectedLineRange) => DraftProps
|
||||
}) {
|
||||
const nodes = new Map<
|
||||
string,
|
||||
{
|
||||
host: HTMLDivElement
|
||||
dispose: VoidFunction
|
||||
setMeta: (meta: LineCommentAnnotationMeta<T>) => void
|
||||
}
|
||||
>()
|
||||
|
||||
const mount = (meta: LineCommentAnnotationMeta<T>) => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const host = document.createElement("div")
|
||||
host.setAttribute("data-prevent-autofocus", "")
|
||||
const [current, setCurrent] = createSignal(meta)
|
||||
|
||||
const dispose = renderSolid(() => {
|
||||
const active = current()
|
||||
if (active.kind === "comment") {
|
||||
const view = createMemo(() => {
|
||||
const next = current()
|
||||
if (next.kind !== "comment") return props.renderComment(active.comment)
|
||||
return props.renderComment(next.comment)
|
||||
})
|
||||
return (
|
||||
<Show
|
||||
when={view().editor}
|
||||
fallback={
|
||||
<LineComment
|
||||
inline
|
||||
id={view().id}
|
||||
open={view().open}
|
||||
comment={view().comment}
|
||||
selection={view().selection}
|
||||
actions={view().actions}
|
||||
onClick={view().onClick}
|
||||
onMouseEnter={view().onMouseEnter}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LineCommentEditor
|
||||
inline
|
||||
id={view().id}
|
||||
value={view().editor!.value}
|
||||
selection={view().editor!.selection}
|
||||
onInput={view().editor!.onInput}
|
||||
onCancel={view().editor!.onCancel}
|
||||
onSubmit={view().editor!.onSubmit}
|
||||
onPopoverFocusOut={view().editor!.onPopoverFocusOut}
|
||||
cancelLabel={view().editor!.cancelLabel}
|
||||
submitLabel={view().editor!.submitLabel}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const view = createMemo(() => {
|
||||
const next = current()
|
||||
if (next.kind !== "draft") return props.renderDraft(active.range)
|
||||
return props.renderDraft(next.range)
|
||||
})
|
||||
return (
|
||||
<LineCommentEditor
|
||||
inline
|
||||
value={view().value}
|
||||
selection={view().selection}
|
||||
onInput={view().onInput}
|
||||
onCancel={view().onCancel}
|
||||
onSubmit={view().onSubmit}
|
||||
onPopoverFocusOut={view().onPopoverFocusOut}
|
||||
/>
|
||||
)
|
||||
}, host)
|
||||
|
||||
const node = { host, dispose, setMeta: setCurrent }
|
||||
nodes.set(meta.key, node)
|
||||
return node
|
||||
}
|
||||
|
||||
const render = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotation: A) => {
|
||||
const meta = annotation.metadata
|
||||
const node = nodes.get(meta.key) ?? mount(meta)
|
||||
if (!node) return
|
||||
node.setMeta(meta)
|
||||
return node.host
|
||||
}
|
||||
|
||||
const reconcile = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotations: A[]) => {
|
||||
const next = new Set(annotations.map((annotation) => annotation.metadata.key))
|
||||
for (const [key, node] of nodes) {
|
||||
if (next.has(key)) continue
|
||||
node.dispose()
|
||||
nodes.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
for (const [, node] of nodes) node.dispose()
|
||||
nodes.clear()
|
||||
}
|
||||
|
||||
return { render, reconcile, cleanup }
|
||||
}
|
||||
|
||||
export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
|
||||
const [draft, setDraft] = createSignal("")
|
||||
const [editing, setEditing] = createSignal<T | null>(null)
|
||||
|
||||
const toRange = (range: SelectedLineRange | null) => (range ? cloneSelectedLineRange(range) : null)
|
||||
const setSelected = (range: SelectedLineRange | null) => {
|
||||
const next = toRange(range)
|
||||
props.setSelected(next)
|
||||
props.syncSelected?.(toRange(next))
|
||||
return next
|
||||
}
|
||||
|
||||
const setCommenting = (range: SelectedLineRange | null) => {
|
||||
const next = toRange(range)
|
||||
props.setCommenting(next)
|
||||
return next
|
||||
}
|
||||
|
||||
const closeComment = () => {
|
||||
props.setOpened(null)
|
||||
}
|
||||
|
||||
const cancelDraft = () => {
|
||||
setDraft("")
|
||||
setEditing(null)
|
||||
setCommenting(null)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setDraft("")
|
||||
setEditing(null)
|
||||
props.setOpened(null)
|
||||
props.setSelected(null)
|
||||
props.setCommenting(null)
|
||||
}
|
||||
|
||||
const openComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => {
|
||||
if (options?.cancelDraft) cancelDraft()
|
||||
props.setOpened(id)
|
||||
setSelected(range)
|
||||
}
|
||||
|
||||
const toggleComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => {
|
||||
if (options?.cancelDraft) cancelDraft()
|
||||
const next = props.opened() === id ? null : id
|
||||
props.setOpened(next)
|
||||
setSelected(range)
|
||||
}
|
||||
|
||||
const openDraft = (range: SelectedLineRange) => {
|
||||
const next = toRange(range)
|
||||
setDraft("")
|
||||
setEditing(null)
|
||||
closeComment()
|
||||
setSelected(next)
|
||||
setCommenting(next)
|
||||
}
|
||||
|
||||
const openEditor = (id: T, range: SelectedLineRange, value: string) => {
|
||||
closeComment()
|
||||
setSelected(range)
|
||||
props.setCommenting(null)
|
||||
setEditing(() => id)
|
||||
setDraft(value)
|
||||
}
|
||||
|
||||
const hoverComment = (range: SelectedLineRange) => {
|
||||
const next = toRange(range)
|
||||
if (!next) return
|
||||
if (props.hoverSelected) {
|
||||
props.hoverSelected(next)
|
||||
return
|
||||
}
|
||||
|
||||
setSelected(next)
|
||||
}
|
||||
|
||||
const finishSelection = (range: SelectedLineRange) => {
|
||||
closeComment()
|
||||
setSelected(range)
|
||||
cancelDraft()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
props.commenting()
|
||||
setDraft("")
|
||||
})
|
||||
|
||||
return {
|
||||
draft,
|
||||
setDraft,
|
||||
editing,
|
||||
opened: props.opened,
|
||||
selected: props.selected,
|
||||
commenting: props.commenting,
|
||||
isOpen: (id: T) => props.opened() === id,
|
||||
isEditing: (id: T) => editing() === id,
|
||||
closeComment,
|
||||
openComment,
|
||||
toggleComment,
|
||||
openDraft,
|
||||
openEditor,
|
||||
hoverComment,
|
||||
cancelDraft,
|
||||
finishSelection,
|
||||
select: setSelected,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export function createLineCommentController<T extends LineCommentShape>(
|
||||
props: LineCommentControllerWithSideProps<T>,
|
||||
): {
|
||||
note: ReturnType<typeof createLineCommentState<string>>
|
||||
annotations: Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
|
||||
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
|
||||
renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
|
||||
onLineSelected: (range: SelectedLineRange | null) => void
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
}
|
||||
export function createLineCommentController<T extends LineCommentShape>(
|
||||
props: LineCommentControllerProps<T>,
|
||||
): {
|
||||
note: ReturnType<typeof createLineCommentState<string>>
|
||||
annotations: Accessor<LineCommentAnnotation<T>[]>
|
||||
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
|
||||
renderHoverUtility: ReturnType<typeof createLineCommentHoverRenderer>
|
||||
onLineSelected: (range: SelectedLineRange | null) => void
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
onLineNumberSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
}
|
||||
export function createLineCommentController<T extends LineCommentShape>(
|
||||
props: LineCommentControllerProps<T> | LineCommentControllerWithSideProps<T>,
|
||||
) {
|
||||
const note = createLineCommentState<string>(props.state)
|
||||
|
||||
const annotations =
|
||||
"getSide" in props
|
||||
? createLineCommentAnnotations({
|
||||
comments: props.comments,
|
||||
getCommentId: (comment) => comment.id,
|
||||
getCommentSelection: (comment) => comment.selection,
|
||||
draftRange: note.commenting,
|
||||
draftKey: props.draftKey,
|
||||
getSide: props.getSide,
|
||||
})
|
||||
: createLineCommentAnnotations({
|
||||
comments: props.comments,
|
||||
getCommentId: (comment) => comment.id,
|
||||
getCommentSelection: (comment) => comment.selection,
|
||||
draftRange: note.commenting,
|
||||
draftKey: props.draftKey,
|
||||
})
|
||||
|
||||
const { renderAnnotation } = createManagedLineCommentAnnotationRenderer<T>({
|
||||
annotations,
|
||||
renderComment: (comment) => {
|
||||
const edit = () => note.openEditor(comment.id, comment.selection, comment.comment)
|
||||
const remove = () => {
|
||||
note.reset()
|
||||
props.onDelete?.(comment)
|
||||
}
|
||||
|
||||
return {
|
||||
id: comment.id,
|
||||
get open() {
|
||||
return note.isOpen(comment.id) || note.isEditing(comment.id)
|
||||
},
|
||||
comment: comment.comment,
|
||||
selection: formatSelectedLineLabel(comment.selection),
|
||||
get actions() {
|
||||
return props.renderCommentActions?.(comment, { edit, remove })
|
||||
},
|
||||
get editor() {
|
||||
return note.isEditing(comment.id)
|
||||
? {
|
||||
get value() {
|
||||
return note.draft()
|
||||
},
|
||||
selection: formatSelectedLineLabel(comment.selection),
|
||||
onInput: note.setDraft,
|
||||
onCancel: note.cancelDraft,
|
||||
onSubmit: (value: string) => {
|
||||
props.onUpdate?.({
|
||||
id: comment.id,
|
||||
comment: value,
|
||||
selection: cloneSelectedLineRange(comment.selection),
|
||||
})
|
||||
note.cancelDraft()
|
||||
},
|
||||
submitLabel: props.editSubmitLabel,
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
onMouseEnter: () => note.hoverComment(comment.selection),
|
||||
onClick: () => {
|
||||
if (note.isEditing(comment.id)) return
|
||||
note.toggleComment(comment.id, comment.selection, { cancelDraft: props.cancelDraftOnCommentToggle })
|
||||
},
|
||||
}
|
||||
},
|
||||
renderDraft: (range) => ({
|
||||
get value() {
|
||||
return note.draft()
|
||||
},
|
||||
selection: formatSelectedLineLabel(range),
|
||||
onInput: note.setDraft,
|
||||
onCancel: note.cancelDraft,
|
||||
onSubmit: (comment) => {
|
||||
props.onSubmit({ comment, selection: cloneSelectedLineRange(range) })
|
||||
note.cancelDraft()
|
||||
},
|
||||
onPopoverFocusOut: props.onDraftPopoverFocusOut,
|
||||
}),
|
||||
})
|
||||
|
||||
const renderHoverUtility = createLineCommentHoverRenderer({
|
||||
label: props.label,
|
||||
getSelectedRange: () => {
|
||||
if (note.opened()) return null
|
||||
return props.getHoverSelectedRange?.() ?? note.selected()
|
||||
},
|
||||
onOpenDraft: note.openDraft,
|
||||
})
|
||||
|
||||
const onLineSelected = (range: SelectedLineRange | null) => {
|
||||
if (!range) {
|
||||
note.select(null)
|
||||
note.cancelDraft()
|
||||
return
|
||||
}
|
||||
|
||||
note.select(range)
|
||||
}
|
||||
|
||||
const onLineSelectionEnd = (range: SelectedLineRange | null) => {
|
||||
if (!range) {
|
||||
if (props.clearSelectionOnSelectionEndNull) note.select(null)
|
||||
note.cancelDraft()
|
||||
return
|
||||
}
|
||||
|
||||
note.finishSelection(range)
|
||||
}
|
||||
|
||||
const onLineNumberSelectionEnd = (range: SelectedLineRange | null) => {
|
||||
if (!range) return
|
||||
note.openDraft(range)
|
||||
}
|
||||
|
||||
return {
|
||||
note,
|
||||
annotations,
|
||||
renderAnnotation,
|
||||
renderHoverUtility,
|
||||
onLineSelected,
|
||||
onLineSelectionEnd,
|
||||
onLineNumberSelectionEnd,
|
||||
}
|
||||
}
|
||||
|
||||
export function createLineCommentAnnotations<T>(
|
||||
props: LineCommentAnnotationsWithSideProps<T>,
|
||||
): Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
|
||||
export function createLineCommentAnnotations<T>(
|
||||
props: LineCommentAnnotationsProps<T>,
|
||||
): Accessor<LineCommentAnnotation<T>[]>
|
||||
export function createLineCommentAnnotations<T>(
|
||||
props: LineCommentAnnotationsProps<T> | LineCommentAnnotationsWithSideProps<T>,
|
||||
) {
|
||||
const line = (range: SelectedLineRange) => Math.max(range.start, range.end)
|
||||
|
||||
if ("getSide" in props) {
|
||||
return createMemo<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>(() => {
|
||||
const list = props.comments().map((comment) => {
|
||||
const range = props.getCommentSelection(comment)
|
||||
return {
|
||||
side: props.getSide(range),
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "comment",
|
||||
key: `comment:${props.getCommentId(comment)}`,
|
||||
comment,
|
||||
} satisfies LineCommentAnnotationMeta<T>,
|
||||
}
|
||||
})
|
||||
|
||||
const range = props.draftRange()
|
||||
if (!range) return list
|
||||
|
||||
return [
|
||||
...list,
|
||||
{
|
||||
side: props.getSide(range),
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "draft",
|
||||
key: `draft:${props.draftKey()}`,
|
||||
range,
|
||||
} satisfies LineCommentAnnotationMeta<T>,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return createMemo<LineCommentAnnotation<T>[]>(() => {
|
||||
const list = props.comments().map((comment) => {
|
||||
const range = props.getCommentSelection(comment)
|
||||
const entry: LineCommentAnnotation<T> = {
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "comment",
|
||||
key: `comment:${props.getCommentId(comment)}`,
|
||||
comment,
|
||||
},
|
||||
}
|
||||
|
||||
return entry
|
||||
})
|
||||
|
||||
const range = props.draftRange()
|
||||
if (!range) return list
|
||||
|
||||
const draft: LineCommentAnnotation<T> = {
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "draft",
|
||||
key: `draft:${props.draftKey()}`,
|
||||
range,
|
||||
},
|
||||
}
|
||||
|
||||
return [...list, draft]
|
||||
})
|
||||
}
|
||||
|
||||
export function createManagedLineCommentAnnotationRenderer<T>(props: {
|
||||
annotations: Accessor<LineCommentAnnotation<T>[]>
|
||||
renderComment: (comment: T) => CommentProps
|
||||
renderDraft: (range: SelectedLineRange) => DraftProps
|
||||
}) {
|
||||
const renderer = createLineCommentAnnotationRenderer<T>({
|
||||
renderComment: props.renderComment,
|
||||
renderDraft: props.renderDraft,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
renderer.reconcile(props.annotations())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
renderer.cleanup()
|
||||
})
|
||||
|
||||
return {
|
||||
renderAnnotation: renderer.render,
|
||||
}
|
||||
}
|
||||
|
||||
export function createLineCommentHoverRenderer(props: {
|
||||
label: string
|
||||
getSelectedRange: Accessor<SelectedLineRange | null>
|
||||
onOpenDraft: (range: SelectedLineRange) => void
|
||||
}) {
|
||||
return (getHoveredLine: () => HoverCommentLine | undefined) =>
|
||||
createHoverCommentUtility({
|
||||
label: props.label,
|
||||
getHoveredLine,
|
||||
onSelect: (hovered) => {
|
||||
const current = props.getSelectedRange()
|
||||
if (current && lineInSelectedRange(current, hovered.lineNumber, hovered.side)) {
|
||||
props.onOpenDraft(cloneSelectedLineRange(current))
|
||||
return
|
||||
}
|
||||
|
||||
const range: SelectedLineRange = {
|
||||
start: hovered.lineNumber,
|
||||
end: hovered.lineNumber,
|
||||
}
|
||||
if (hovered.side) range.side = hovered.side
|
||||
props.onOpenDraft(range)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,23 @@
|
||||
export const lineCommentStyles = `
|
||||
[data-annotation-slot] {
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
z-index: var(--line-comment-z, 30);
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline] {
|
||||
position: relative;
|
||||
right: auto;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-open] {
|
||||
z-index: var(--line-comment-open-z, 100);
|
||||
}
|
||||
@@ -21,10 +35,20 @@
|
||||
border: none;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-variant="add"] [data-slot="line-comment-button"] {
|
||||
background: var(--syntax-diff-add);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-component="icon"] {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-icon"] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-button"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
@@ -39,27 +63,56 @@
|
||||
right: -8px;
|
||||
z-index: var(--line-comment-popover-z, 40);
|
||||
min-width: 200px;
|
||||
max-width: min(320px, calc(100vw - 48px));
|
||||
max-width: none;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
box-shadow: var(--shadow-lg-border-base);
|
||||
box-shadow: var(--shadow-xxs-border);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"] {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
margin-left: 8px;
|
||||
flex: 0 1 600px;
|
||||
width: min(100%, 600px);
|
||||
max-width: min(100%, 600px);
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"][data-inline-body] {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline][data-variant="default"] [data-slot="line-comment-popover"][data-inline-body] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-variant="editor"] [data-slot="line-comment-popover"] {
|
||||
width: 380px;
|
||||
max-width: min(380px, calc(100vw - 48px));
|
||||
max-width: none;
|
||||
padding: 8px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline][data-variant="editor"] [data-slot="line-comment-popover"] {
|
||||
flex-basis: 600px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-content"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-head"] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-text"] {
|
||||
flex: 1;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-regular);
|
||||
@@ -69,6 +122,13 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-tools"] {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-label"],
|
||||
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
|
||||
font-family: var(--font-family-sans);
|
||||
@@ -108,8 +168,56 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-editor-label"] {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"] {
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-strong);
|
||||
border-radius: var(--radius-md);
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="ghost"] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"][data-variant="primary"] {
|
||||
background: var(--text-strong);
|
||||
border-color: var(--text-strong);
|
||||
color: var(--background-base);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-action"]:disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
`
|
||||
|
||||
let installed = false
|
||||
|
||||
export function installLineCommentStyles() {
|
||||
if (installed) return
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const id = "opencode-line-comment-styles"
|
||||
if (document.getElementById(id)) {
|
||||
installed = true
|
||||
return
|
||||
}
|
||||
|
||||
const style = document.createElement("style")
|
||||
style.id = id
|
||||
style.textContent = lineCommentStyles
|
||||
document.head.appendChild(style)
|
||||
installed = true
|
||||
}
|
||||
@@ -1,52 +1,121 @@
|
||||
import { onMount, Show, splitProps, type JSX } from "solid-js"
|
||||
import { createEffect, createSignal, onMount, Show, splitProps, type JSX } from "solid-js"
|
||||
import { Button } from "./button"
|
||||
import { Icon } from "./icon"
|
||||
import { installLineCommentStyles } from "./line-comment-styles"
|
||||
import { useI18n } from "../context/i18n"
|
||||
|
||||
export type LineCommentVariant = "default" | "editor"
|
||||
installLineCommentStyles()
|
||||
|
||||
export type LineCommentVariant = "default" | "editor" | "add"
|
||||
|
||||
function InlineGlyph(props: { icon: "comment" | "plus" }) {
|
||||
return (
|
||||
<svg data-slot="line-comment-icon" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||
<Show
|
||||
when={props.icon === "comment"}
|
||||
fallback={
|
||||
<path
|
||||
d="M10 5.41699V10.0003M10 10.0003V14.5837M10 10.0003H5.4165M10 10.0003H14.5832"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<path d="M16.25 3.75H3.75V16.25L6.875 14.4643H16.25V3.75Z" stroke="currentColor" stroke-linecap="square" />
|
||||
</Show>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export type LineCommentAnchorProps = {
|
||||
id?: string
|
||||
top?: number
|
||||
inline?: boolean
|
||||
hideButton?: boolean
|
||||
open: boolean
|
||||
variant?: LineCommentVariant
|
||||
icon?: "comment" | "plus"
|
||||
buttonLabel?: string
|
||||
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||
onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent>
|
||||
class?: string
|
||||
popoverClass?: string
|
||||
children: JSX.Element
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
|
||||
const hidden = () => props.top === undefined
|
||||
const hidden = () => !props.inline && props.top === undefined
|
||||
const variant = () => props.variant ?? "default"
|
||||
const icon = () => props.icon ?? "comment"
|
||||
const inlineBody = () => props.inline && props.hideButton
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="line-comment"
|
||||
data-prevent-autofocus=""
|
||||
data-variant={variant()}
|
||||
data-comment-id={props.id}
|
||||
data-open={props.open ? "" : undefined}
|
||||
data-inline={props.inline ? "" : undefined}
|
||||
classList={{
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
style={{
|
||||
top: `${props.top ?? 0}px`,
|
||||
opacity: hidden() ? 0 : 1,
|
||||
"pointer-events": hidden() ? "none" : "auto",
|
||||
}}
|
||||
style={
|
||||
props.inline
|
||||
? undefined
|
||||
: {
|
||||
top: `${props.top ?? 0}px`,
|
||||
opacity: hidden() ? 0 : 1,
|
||||
"pointer-events": hidden() ? "none" : "auto",
|
||||
}
|
||||
}
|
||||
>
|
||||
<button type="button" data-slot="line-comment-button" onClick={props.onClick} onMouseEnter={props.onMouseEnter}>
|
||||
<Icon name="comment" size="small" />
|
||||
</button>
|
||||
<Show when={props.open}>
|
||||
<Show
|
||||
when={inlineBody()}
|
||||
fallback={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={props.buttonLabel}
|
||||
data-slot="line-comment-button"
|
||||
on:mousedown={(e) => e.stopPropagation()}
|
||||
on:mouseup={(e) => e.stopPropagation()}
|
||||
on:click={props.onClick as any}
|
||||
on:mouseenter={props.onMouseEnter as any}
|
||||
>
|
||||
<Show
|
||||
when={props.inline}
|
||||
fallback={<Icon name={icon() === "plus" ? "plus-small" : "comment"} size="small" />}
|
||||
>
|
||||
<InlineGlyph icon={icon()} />
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={props.open}>
|
||||
<div
|
||||
data-slot="line-comment-popover"
|
||||
classList={{
|
||||
[props.popoverClass ?? ""]: !!props.popoverClass,
|
||||
}}
|
||||
on:mousedown={(e) => e.stopPropagation()}
|
||||
on:focusout={props.onPopoverFocusOut as any}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-slot="line-comment-popover"
|
||||
data-inline-body=""
|
||||
classList={{
|
||||
[props.popoverClass ?? ""]: !!props.popoverClass,
|
||||
}}
|
||||
onFocusOut={props.onPopoverFocusOut}
|
||||
on:mousedown={(e) => e.stopPropagation()}
|
||||
on:click={props.onClick as any}
|
||||
on:mouseenter={props.onMouseEnter as any}
|
||||
on:focusout={props.onPopoverFocusOut as any}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
@@ -58,16 +127,22 @@ export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
|
||||
export type LineCommentProps = Omit<LineCommentAnchorProps, "children" | "variant"> & {
|
||||
comment: JSX.Element
|
||||
selection: JSX.Element
|
||||
actions?: JSX.Element
|
||||
}
|
||||
|
||||
export const LineComment = (props: LineCommentProps) => {
|
||||
const i18n = useI18n()
|
||||
const [split, rest] = splitProps(props, ["comment", "selection"])
|
||||
const [split, rest] = splitProps(props, ["comment", "selection", "actions"])
|
||||
|
||||
return (
|
||||
<LineCommentAnchor {...rest} variant="default">
|
||||
<LineCommentAnchor {...rest} variant="default" hideButton={props.inline}>
|
||||
<div data-slot="line-comment-content">
|
||||
<div data-slot="line-comment-text">{split.comment}</div>
|
||||
<div data-slot="line-comment-head">
|
||||
<div data-slot="line-comment-text">{split.comment}</div>
|
||||
<Show when={split.actions}>
|
||||
<div data-slot="line-comment-tools">{split.actions}</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="line-comment-label">
|
||||
{i18n.t("ui.lineComment.label.prefix")}
|
||||
{split.selection}
|
||||
@@ -78,6 +153,25 @@ export const LineComment = (props: LineCommentProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const LineCommentAdd = (props: LineCommentAddProps) => {
|
||||
const [split, rest] = splitProps(props, ["label"])
|
||||
const i18n = useI18n()
|
||||
|
||||
return (
|
||||
<LineCommentAnchor
|
||||
{...rest}
|
||||
open={false}
|
||||
variant="add"
|
||||
icon="plus"
|
||||
buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
|
||||
value: string
|
||||
selection: JSX.Element
|
||||
@@ -109,11 +203,16 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
const refs = {
|
||||
textarea: undefined as HTMLTextAreaElement | undefined,
|
||||
}
|
||||
const [text, setText] = createSignal(split.value)
|
||||
|
||||
const focus = () => refs.textarea?.focus()
|
||||
|
||||
createEffect(() => {
|
||||
setText(split.value)
|
||||
})
|
||||
|
||||
const submit = () => {
|
||||
const value = split.value.trim()
|
||||
const value = text().trim()
|
||||
if (!value) return
|
||||
split.onSubmit(value)
|
||||
}
|
||||
@@ -124,7 +223,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
})
|
||||
|
||||
return (
|
||||
<LineCommentAnchor {...rest} open={true} variant="editor" onClick={() => focus()}>
|
||||
<LineCommentAnchor {...rest} open={true} variant="editor" hideButton={props.inline} onClick={() => focus()}>
|
||||
<div data-slot="line-comment-editor">
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
@@ -133,19 +232,23 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
data-slot="line-comment-textarea"
|
||||
rows={split.rows ?? 3}
|
||||
placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
|
||||
value={split.value}
|
||||
onInput={(e) => split.onInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
value={text()}
|
||||
on:input={(e) => {
|
||||
const value = (e.currentTarget as HTMLTextAreaElement).value
|
||||
setText(value)
|
||||
split.onInput(value)
|
||||
}}
|
||||
on:keydown={(e) => {
|
||||
const event = e as KeyboardEvent
|
||||
event.stopPropagation()
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
event.preventDefault()
|
||||
split.onCancel()
|
||||
return
|
||||
}
|
||||
if (e.key !== "Enter") return
|
||||
if (e.shiftKey) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
/>
|
||||
@@ -155,12 +258,37 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
{split.selection}
|
||||
{i18n.t("ui.lineComment.editorLabel.suffix")}
|
||||
</div>
|
||||
<Button size="small" variant="ghost" onClick={split.onCancel}>
|
||||
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
|
||||
</Button>
|
||||
<Button size="small" variant="primary" disabled={split.value.trim().length === 0} onClick={submit}>
|
||||
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
|
||||
</Button>
|
||||
<Show
|
||||
when={!props.inline}
|
||||
fallback={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="line-comment-action"
|
||||
data-variant="ghost"
|
||||
on:click={split.onCancel as any}
|
||||
>
|
||||
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="line-comment-action"
|
||||
data-variant="primary"
|
||||
disabled={text().trim().length === 0}
|
||||
on:click={submit as any}
|
||||
>
|
||||
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button size="small" variant="ghost" onClick={split.onCancel}>
|
||||
{split.cancelLabel ?? i18n.t("ui.common.cancel")}
|
||||
</Button>
|
||||
<Button size="small" variant="primary" disabled={text().trim().length === 0} onClick={submit}>
|
||||
{split.submitLabel ?? i18n.t("ui.lineComment.submit")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</LineCommentAnchor>
|
||||
|
||||
@@ -27,8 +27,7 @@ import {
|
||||
QuestionInfo,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { useData } from "../context"
|
||||
import { useDiffComponent } from "../context/diff"
|
||||
import { useCodeComponent } from "../context/code"
|
||||
import { useFileComponent } from "../context/file"
|
||||
import { useDialog } from "../context/dialog"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { BasicTool } from "./basic-tool"
|
||||
@@ -1452,7 +1451,7 @@ ToolRegistry.register({
|
||||
name: "edit",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const diffComponent = useDiffComponent()
|
||||
const fileComponent = useFileComponent()
|
||||
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
|
||||
const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "")
|
||||
const filename = () => getFilename(props.input.filePath ?? "")
|
||||
@@ -1499,7 +1498,8 @@ ToolRegistry.register({
|
||||
>
|
||||
<div data-component="edit-content">
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{
|
||||
name: props.metadata?.filediff?.file || props.input.filePath,
|
||||
contents: props.metadata?.filediff?.before || props.input.oldString,
|
||||
@@ -1523,7 +1523,7 @@ ToolRegistry.register({
|
||||
name: "write",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const codeComponent = useCodeComponent()
|
||||
const fileComponent = useFileComponent()
|
||||
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
|
||||
const path = createMemo(() => props.input.filePath || "")
|
||||
const filename = () => getFilename(props.input.filePath ?? "")
|
||||
@@ -1561,7 +1561,8 @@ ToolRegistry.register({
|
||||
<ToolFileAccordion path={path()}>
|
||||
<div data-component="write-content">
|
||||
<Dynamic
|
||||
component={codeComponent}
|
||||
component={fileComponent}
|
||||
mode="text"
|
||||
file={{
|
||||
name: props.input.filePath,
|
||||
contents: props.input.content,
|
||||
@@ -1595,7 +1596,7 @@ ToolRegistry.register({
|
||||
name: "apply_patch",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const diffComponent = useDiffComponent()
|
||||
const fileComponent = useFileComponent()
|
||||
const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
|
||||
const pending = createMemo(() => props.status === "pending" || props.status === "running")
|
||||
const single = createMemo(() => {
|
||||
@@ -1703,7 +1704,8 @@ ToolRegistry.register({
|
||||
<Show when={visible()}>
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: file.filePath, contents: file.before }}
|
||||
after={{ name: file.movePath ?? file.filePath, contents: file.after }}
|
||||
/>
|
||||
@@ -1780,7 +1782,8 @@ ToolRegistry.register({
|
||||
>
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: file().filePath, contents: file().before }}
|
||||
after={{ name: file().movePath ?? file().filePath, contents: file().after }}
|
||||
/>
|
||||
|
||||
39
packages/ui/src/components/session-review-search.test.ts
Normal file
39
packages/ui/src/components/session-review-search.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildSessionSearchHits, stepSessionSearchIndex } from "./session-review-search"
|
||||
|
||||
describe("session review search", () => {
|
||||
test("builds hits with line, col, and side", () => {
|
||||
const hits = buildSessionSearchHits({
|
||||
query: "alpha",
|
||||
files: [
|
||||
{
|
||||
file: "a.txt",
|
||||
before: "alpha\nbeta alpha",
|
||||
after: "ALPHA",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(hits).toEqual([
|
||||
{ file: "a.txt", side: "deletions", line: 1, col: 1, len: 5 },
|
||||
{ file: "a.txt", side: "deletions", line: 2, col: 6, len: 5 },
|
||||
{ file: "a.txt", side: "additions", line: 1, col: 1, len: 5 },
|
||||
])
|
||||
})
|
||||
|
||||
test("uses non-overlapping matches", () => {
|
||||
const hits = buildSessionSearchHits({
|
||||
query: "aa",
|
||||
files: [{ file: "a.txt", after: "aaaa" }],
|
||||
})
|
||||
|
||||
expect(hits.map((hit) => hit.col)).toEqual([1, 3])
|
||||
})
|
||||
|
||||
test("wraps next and previous navigation", () => {
|
||||
expect(stepSessionSearchIndex(5, 0, -1)).toBe(4)
|
||||
expect(stepSessionSearchIndex(5, 4, 1)).toBe(0)
|
||||
expect(stepSessionSearchIndex(5, 2, 1)).toBe(3)
|
||||
expect(stepSessionSearchIndex(0, 0, 1)).toBe(0)
|
||||
})
|
||||
})
|
||||
59
packages/ui/src/components/session-review-search.ts
Normal file
59
packages/ui/src/components/session-review-search.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type SessionSearchHit = {
|
||||
file: string
|
||||
side: "additions" | "deletions"
|
||||
line: number
|
||||
col: number
|
||||
len: number
|
||||
}
|
||||
|
||||
type SessionSearchFile = {
|
||||
file: string
|
||||
before?: string
|
||||
after?: string
|
||||
}
|
||||
|
||||
function hitsForSide(args: { file: string; side: SessionSearchHit["side"]; text: string; needle: string }) {
|
||||
return args.text.split("\n").flatMap((line, i) => {
|
||||
if (!line) return []
|
||||
|
||||
const hay = line.toLowerCase()
|
||||
let at = hay.indexOf(args.needle)
|
||||
if (at < 0) return []
|
||||
|
||||
const out: SessionSearchHit[] = []
|
||||
while (at >= 0) {
|
||||
out.push({
|
||||
file: args.file,
|
||||
side: args.side,
|
||||
line: i + 1,
|
||||
col: at + 1,
|
||||
len: args.needle.length,
|
||||
})
|
||||
at = hay.indexOf(args.needle, at + args.needle.length)
|
||||
}
|
||||
|
||||
return out
|
||||
})
|
||||
}
|
||||
|
||||
export function buildSessionSearchHits(args: { query: string; files: SessionSearchFile[] }) {
|
||||
const value = args.query.trim().toLowerCase()
|
||||
if (!value) return []
|
||||
|
||||
return args.files.flatMap((file) => {
|
||||
const out: SessionSearchHit[] = []
|
||||
if (typeof file.before === "string") {
|
||||
out.push(...hitsForSide({ file: file.file, side: "deletions", text: file.before, needle: value }))
|
||||
}
|
||||
if (typeof file.after === "string") {
|
||||
out.push(...hitsForSide({ file: file.file, side: "additions", text: file.after, needle: value }))
|
||||
}
|
||||
return out
|
||||
})
|
||||
}
|
||||
|
||||
export function stepSessionSearchIndex(total: number, current: number, dir: 1 | -1) {
|
||||
if (total <= 0) return 0
|
||||
if (current < 0 || current >= total) return dir > 0 ? 0 : total - 1
|
||||
return (current + dir + total) % total
|
||||
}
|
||||
@@ -200,50 +200,6 @@
|
||||
color: var(--icon-diff-modified-base);
|
||||
}
|
||||
|
||||
[data-slot="session-review-file-container"] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-review-image-container"] {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: var(--background-stronger);
|
||||
}
|
||||
|
||||
[data-slot="session-review-image"] {
|
||||
max-width: 100%;
|
||||
max-height: 60vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
background: var(--background-base);
|
||||
}
|
||||
|
||||
[data-slot="session-review-image-placeholder"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="session-review-audio-container"] {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: var(--background-stronger);
|
||||
}
|
||||
|
||||
[data-slot="session-review-audio"] {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
[data-slot="session-review-audio-placeholder"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="session-review-diff-wrapper"] {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client"
|
||||
import { useData } from "../context"
|
||||
import { useDiffComponent } from "../context/diff"
|
||||
import { useFileComponent } from "../context/file"
|
||||
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
@@ -152,7 +152,7 @@ export function SessionTurn(
|
||||
) {
|
||||
const data = useData()
|
||||
const i18n = useI18n()
|
||||
const diffComponent = useDiffComponent()
|
||||
const fileComponent = useFileComponent()
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyParts: PartType[] = []
|
||||
@@ -465,7 +465,8 @@ export function SessionTurn(
|
||||
<Show when={visible()}>
|
||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||
<Dynamic
|
||||
component={diffComponent}
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: diff.file, contents: diff.before }}
|
||||
after={{ name: diff.file, contents: diff.after }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user