refactor(ui): isolate session components (#33670)
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
export type HoverCommentLine = {
|
||||
lineNumber: number
|
||||
side?: "additions" | "deletions"
|
||||
}
|
||||
|
||||
export function createHoverCommentUtility(props: {
|
||||
label: string
|
||||
getHoveredLine: () => HoverCommentLine | undefined
|
||||
onSelect: (line: HoverCommentLine) => void
|
||||
}) {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const button = document.createElement("button")
|
||||
button.type = "button"
|
||||
button.ariaLabel = props.label
|
||||
button.textContent = "+"
|
||||
button.style.width = "20px"
|
||||
button.style.height = "20px"
|
||||
button.style.display = "flex"
|
||||
button.style.alignItems = "center"
|
||||
button.style.justifyContent = "center"
|
||||
button.style.border = "none"
|
||||
button.style.borderRadius = "var(--radius-md)"
|
||||
button.style.background = "var(--icon-interactive-base)"
|
||||
button.style.color = "var(--white)"
|
||||
button.style.boxShadow = "var(--shadow-xs)"
|
||||
button.style.fontSize = "14px"
|
||||
button.style.lineHeight = "1"
|
||||
button.style.cursor = "pointer"
|
||||
button.style.position = "relative"
|
||||
button.style.left = "30px"
|
||||
button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)"
|
||||
|
||||
let line: HoverCommentLine | undefined
|
||||
|
||||
const sync = () => {
|
||||
const next = props.getHoveredLine()
|
||||
if (!next) return
|
||||
line = next
|
||||
}
|
||||
|
||||
const loop = () => {
|
||||
if (!button.isConnected) return
|
||||
sync()
|
||||
requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
const next = props.getHoveredLine() ?? line
|
||||
if (!next) return
|
||||
props.onSelect(next)
|
||||
}
|
||||
|
||||
requestAnimationFrame(loop)
|
||||
button.addEventListener("mouseenter", sync)
|
||||
button.addEventListener("mousemove", sync)
|
||||
button.addEventListener("pointerdown", (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
sync()
|
||||
})
|
||||
button.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
sync()
|
||||
})
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
open()
|
||||
})
|
||||
|
||||
return button
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
import { diffLineIndex, diffRowIndex } from "./diff-selection"
|
||||
|
||||
export type CommentSide = "additions" | "deletions"
|
||||
|
||||
function annotationIndex(node: HTMLElement) {
|
||||
const value = node.dataset.lineAnnotation?.split(",")[1]
|
||||
if (!value) return
|
||||
const line = parseInt(value, 10)
|
||||
if (Number.isNaN(line)) return
|
||||
return line
|
||||
}
|
||||
|
||||
function clear(root: ShadowRoot) {
|
||||
const marked = Array.from(root.querySelectorAll("[data-comment-selected]"))
|
||||
for (const node of marked) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.removeAttribute("data-comment-selected")
|
||||
}
|
||||
}
|
||||
|
||||
export function markCommentedDiffLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
|
||||
clear(root)
|
||||
|
||||
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 = diffRowIndex(root, split, range.start, range.side as CommentSide | undefined)
|
||||
if (start === undefined) continue
|
||||
|
||||
const end = (() => {
|
||||
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
|
||||
if (same) return start
|
||||
return diffRowIndex(root, split, range.end, (range.endSide ?? range.side) as CommentSide | undefined)
|
||||
})()
|
||||
if (end === undefined) continue
|
||||
|
||||
const first = Math.min(start, end)
|
||||
const last = Math.max(start, end)
|
||||
|
||||
for (const row of rows) {
|
||||
const idx = diffLineIndex(split, row)
|
||||
if (idx === undefined || idx < first || idx > last) continue
|
||||
row.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const idx = annotationIndex(annotation)
|
||||
if (idx === undefined || idx < first || idx > last) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function markCommentedFileLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
|
||||
clear(root)
|
||||
|
||||
const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = Math.max(1, Math.min(range.start, range.end))
|
||||
const end = Math.max(range.start, range.end)
|
||||
|
||||
for (let line = start; line <= end; line++) {
|
||||
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
|
||||
for (const node of nodes) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const line = annotationIndex(annotation)
|
||||
if (line === undefined || line < start || line > end) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
|
||||
export type DiffSelectionSide = "additions" | "deletions"
|
||||
|
||||
export function findDiffSide(node: HTMLElement): DiffSelectionSide {
|
||||
const line = node.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 = node.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return "additions"
|
||||
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
|
||||
}
|
||||
|
||||
export function diffLineIndex(split: boolean, node: HTMLElement) {
|
||||
const raw = node.dataset.lineIndex
|
||||
if (!raw) return
|
||||
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((x) => parseInt(x, 10))
|
||||
.filter((x) => !Number.isNaN(x))
|
||||
if (values.length === 0) return
|
||||
if (!split) return values[0]
|
||||
if (values.length === 2) return values[1]
|
||||
return values[0]
|
||||
}
|
||||
|
||||
export function diffRowIndex(root: ShadowRoot, split: boolean, line: number, side: DiffSelectionSide | undefined) {
|
||||
const rows = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
|
||||
const target = side ?? "additions"
|
||||
for (const row of rows) {
|
||||
if (findDiffSide(row) === target) return diffLineIndex(split, row)
|
||||
if (parseInt(row.dataset.altLine ?? "", 10) === line) return diffLineIndex(split, row)
|
||||
}
|
||||
}
|
||||
|
||||
export function fixDiffSelection(root: ShadowRoot | undefined, range: SelectedLineRange | null) {
|
||||
if (!range) return range
|
||||
if (!root) return
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
const start = diffRowIndex(root, split, range.start, range.side)
|
||||
const end = diffRowIndex(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
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export type FindHost = {
|
||||
element: () => HTMLElement | undefined
|
||||
open: () => void
|
||||
close: () => void
|
||||
next: (dir: 1 | -1) => void
|
||||
isOpen: () => boolean
|
||||
}
|
||||
|
||||
const hosts = new Set<FindHost>()
|
||||
let target: FindHost | undefined
|
||||
let current: FindHost | undefined
|
||||
let installed = false
|
||||
|
||||
function isEditable(node: unknown): boolean {
|
||||
if (!(node instanceof HTMLElement)) return false
|
||||
if (node.closest("[data-prevent-autofocus]")) return true
|
||||
if (node.isContentEditable) return true
|
||||
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName)
|
||||
}
|
||||
|
||||
function hostForNode(node: unknown) {
|
||||
if (!(node instanceof Node)) return
|
||||
for (const host of hosts) {
|
||||
const el = host.element()
|
||||
if (el && el.isConnected && el.contains(node)) return host
|
||||
}
|
||||
}
|
||||
|
||||
function installShortcuts() {
|
||||
if (installed) return
|
||||
if (typeof window === "undefined") return
|
||||
installed = true
|
||||
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.defaultPrevented) return
|
||||
if (isEditable(event.target)) return
|
||||
|
||||
const mod = event.metaKey || event.ctrlKey
|
||||
if (!mod) return
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === "g") {
|
||||
const host = current
|
||||
if (!host || !host.isOpen()) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
host.next(event.shiftKey ? -1 : 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (key !== "f") return
|
||||
|
||||
const active = current
|
||||
if (active && active.isOpen()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
active.open()
|
||||
return
|
||||
}
|
||||
|
||||
const host = hostForNode(document.activeElement) ?? hostForNode(event.target) ?? target ?? Array.from(hosts)[0]
|
||||
if (!host) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
host.open()
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
}
|
||||
|
||||
function clearHighlightFind() {
|
||||
const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights
|
||||
if (!api) return
|
||||
api.delete("opencode-find")
|
||||
api.delete("opencode-find-current")
|
||||
}
|
||||
|
||||
function supportsHighlights() {
|
||||
const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown }
|
||||
return typeof g.Highlight === "function" && g.CSS?.highlights != null
|
||||
}
|
||||
|
||||
function scrollParent(el: HTMLElement): HTMLElement | undefined {
|
||||
let parent = el.parentElement
|
||||
while (parent) {
|
||||
const style = getComputedStyle(parent)
|
||||
if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
|
||||
parent = parent.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
type CreateFileFindOptions = {
|
||||
wrapper: () => HTMLElement | undefined
|
||||
overlay: () => HTMLDivElement | undefined
|
||||
getRoot: () => ShadowRoot | undefined
|
||||
}
|
||||
|
||||
export function createFileFind(opts: CreateFileFindOptions) {
|
||||
let input: HTMLInputElement | undefined
|
||||
let overlayFrame: number | undefined
|
||||
let mode: "highlights" | "overlay" = "overlay"
|
||||
let hits: Range[] = []
|
||||
const [overlayScroll, setOverlayScroll] = createSignal<HTMLElement[]>([])
|
||||
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
query: "",
|
||||
index: 0,
|
||||
count: 0,
|
||||
pos: { top: 8, right: 8 },
|
||||
})
|
||||
const open = () => state.open
|
||||
const query = () => state.query
|
||||
const index = () => state.index
|
||||
const count = () => state.count
|
||||
const pos = () => state.pos
|
||||
|
||||
const clearOverlayScroll = () => {
|
||||
setOverlayScroll([])
|
||||
}
|
||||
|
||||
const clearOverlay = () => {
|
||||
const el = opts.overlay()
|
||||
if (!el) return
|
||||
if (overlayFrame !== undefined) {
|
||||
cancelAnimationFrame(overlayFrame)
|
||||
overlayFrame = undefined
|
||||
}
|
||||
el.innerHTML = ""
|
||||
}
|
||||
|
||||
const renderOverlay = () => {
|
||||
if (mode !== "overlay") {
|
||||
clearOverlay()
|
||||
return
|
||||
}
|
||||
|
||||
const wrapper = opts.wrapper()
|
||||
const overlay = opts.overlay()
|
||||
if (!wrapper || !overlay) return
|
||||
|
||||
clearOverlay()
|
||||
if (hits.length === 0) return
|
||||
|
||||
const base = wrapper.getBoundingClientRect()
|
||||
const currentIndex = index()
|
||||
const frag = document.createDocumentFragment()
|
||||
|
||||
for (let i = 0; i < hits.length; i++) {
|
||||
const range = hits[i]
|
||||
const active = i === currentIndex
|
||||
for (const rect of Array.from(range.getClientRects())) {
|
||||
if (!rect.width || !rect.height) continue
|
||||
|
||||
const mark = document.createElement("div")
|
||||
mark.style.position = "absolute"
|
||||
mark.style.left = `${Math.round(rect.left - base.left)}px`
|
||||
mark.style.top = `${Math.round(rect.top - base.top)}px`
|
||||
mark.style.width = `${Math.round(rect.width)}px`
|
||||
mark.style.height = `${Math.round(rect.height)}px`
|
||||
mark.style.borderRadius = "2px"
|
||||
mark.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)"
|
||||
mark.style.opacity = active ? "0.55" : "0.35"
|
||||
if (active) mark.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)"
|
||||
frag.appendChild(mark)
|
||||
}
|
||||
}
|
||||
|
||||
overlay.appendChild(frag)
|
||||
}
|
||||
|
||||
function scheduleOverlay() {
|
||||
if (mode !== "overlay") return
|
||||
if (!open()) return
|
||||
if (overlayFrame !== undefined) return
|
||||
|
||||
overlayFrame = requestAnimationFrame(() => {
|
||||
overlayFrame = undefined
|
||||
renderOverlay()
|
||||
})
|
||||
}
|
||||
|
||||
const syncOverlayScroll = () => {
|
||||
if (mode !== "overlay") return
|
||||
const root = opts.getRoot()
|
||||
|
||||
const next = root
|
||||
? Array.from(root.querySelectorAll("[data-code]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
: []
|
||||
const current = overlayScroll()
|
||||
if (next.length === current.length && next.every((el, i) => el === current[i])) return
|
||||
|
||||
clearOverlayScroll()
|
||||
setOverlayScroll(next)
|
||||
}
|
||||
|
||||
const clearFind = () => {
|
||||
clearHighlightFind()
|
||||
clearOverlay()
|
||||
clearOverlayScroll()
|
||||
hits = []
|
||||
setState("count", 0)
|
||||
setState("index", 0)
|
||||
}
|
||||
|
||||
const positionBar = () => {
|
||||
if (typeof window === "undefined") return
|
||||
const wrapper = opts.wrapper()
|
||||
if (!wrapper) return
|
||||
|
||||
const root = scrollParent(wrapper) ?? wrapper
|
||||
const rect = root.getBoundingClientRect()
|
||||
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
|
||||
const header = Number.isNaN(title) ? 0 : title
|
||||
|
||||
setState("pos", {
|
||||
top: Math.round(rect.top) + header - 4,
|
||||
right: Math.round(window.innerWidth - rect.right) + 8,
|
||||
})
|
||||
}
|
||||
|
||||
const scan = (root: ShadowRoot, value: string) => {
|
||||
const needle = value.toLowerCase()
|
||||
const ranges: Range[] = []
|
||||
const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const col of cols) {
|
||||
const text = col.textContent
|
||||
if (!text) continue
|
||||
|
||||
const hay = text.toLowerCase()
|
||||
let at = hay.indexOf(needle)
|
||||
if (at === -1) continue
|
||||
|
||||
const nodes: Text[] = []
|
||||
const ends: number[] = []
|
||||
const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT)
|
||||
let node = walker.nextNode()
|
||||
let pos = 0
|
||||
while (node) {
|
||||
if (node instanceof Text) {
|
||||
pos += node.data.length
|
||||
nodes.push(node)
|
||||
ends.push(pos)
|
||||
}
|
||||
node = walker.nextNode()
|
||||
}
|
||||
if (nodes.length === 0) continue
|
||||
|
||||
const locate = (offset: number) => {
|
||||
let lo = 0
|
||||
let hi = ends.length - 1
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (ends[mid] >= offset) hi = mid
|
||||
else lo = mid + 1
|
||||
}
|
||||
const prev = lo === 0 ? 0 : ends[lo - 1]
|
||||
return { node: nodes[lo], offset: offset - prev }
|
||||
}
|
||||
|
||||
while (at !== -1) {
|
||||
const start = locate(at)
|
||||
const end = locate(at + value.length)
|
||||
const range = document.createRange()
|
||||
range.setStart(start.node, start.offset)
|
||||
range.setEnd(end.node, end.offset)
|
||||
ranges.push(range)
|
||||
at = hay.indexOf(needle, at + value.length)
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
const scrollToRange = (range: Range) => {
|
||||
const start = range.startContainer
|
||||
const el = start instanceof Element ? start : start.parentElement
|
||||
el?.scrollIntoView({ block: "center", inline: "center" })
|
||||
}
|
||||
|
||||
const setHighlights = (ranges: Range[], currentIndex: number) => {
|
||||
const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights
|
||||
const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight
|
||||
if (!api || typeof Highlight !== "function") return false
|
||||
|
||||
api.delete("opencode-find")
|
||||
api.delete("opencode-find-current")
|
||||
|
||||
const active = ranges[currentIndex]
|
||||
if (active) api.set("opencode-find-current", new Highlight(active))
|
||||
|
||||
const rest = ranges.filter((_, i) => i !== currentIndex)
|
||||
if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
|
||||
return true
|
||||
}
|
||||
|
||||
const apply = (args?: { reset?: boolean; scroll?: boolean }) => {
|
||||
if (!open()) return
|
||||
|
||||
const value = query().trim()
|
||||
if (!value) {
|
||||
clearFind()
|
||||
return
|
||||
}
|
||||
|
||||
const root = opts.getRoot()
|
||||
if (!root) return
|
||||
|
||||
mode = supportsHighlights() ? "highlights" : "overlay"
|
||||
|
||||
const ranges = scan(root, value)
|
||||
const total = ranges.length
|
||||
const desired = args?.reset ? 0 : index()
|
||||
const currentIndex = total ? Math.min(desired, total - 1) : 0
|
||||
|
||||
hits = ranges
|
||||
setState("count", total)
|
||||
setState("index", currentIndex)
|
||||
|
||||
const active = ranges[currentIndex]
|
||||
if (mode === "highlights") {
|
||||
clearOverlay()
|
||||
clearOverlayScroll()
|
||||
if (!setHighlights(ranges, currentIndex)) {
|
||||
mode = "overlay"
|
||||
clearHighlightFind()
|
||||
syncOverlayScroll()
|
||||
scheduleOverlay()
|
||||
}
|
||||
if (args?.scroll && active) scrollToRange(active)
|
||||
return
|
||||
}
|
||||
|
||||
clearHighlightFind()
|
||||
syncOverlayScroll()
|
||||
if (args?.scroll && active) scrollToRange(active)
|
||||
scheduleOverlay()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
setState("open", false)
|
||||
setState("query", "")
|
||||
clearFind()
|
||||
if (current === host) current = undefined
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
if (current && current !== host) current.close()
|
||||
current = host
|
||||
target = host
|
||||
if (!open()) setState("open", true)
|
||||
requestAnimationFrame(() => {
|
||||
apply({ scroll: true })
|
||||
input?.focus()
|
||||
input?.select()
|
||||
})
|
||||
}
|
||||
|
||||
const next = (dir: 1 | -1) => {
|
||||
if (!open()) return
|
||||
const total = count()
|
||||
if (total <= 0) return
|
||||
|
||||
const currentIndex = (index() + dir + total) % total
|
||||
setState("index", currentIndex)
|
||||
|
||||
const active = hits[currentIndex]
|
||||
if (!active) return
|
||||
|
||||
if (mode === "highlights") {
|
||||
if (!setHighlights(hits, currentIndex)) {
|
||||
mode = "overlay"
|
||||
apply({ reset: true, scroll: true })
|
||||
return
|
||||
}
|
||||
scrollToRange(active)
|
||||
return
|
||||
}
|
||||
|
||||
clearHighlightFind()
|
||||
syncOverlayScroll()
|
||||
scrollToRange(active)
|
||||
scheduleOverlay()
|
||||
}
|
||||
|
||||
const host: FindHost = {
|
||||
element: opts.wrapper,
|
||||
isOpen: () => open(),
|
||||
next,
|
||||
open: focus,
|
||||
close,
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
for (const el of overlayScroll()) makeEventListener(el, "scroll", scheduleOverlay, { passive: true })
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
mode = supportsHighlights() ? "highlights" : "overlay"
|
||||
installShortcuts()
|
||||
hosts.add(host)
|
||||
if (!target) target = host
|
||||
|
||||
onCleanup(() => {
|
||||
hosts.delete(host)
|
||||
if (current === host) {
|
||||
current = undefined
|
||||
clearHighlightFind()
|
||||
}
|
||||
if (target === host) target = undefined
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!open()) return
|
||||
|
||||
const update = () => positionBar()
|
||||
requestAnimationFrame(update)
|
||||
makeEventListener(window, "resize", update, { passive: true })
|
||||
|
||||
const wrapper = opts.wrapper()
|
||||
if (!wrapper) return
|
||||
const root = scrollParent(wrapper) ?? wrapper
|
||||
createResizeObserver(root, update)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearOverlayScroll()
|
||||
clearOverlay()
|
||||
if (current === host) {
|
||||
current = undefined
|
||||
clearHighlightFind()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
open,
|
||||
query,
|
||||
count,
|
||||
index,
|
||||
pos,
|
||||
setInput: (el: HTMLInputElement) => {
|
||||
input = el
|
||||
},
|
||||
setQuery: (value: string) => {
|
||||
setState("query", value)
|
||||
setState("index", 0)
|
||||
apply({ reset: true, scroll: true })
|
||||
},
|
||||
focus,
|
||||
close,
|
||||
next,
|
||||
refresh: (args?: { reset?: boolean; scroll?: boolean }) => apply(args),
|
||||
onPointerDown: () => {
|
||||
target = host
|
||||
opts.wrapper()?.focus({ preventScroll: true })
|
||||
},
|
||||
onFocus: () => {
|
||||
target = host
|
||||
},
|
||||
onInputKeyDown: (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
next(event.shiftKey ? -1 : 1)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
type ReadyWatcher = {
|
||||
observer?: MutationObserver
|
||||
token: number
|
||||
}
|
||||
|
||||
export function createReadyWatcher(): ReadyWatcher {
|
||||
return { token: 0 }
|
||||
}
|
||||
|
||||
export function clearReadyWatcher(state: ReadyWatcher) {
|
||||
state.observer?.disconnect()
|
||||
state.observer = undefined
|
||||
}
|
||||
|
||||
export function getViewerHost(container: HTMLElement | undefined) {
|
||||
if (!container) return
|
||||
const host = container.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
return host
|
||||
}
|
||||
|
||||
export function getViewerRoot(container: HTMLElement | undefined) {
|
||||
return getViewerHost(container)?.shadowRoot ?? undefined
|
||||
}
|
||||
|
||||
export function applyViewerScheme(host: HTMLElement | undefined) {
|
||||
if (!host) return
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const scheme = document.documentElement.dataset.colorScheme
|
||||
if (scheme === "dark" || scheme === "light") {
|
||||
host.dataset.colorScheme = scheme
|
||||
return
|
||||
}
|
||||
|
||||
host.removeAttribute("data-color-scheme")
|
||||
}
|
||||
|
||||
export function observeViewerScheme(getHost: () => HTMLElement | undefined) {
|
||||
if (typeof document === "undefined") return () => {}
|
||||
|
||||
applyViewerScheme(getHost())
|
||||
if (typeof MutationObserver === "undefined") return () => {}
|
||||
|
||||
const root = document.documentElement
|
||||
const monitor = new MutationObserver(() => applyViewerScheme(getHost()))
|
||||
monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
|
||||
return () => monitor.disconnect()
|
||||
}
|
||||
|
||||
export function notifyShadowReady(opts: {
|
||||
state: ReadyWatcher
|
||||
container: HTMLElement
|
||||
getRoot: () => ShadowRoot | undefined
|
||||
isReady: (root: ShadowRoot) => boolean
|
||||
onReady: () => void
|
||||
settleFrames?: number
|
||||
}) {
|
||||
clearReadyWatcher(opts.state)
|
||||
opts.state.token += 1
|
||||
|
||||
const token = opts.state.token
|
||||
const settle = Math.max(0, opts.settleFrames ?? 0)
|
||||
|
||||
const runReady = () => {
|
||||
const step = (left: number) => {
|
||||
if (token !== opts.state.token) return
|
||||
if (left <= 0) {
|
||||
opts.onReady()
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => step(left - 1))
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => step(settle))
|
||||
}
|
||||
|
||||
const observeRoot = (root: ShadowRoot) => {
|
||||
if (opts.isReady(root)) {
|
||||
runReady()
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof MutationObserver === "undefined") return
|
||||
|
||||
clearReadyWatcher(opts.state)
|
||||
opts.state.observer = new MutationObserver(() => {
|
||||
if (token !== opts.state.token) return
|
||||
if (!opts.isReady(root)) return
|
||||
|
||||
clearReadyWatcher(opts.state)
|
||||
runReady()
|
||||
})
|
||||
opts.state.observer.observe(root, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
const root = opts.getRoot()
|
||||
if (!root) {
|
||||
if (typeof MutationObserver === "undefined") return
|
||||
|
||||
opts.state.observer = new MutationObserver(() => {
|
||||
if (token !== opts.state.token) return
|
||||
|
||||
const next = opts.getRoot()
|
||||
if (!next) return
|
||||
|
||||
observeRoot(next)
|
||||
})
|
||||
opts.state.observer.observe(opts.container, { childList: true, subtree: true })
|
||||
return
|
||||
}
|
||||
|
||||
observeRoot(root)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
import { toRange } from "./selection-bridge"
|
||||
|
||||
export function findElement(node: Node | null): HTMLElement | undefined {
|
||||
if (!node) return
|
||||
if (node instanceof HTMLElement) return node
|
||||
return node.parentElement ?? undefined
|
||||
}
|
||||
|
||||
export function findFileLineNumber(node: Node | null): number | undefined {
|
||||
const el = findElement(node)
|
||||
if (!el) return
|
||||
|
||||
const line = el.closest("[data-line]")
|
||||
if (!(line instanceof HTMLElement)) return
|
||||
|
||||
const value = parseInt(line.dataset.line ?? "", 10)
|
||||
if (Number.isNaN(value)) return
|
||||
return value
|
||||
}
|
||||
|
||||
export function findDiffLineNumber(node: Node | null): number | undefined {
|
||||
const el = findElement(node)
|
||||
if (!el) return
|
||||
|
||||
const line = el.closest("[data-line], [data-alt-line]")
|
||||
if (!(line instanceof HTMLElement)) return
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function findCodeSelectionSide(node: Node | null): SelectedLineRange["side"] {
|
||||
const el = findElement(node)
|
||||
if (!el) return
|
||||
|
||||
const code = el.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return
|
||||
if (code.hasAttribute("data-deletions")) return "deletions"
|
||||
return "additions"
|
||||
}
|
||||
|
||||
export function readShadowLineSelection(opts: {
|
||||
root: ShadowRoot
|
||||
lineForNode: (node: Node | null) => number | undefined
|
||||
sideForNode?: (node: Node | null) => SelectedLineRange["side"]
|
||||
preserveTextSelection?: boolean
|
||||
}) {
|
||||
const selection =
|
||||
(opts.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[] }) => StaticRange[]
|
||||
}
|
||||
).getComposedRanges?.({ shadowRoots: [opts.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 (!opts.root.contains(startNode) || !opts.root.contains(endNode)) return
|
||||
|
||||
const start = opts.lineForNode(startNode)
|
||||
const end = opts.lineForNode(endNode)
|
||||
if (start === undefined || end === undefined) return
|
||||
|
||||
const startSide = opts.sideForNode?.(startNode)
|
||||
const endSide = opts.sideForNode?.(endNode)
|
||||
const side = startSide ?? endSide
|
||||
|
||||
const range: SelectedLineRange = { start, end }
|
||||
if (side) range.side = side
|
||||
if (endSide && side && endSide !== side) range.endSide = endSide
|
||||
|
||||
return {
|
||||
range,
|
||||
text: opts.preserveTextSelection && domRange ? toRange(domRange).cloneRange() : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
|
||||
import { ComponentProps } from "solid-js"
|
||||
import { lineCommentStyles } from "../components/line-comment-styles"
|
||||
|
||||
export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
||||
before: FileContents
|
||||
after: FileContents
|
||||
annotations?: DiffLineAnnotation<T>[]
|
||||
selectedLines?: SelectedLineRange | null
|
||||
commentedLines?: SelectedLineRange[]
|
||||
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
|
||||
onRendered?: () => void
|
||||
class?: string
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
}
|
||||
|
||||
const unsafeCSS = `
|
||||
[data-diff],
|
||||
[data-file] {
|
||||
/* Pierre 1.2 mixes these override targets at 12% in light mode and 20% in dark mode. */
|
||||
--diffs-bg-deletion-override: light-dark(
|
||||
color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-deletion-base)),
|
||||
color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-deletion-base))
|
||||
);
|
||||
--diffs-bg-addition-override: light-dark(
|
||||
color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-addition-base)),
|
||||
color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-addition-base))
|
||||
);
|
||||
--diffs-selection-base: var(--surface-warning-strong);
|
||||
--diffs-selection-border: var(--border-warning-base);
|
||||
--diffs-selection-number-fg: #1c1917;
|
||||
/* Use explicit alpha instead of color-mix(..., transparent) to avoid Safari's non-premultiplied interpolation bugs. */
|
||||
--diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--surface-warning-base) r g b / 0.65));
|
||||
--diffs-bg-selection-number: var(
|
||||
--diffs-bg-selection-number-override,
|
||||
rgb(from var(--surface-warning-base) r g b / 0.85)
|
||||
);
|
||||
--diffs-bg-selection-text: rgb(from var(--surface-warning-strong) r g b / 0.2);
|
||||
}
|
||||
|
||||
:host([data-color-scheme='dark']) [data-diff],
|
||||
:host([data-color-scheme='dark']) [data-file] {
|
||||
--diffs-selection-number-fg: #fdfbfb;
|
||||
--diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--solaris-dark-6) r g b / 0.65));
|
||||
--diffs-bg-selection-number: var(
|
||||
--diffs-bg-selection-number-override,
|
||||
rgb(from var(--solaris-dark-6) r g b / 0.85)
|
||||
);
|
||||
}
|
||||
|
||||
[data-diff] ::selection,
|
||||
[data-file] ::selection {
|
||||
background-color: var(--diffs-bg-selection-text);
|
||||
}
|
||||
|
||||
::highlight(opencode-find) {
|
||||
background-color: rgb(from var(--surface-warning-base) r g b / 0.35);
|
||||
}
|
||||
|
||||
::highlight(opencode-find-current) {
|
||||
background-color: rgb(from var(--surface-warning-strong) r g b / 0.55);
|
||||
}
|
||||
|
||||
[data-diff] [data-line][data-comment-selected]:not([data-selected-line]) {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
|
||||
}
|
||||
|
||||
[data-file] [data-line][data-comment-selected]:not([data-selected-line]) {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
|
||||
}
|
||||
|
||||
[data-diff] [data-column-number][data-comment-selected]:not([data-selected-line]) {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-file] [data-column-number][data-comment-selected]:not([data-selected-line]) {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-diff] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
|
||||
}
|
||||
|
||||
[data-file] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
|
||||
}
|
||||
|
||||
[data-diff] [data-line][data-selected-line] {
|
||||
background-color: var(--diffs-bg-selection);
|
||||
box-shadow: inset 2px 0 0 var(--diffs-selection-border);
|
||||
}
|
||||
|
||||
[data-file] [data-line][data-selected-line] {
|
||||
background-color: var(--diffs-bg-selection);
|
||||
box-shadow: inset 2px 0 0 var(--diffs-selection-border);
|
||||
}
|
||||
|
||||
[data-diff] [data-column-number][data-selected-line] {
|
||||
background-color: var(--diffs-bg-selection-number);
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-file] [data-column-number][data-selected-line] {
|
||||
background-color: var(--diffs-bg-selection-number);
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-diff] [data-column-number][data-line-type='context'][data-selected-line],
|
||||
[data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line],
|
||||
[data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line],
|
||||
[data-diff] [data-column-number][data-line-type='change-deletion'][data-selected-line] {
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
/* The deletion word-diff emphasis is stronger than additions; soften it while selected so the selection highlight reads consistently. */
|
||||
[data-diff] [data-line][data-line-type='change-deletion'][data-selected-line] {
|
||||
--diffs-bg-deletion-emphasis: light-dark(
|
||||
rgb(from var(--diffs-deletion-base) r g b / 0.07),
|
||||
rgb(from var(--diffs-deletion-base) r g b / 0.1)
|
||||
);
|
||||
}
|
||||
|
||||
[data-diff-header],
|
||||
[data-diff],
|
||||
[data-file] {
|
||||
[data-separator] {
|
||||
height: 24px;
|
||||
}
|
||||
[data-column-number] {
|
||||
background-color: var(--background-stronger);
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
&[data-interactive-line-numbers] [data-column-number] {
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
&[data-interactive-lines] [data-line] {
|
||||
cursor: auto !important;
|
||||
}
|
||||
[data-code] {
|
||||
overflow-x: auto !important;
|
||||
overflow-y: clip !important;
|
||||
}
|
||||
}
|
||||
|
||||
${lineCommentStyles}
|
||||
|
||||
`
|
||||
|
||||
export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
|
||||
return {
|
||||
theme: "OpenCode",
|
||||
themeType: "system",
|
||||
disableLineNumbers: false,
|
||||
overflow: "wrap",
|
||||
diffStyle: style ?? "unified",
|
||||
diffIndicators: "bars",
|
||||
lineHoverHighlight: "both",
|
||||
disableBackground: false,
|
||||
expansionLineCount: 20,
|
||||
hunkSeparators: "line-info-basic",
|
||||
lineDiffType: style === "split" ? "word-alt" : "none",
|
||||
maxLineDiffLength: 1000,
|
||||
maxLineLengthForHighlighting: 1000,
|
||||
disableFileHeader: true,
|
||||
unsafeCSS,
|
||||
} as const
|
||||
}
|
||||
|
||||
export const styleVariables = {
|
||||
"--diffs-font-family": "var(--font-family-mono)",
|
||||
"--diffs-font-size": "var(--font-size-small)",
|
||||
"--diffs-line-height": "24px",
|
||||
"--diffs-tab-size": 2,
|
||||
"--diffs-font-features": "var(--font-family-mono--font-feature-settings)",
|
||||
"--diffs-header-font-family": "var(--font-family-sans)",
|
||||
"--diffs-gap-block": 0,
|
||||
"--diffs-min-number-column-width": "4ch",
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type MediaKind = "image" | "audio" | "svg"
|
||||
|
||||
const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
|
||||
const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"])
|
||||
|
||||
type MediaValue = unknown
|
||||
|
||||
function mediaRecord(value: unknown) {
|
||||
if (!value || typeof value !== "object") return
|
||||
return value as Partial<FileContent> & {
|
||||
content?: unknown
|
||||
encoding?: unknown
|
||||
mimeType?: unknown
|
||||
type?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMimeType(type: string | undefined) {
|
||||
if (!type) return
|
||||
const mime = type.split(";", 1)[0]?.trim().toLowerCase()
|
||||
if (!mime) return
|
||||
if (mime === "audio/x-aac") return "audio/aac"
|
||||
if (mime === "audio/x-m4a") return "audio/mp4"
|
||||
return mime
|
||||
}
|
||||
|
||||
export function fileExtension(path: string | undefined) {
|
||||
if (!path) return ""
|
||||
const idx = path.lastIndexOf(".")
|
||||
if (idx === -1) return ""
|
||||
return path.slice(idx + 1).toLowerCase()
|
||||
}
|
||||
|
||||
export function mediaKindFromPath(path: string | undefined): MediaKind | undefined {
|
||||
const ext = fileExtension(path)
|
||||
if (ext === "svg") return "svg"
|
||||
if (imageExtensions.has(ext)) return "image"
|
||||
if (audioExtensions.has(ext)) return "audio"
|
||||
}
|
||||
|
||||
export function isBinaryContent(value: MediaValue) {
|
||||
return mediaRecord(value)?.type === "binary"
|
||||
}
|
||||
|
||||
function validDataUrl(value: string, kind: MediaKind) {
|
||||
if (kind === "svg") return value.startsWith("data:image/svg+xml") ? value : undefined
|
||||
if (kind === "image") return value.startsWith("data:image/") ? value : undefined
|
||||
if (value.startsWith("data:audio/x-aac;")) return value.replace("data:audio/x-aac;", "data:audio/aac;")
|
||||
if (value.startsWith("data:audio/x-m4a;")) return value.replace("data:audio/x-m4a;", "data:audio/mp4;")
|
||||
if (value.startsWith("data:audio/")) return value
|
||||
}
|
||||
|
||||
export function dataUrlFromMediaValue(value: MediaValue, kind: MediaKind) {
|
||||
if (!value) return
|
||||
|
||||
if (typeof value === "string") {
|
||||
return validDataUrl(value, kind)
|
||||
}
|
||||
|
||||
const record = mediaRecord(value)
|
||||
if (!record) return
|
||||
|
||||
if (typeof record.content !== "string") return
|
||||
|
||||
const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
|
||||
if (!mime) return
|
||||
|
||||
if (kind === "svg") {
|
||||
if (mime !== "image/svg+xml") return
|
||||
if (record.encoding === "base64") return `data:image/svg+xml;base64,${record.content}`
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(record.content)}`
|
||||
}
|
||||
|
||||
if (kind === "image" && !mime.startsWith("image/")) return
|
||||
if (kind === "audio" && !mime.startsWith("audio/")) return
|
||||
if (record.encoding !== "base64") return
|
||||
|
||||
return `data:${mime};base64,${record.content}`
|
||||
}
|
||||
|
||||
function decodeBase64Utf8(value: string) {
|
||||
if (typeof atob !== "function") return
|
||||
|
||||
try {
|
||||
const raw = atob(value)
|
||||
const bytes = Uint8Array.from(raw, (x) => x.charCodeAt(0))
|
||||
if (typeof TextDecoder === "function") return new TextDecoder().decode(bytes)
|
||||
return raw
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function svgTextFromValue(value: MediaValue) {
|
||||
const record = mediaRecord(value)
|
||||
if (!record) return
|
||||
if (typeof record.content !== "string") return
|
||||
|
||||
const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
|
||||
if (mime !== "image/svg+xml") return
|
||||
if (record.encoding === "base64") return decodeBase64Utf8(record.content)
|
||||
return record.content
|
||||
}
|
||||
|
||||
export function hasMediaValue(value: MediaValue) {
|
||||
if (typeof value === "string") return value.length > 0
|
||||
const record = mediaRecord(value)
|
||||
if (!record) return false
|
||||
return typeof record.content === "string" && record.content.length > 0
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
|
||||
type SelectionKey = "ui.sessionReview.selection.line" | "ui.sessionReview.selection.lines"
|
||||
type SelectionVars = Record<string, string | number>
|
||||
|
||||
type PointerMode = "none" | "text" | "numbers"
|
||||
type Side = SelectedLineRange["side"]
|
||||
type LineSpan = Pick<SelectedLineRange, "start" | "end">
|
||||
|
||||
export function formatSelectedLineLabel(range: LineSpan, t: (key: SelectionKey, params: SelectionVars) => string) {
|
||||
const start = Math.min(range.start, range.end)
|
||||
const end = Math.max(range.start, range.end)
|
||||
if (start === end) return t("ui.sessionReview.selection.line", { line: start })
|
||||
return t("ui.sessionReview.selection.lines", { start, end })
|
||||
}
|
||||
|
||||
export function previewSelectedLines(source: string, range: LineSpan) {
|
||||
const start = Math.max(1, Math.min(range.start, range.end))
|
||||
const end = Math.max(range.start, range.end)
|
||||
const lines = source.split("\n").slice(start - 1, end)
|
||||
if (lines.length === 0) return
|
||||
return lines.slice(0, 2).join("\n")
|
||||
}
|
||||
|
||||
export function cloneSelectedLineRange(range: SelectedLineRange): SelectedLineRange {
|
||||
const next: SelectedLineRange = {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
}
|
||||
|
||||
if (range.side) next.side = range.side
|
||||
if (range.endSide) next.endSide = range.endSide
|
||||
return next
|
||||
}
|
||||
|
||||
export function lineInSelectedRange(range: SelectedLineRange | null | undefined, line: number, side?: Side) {
|
||||
if (!range) return false
|
||||
|
||||
const start = Math.min(range.start, range.end)
|
||||
const end = Math.max(range.start, range.end)
|
||||
if (line < start || line > end) return false
|
||||
if (!side) return true
|
||||
|
||||
const first = range.side
|
||||
const last = range.endSide ?? first
|
||||
if (!first && !last) return true
|
||||
if (!first || !last) return (first ?? last) === side
|
||||
if (first === last) return first === side
|
||||
if (line === start) return first === side
|
||||
if (line === end) return last === side
|
||||
return true
|
||||
}
|
||||
|
||||
export function isSingleLineSelection(range: SelectedLineRange | null) {
|
||||
if (!range) return false
|
||||
return range.start === range.end && (range.endSide == null || range.endSide === range.side)
|
||||
}
|
||||
|
||||
export function toRange(source: Range | StaticRange): Range {
|
||||
if (source instanceof Range) return source
|
||||
const range = new Range()
|
||||
range.setStart(source.startContainer, source.startOffset)
|
||||
range.setEnd(source.endContainer, source.endOffset)
|
||||
return range
|
||||
}
|
||||
|
||||
export function restoreShadowTextSelection(root: ShadowRoot | undefined, range: Range | undefined) {
|
||||
if (!root || !range) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const selection =
|
||||
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
try {
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
} catch {}
|
||||
})
|
||||
}
|
||||
|
||||
export function createLineNumberSelectionBridge() {
|
||||
let mode: PointerMode = "none"
|
||||
let line: number | undefined
|
||||
let moved = false
|
||||
let pending = false
|
||||
|
||||
const clear = () => {
|
||||
mode = "none"
|
||||
line = undefined
|
||||
moved = false
|
||||
}
|
||||
|
||||
return {
|
||||
begin(numberColumn: boolean, next: number | undefined) {
|
||||
if (!numberColumn) {
|
||||
mode = "text"
|
||||
return
|
||||
}
|
||||
|
||||
mode = "numbers"
|
||||
line = next
|
||||
moved = false
|
||||
},
|
||||
track(buttons: number, next: number | undefined) {
|
||||
if (mode !== "numbers") return false
|
||||
|
||||
if ((buttons & 1) === 0) {
|
||||
clear()
|
||||
return true
|
||||
}
|
||||
|
||||
if (next !== undefined && line !== undefined && next !== line) moved = true
|
||||
return true
|
||||
},
|
||||
finish() {
|
||||
const current = mode
|
||||
pending = current === "numbers" && moved
|
||||
clear()
|
||||
return current
|
||||
},
|
||||
consume(range: SelectedLineRange | null) {
|
||||
const result = pending && !isSingleLineSelection(range)
|
||||
pending = false
|
||||
return result
|
||||
},
|
||||
reset() {
|
||||
pending = false
|
||||
clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { type VirtualFileMetrics, Virtualizer } from "@pierre/diffs"
|
||||
|
||||
type Target = {
|
||||
key: Document | HTMLElement
|
||||
root: Document | HTMLElement
|
||||
content: HTMLElement | undefined
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
virtualizer: Virtualizer
|
||||
refs: number
|
||||
}
|
||||
|
||||
const cache = new WeakMap<Document | HTMLElement, Entry>()
|
||||
|
||||
export const virtualMetrics: Partial<VirtualFileMetrics> = {
|
||||
lineHeight: 24,
|
||||
hunkSeparatorHeight: 24,
|
||||
spacing: 0,
|
||||
}
|
||||
|
||||
function scrollable(value: string) {
|
||||
return value === "auto" || value === "scroll" || value === "overlay"
|
||||
}
|
||||
|
||||
function scrollRoot(container: HTMLElement) {
|
||||
let node = container.parentElement
|
||||
while (node) {
|
||||
const style = getComputedStyle(node)
|
||||
if (scrollable(style.overflowY)) return node
|
||||
node = node.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
function target(container: HTMLElement): Target | undefined {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const review = container.closest("[data-component='session-review']")
|
||||
if (review instanceof HTMLElement) {
|
||||
const root = scrollRoot(container) ?? review
|
||||
const content = review.querySelector("[data-slot='session-review-container']")
|
||||
return {
|
||||
key: review,
|
||||
root,
|
||||
content: content instanceof HTMLElement ? content : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const root = scrollRoot(container)
|
||||
if (root) {
|
||||
const content = root.querySelector("[role='log']")
|
||||
return {
|
||||
key: root,
|
||||
root,
|
||||
content: content instanceof HTMLElement ? content : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: document,
|
||||
root: document,
|
||||
content: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function acquireVirtualizer(container: HTMLElement) {
|
||||
const resolved = target(container)
|
||||
if (!resolved) return
|
||||
|
||||
let entry = cache.get(resolved.key)
|
||||
if (!entry) {
|
||||
const virtualizer = new Virtualizer()
|
||||
virtualizer.setup(resolved.root, resolved.content)
|
||||
entry = {
|
||||
virtualizer,
|
||||
refs: 0,
|
||||
}
|
||||
cache.set(resolved.key, entry)
|
||||
}
|
||||
|
||||
entry.refs += 1
|
||||
let done = false
|
||||
|
||||
return {
|
||||
virtualizer: entry.virtualizer,
|
||||
release() {
|
||||
if (done) return
|
||||
done = true
|
||||
|
||||
const current = cache.get(resolved.key)
|
||||
if (!current) return
|
||||
|
||||
current.refs -= 1
|
||||
if (current.refs > 0) return
|
||||
|
||||
current.virtualizer.cleanUp()
|
||||
cache.delete(resolved.key)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { WorkerPoolManager } from "@pierre/diffs/worker"
|
||||
import ShikiWorkerUrl from "@pierre/diffs/worker/worker.js?worker&url"
|
||||
|
||||
export type WorkerPoolStyle = "unified" | "split"
|
||||
|
||||
export function workerFactory(): Worker {
|
||||
return new Worker(ShikiWorkerUrl, { type: "module" })
|
||||
}
|
||||
|
||||
function createPool(lineDiffType: "none" | "word-alt") {
|
||||
const pool = new WorkerPoolManager(
|
||||
{
|
||||
workerFactory,
|
||||
// poolSize defaults to 8. More workers = more parallelism but
|
||||
// also more memory. Too many can actually slow things down.
|
||||
// NOTE: 2 is probably better for OpenCode, as I think 8 might be
|
||||
// a bit overkill, especially because Safari has a significantly slower
|
||||
// boot up time for workers
|
||||
poolSize: 2,
|
||||
},
|
||||
{
|
||||
theme: "OpenCode",
|
||||
lineDiffType,
|
||||
preferredHighlighter: "shiki-wasm",
|
||||
},
|
||||
)
|
||||
|
||||
void pool.initialize()
|
||||
return pool
|
||||
}
|
||||
|
||||
let unified: WorkerPoolManager | undefined
|
||||
let split: WorkerPoolManager | undefined
|
||||
|
||||
export function getWorkerPool(style: WorkerPoolStyle | undefined): WorkerPoolManager | undefined {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
if (style === "split") {
|
||||
if (!split) split = createPool("word-alt")
|
||||
return split
|
||||
}
|
||||
|
||||
if (!unified) unified = createPool("none")
|
||||
return unified
|
||||
}
|
||||
|
||||
export function getWorkerPools() {
|
||||
return {
|
||||
unified: getWorkerPool("unified"),
|
||||
split: getWorkerPool("split"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user