mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-22 16:54:29 +00:00
Compare commits
2 Commits
config-spl
...
composer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73c619ee46 | ||
|
|
6ef6d538b2 |
@@ -44,6 +44,17 @@ function aggregate(comments: Record<string, LineComment[]>) {
|
||||
.sort((a, b) => a.time - b.time)
|
||||
}
|
||||
|
||||
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
|
||||
const next: SelectedLineRange = {
|
||||
start: selection.start,
|
||||
end: selection.end,
|
||||
}
|
||||
|
||||
if (selection.side) next.side = selection.side
|
||||
if (selection.endSide) next.endSide = selection.endSide
|
||||
return next
|
||||
}
|
||||
|
||||
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
|
||||
const [state, setState] = createStore({
|
||||
focus: null as CommentFocus | null,
|
||||
@@ -70,6 +81,7 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
||||
id: uuid(),
|
||||
time: Date.now(),
|
||||
...input,
|
||||
selection: cloneSelection(input.selection),
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
|
||||
@@ -9,7 +9,7 @@ const MAX_FILE_VIEW_SESSIONS = 20
|
||||
const MAX_VIEW_FILES = 500
|
||||
|
||||
function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
|
||||
if (range.start <= range.end) return range
|
||||
if (range.start <= range.end) return { ...range }
|
||||
|
||||
const startSide = range.side
|
||||
const endSide = range.endSide ?? startSide
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
||||
import { createHoverCommentUtility } from "@opencode-ai/ui/pierre/comment-hover"
|
||||
import { cloneSelectedLineRange, lineInSelectedRange } from "@opencode-ai/ui/pierre/selection-bridge"
|
||||
import {
|
||||
createLineCommentAnnotationRenderer,
|
||||
type LineCommentAnnotationMeta,
|
||||
} from "@opencode-ai/ui/line-comment-annotations"
|
||||
import { sampledChecksum } from "@opencode-ai/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
@@ -97,11 +102,11 @@ export function FileTabContent(props: { tab: string }) {
|
||||
const c = state()?.content
|
||||
return `data:${c?.mimeType};base64,${c?.content}`
|
||||
})
|
||||
const selectedLines = createMemo(() => {
|
||||
const selectedLines = createMemo<SelectedLineRange | null>(() => {
|
||||
const p = path()
|
||||
if (!p) return null
|
||||
if (file.ready()) return file.selectedLines(p) ?? null
|
||||
return getSessionHandoff(sessionKey())?.files[p] ?? null
|
||||
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
|
||||
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
|
||||
})
|
||||
|
||||
const selectionPreview = (source: string, selection: FileSelection) => {
|
||||
@@ -145,127 +150,148 @@ export function FileTabContent(props: { tab: string }) {
|
||||
})
|
||||
}
|
||||
|
||||
let wrap: HTMLDivElement | undefined
|
||||
|
||||
const fileComments = createMemo(() => {
|
||||
const p = path()
|
||||
if (!p) return []
|
||||
return comments.list(p)
|
||||
})
|
||||
|
||||
const commentLayout = createMemo(() => {
|
||||
return fileComments()
|
||||
.map((comment) => `${comment.id}:${comment.selection.start}:${comment.selection.end}`)
|
||||
.join("|")
|
||||
})
|
||||
|
||||
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
|
||||
|
||||
type Annotation = LineCommentAnnotationMeta<ReturnType<typeof fileComments>[number]>
|
||||
|
||||
const [note, setNote] = createStore({
|
||||
openedComment: null as string | null,
|
||||
commenting: null as SelectedLineRange | null,
|
||||
selected: null as SelectedLineRange | null,
|
||||
draft: "",
|
||||
positions: {} as Record<string, number>,
|
||||
draftTop: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const activeSelection = () => note.selected ?? selectedLines()
|
||||
|
||||
const setCommenting = (range: SelectedLineRange | null) => {
|
||||
setNote("commenting", range)
|
||||
scheduleComments()
|
||||
if (!range) return
|
||||
setNote("draft", "")
|
||||
setNote("commenting", range ? cloneSelectedLineRange(range) : null)
|
||||
}
|
||||
|
||||
const getRoot = () => {
|
||||
const el = wrap
|
||||
if (!el) return
|
||||
createEffect(
|
||||
on(
|
||||
path,
|
||||
() => {
|
||||
setNote("selected", null)
|
||||
setNote("openedComment", null)
|
||||
setNote("commenting", null)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
const annotationLine = (range: SelectedLineRange) => Math.max(range.start, range.end)
|
||||
const annotations = createMemo(() => {
|
||||
const list = fileComments().map((comment) => ({
|
||||
lineNumber: annotationLine(comment.selection),
|
||||
metadata: {
|
||||
kind: "comment",
|
||||
key: `comment:${comment.id}`,
|
||||
comment,
|
||||
} satisfies Annotation,
|
||||
}))
|
||||
|
||||
const root = host.shadowRoot
|
||||
if (!root) return
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
|
||||
const line = Math.max(range.start, range.end)
|
||||
const node = root.querySelector(`[data-line="${line}"]`)
|
||||
if (!(node instanceof HTMLElement)) return
|
||||
return node
|
||||
}
|
||||
|
||||
const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const rect = marker.getBoundingClientRect()
|
||||
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
|
||||
}
|
||||
|
||||
const updateComments = () => {
|
||||
const el = wrap
|
||||
const root = getRoot()
|
||||
if (!el || !root) {
|
||||
setNote("positions", {})
|
||||
setNote("draftTop", undefined)
|
||||
return
|
||||
if (note.commenting) {
|
||||
return [
|
||||
...list,
|
||||
{
|
||||
lineNumber: annotationLine(note.commenting),
|
||||
metadata: {
|
||||
kind: "draft",
|
||||
key: `draft:${path() ?? props.tab}`,
|
||||
range: note.commenting,
|
||||
} satisfies Annotation,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const estimateTop = (range: SelectedLineRange) => {
|
||||
const line = Math.max(range.start, range.end)
|
||||
const height = 24
|
||||
const offset = 2
|
||||
return Math.max(0, (line - 1) * height + offset)
|
||||
}
|
||||
const range = activeSelection()
|
||||
if (!range || note.openedComment) return list
|
||||
return list
|
||||
})
|
||||
|
||||
const large = contents().length > 500_000
|
||||
const annotationRenderer = createLineCommentAnnotationRenderer<ReturnType<typeof fileComments>[number]>({
|
||||
renderComment: (comment) => ({
|
||||
id: comment.id,
|
||||
open: note.openedComment === comment.id,
|
||||
comment: comment.comment,
|
||||
selection: formatCommentLabel(comment.selection),
|
||||
onMouseEnter: () => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, cloneSelectedLineRange(comment.selection))
|
||||
},
|
||||
onClick: () => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
setCommenting(null)
|
||||
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
|
||||
file.setSelectedLines(p, cloneSelectedLineRange(comment.selection))
|
||||
},
|
||||
}),
|
||||
renderDraft: (range) => ({
|
||||
value: note.draft,
|
||||
selection: formatCommentLabel(range),
|
||||
onInput: (value) => setNote("draft", value),
|
||||
onCancel: () => setCommenting(null),
|
||||
onSubmit: (value) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
addCommentToContext({ file: p, selection: range, comment: value, origin: "file" })
|
||||
setCommenting(null)
|
||||
},
|
||||
onPopoverFocusOut: (e: FocusEvent) => {
|
||||
const current = e.currentTarget as HTMLDivElement
|
||||
const target = e.relatedTarget
|
||||
if (target instanceof Node && current.contains(target)) return
|
||||
|
||||
const next: Record<string, number> = {}
|
||||
for (const comment of fileComments()) {
|
||||
const marker = findMarker(root, comment.selection)
|
||||
if (marker) next[comment.id] = markerTop(el, marker)
|
||||
else if (large) next[comment.id] = estimateTop(comment.selection)
|
||||
}
|
||||
|
||||
const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
|
||||
const changed = Object.entries(next).filter(([id, top]) => note.positions[id] !== top)
|
||||
if (removed.length > 0 || changed.length > 0) {
|
||||
setNote(
|
||||
"positions",
|
||||
produce((draft) => {
|
||||
for (const id of removed) {
|
||||
delete draft[id]
|
||||
setTimeout(() => {
|
||||
if (!document.activeElement || !current.contains(document.activeElement)) {
|
||||
setCommenting(null)
|
||||
}
|
||||
}, 0)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
for (const [id, top] of changed) {
|
||||
draft[id] = top
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
const renderAnnotation = annotationRenderer.render
|
||||
|
||||
const range = note.commenting
|
||||
if (!range) {
|
||||
setNote("draftTop", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const marker = findMarker(root, range)
|
||||
if (marker) {
|
||||
setNote("draftTop", markerTop(el, marker))
|
||||
return
|
||||
}
|
||||
|
||||
setNote("draftTop", large ? estimateTop(range) : undefined)
|
||||
const openDraft = (range: SelectedLineRange) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
const next = cloneSelectedLineRange(range)
|
||||
setNote("openedComment", null)
|
||||
setNote("selected", next)
|
||||
file.setSelectedLines(p, cloneSelectedLineRange(next))
|
||||
setCommenting(next)
|
||||
}
|
||||
|
||||
const scheduleComments = () => {
|
||||
requestAnimationFrame(updateComments)
|
||||
}
|
||||
const renderHoverUtility = (getHoveredLine: () => { lineNumber: number; side?: "additions" | "deletions" }) =>
|
||||
createHoverCommentUtility({
|
||||
label: language.t("ui.lineComment.submit"),
|
||||
getHoveredLine,
|
||||
onSelect: (hovered) => {
|
||||
const selected = note.openedComment ? null : activeSelection()
|
||||
const range =
|
||||
selected && lineInSelectedRange(selected, hovered.lineNumber, hovered.side)
|
||||
? cloneSelectedLineRange(selected)
|
||||
: { start: hovered.lineNumber, end: hovered.lineNumber }
|
||||
openDraft(range)
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
commentLayout()
|
||||
scheduleComments()
|
||||
annotationRenderer.reconcile(annotations())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
annotationRenderer.cleanup()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -279,8 +305,9 @@ export function FileTabContent(props: { tab: string }) {
|
||||
if (!target) return
|
||||
|
||||
setNote("openedComment", target.id)
|
||||
setNote("selected", cloneSelectedLineRange(target.selection))
|
||||
setCommenting(null)
|
||||
file.setSelectedLines(p, target.selection)
|
||||
file.setSelectedLines(p, cloneSelectedLineRange(target.selection))
|
||||
requestAnimationFrame(() => comments.clearFocus())
|
||||
})
|
||||
|
||||
@@ -414,13 +441,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||
})
|
||||
|
||||
const renderCode = (source: string, wrapperClass: string) => (
|
||||
<div
|
||||
ref={(el) => {
|
||||
wrap = el
|
||||
scheduleComments()
|
||||
}}
|
||||
class={`relative overflow-hidden ${wrapperClass}`}
|
||||
>
|
||||
<div class={`relative overflow-hidden ${wrapperClass}`}>
|
||||
<Dynamic
|
||||
component={codeComponent}
|
||||
file={{
|
||||
@@ -429,83 +450,39 @@ export function FileTabContent(props: { tab: string }) {
|
||||
cacheKey: cacheKey(),
|
||||
}}
|
||||
enableLineSelection
|
||||
selectedLines={selectedLines()}
|
||||
enableHoverUtility
|
||||
selectedLines={activeSelection()}
|
||||
commentedLines={commentedLines()}
|
||||
onRendered={() => {
|
||||
requestAnimationFrame(restoreScroll)
|
||||
requestAnimationFrame(scheduleComments)
|
||||
}}
|
||||
annotations={annotations()}
|
||||
renderAnnotation={renderAnnotation}
|
||||
renderHoverUtility={renderHoverUtility}
|
||||
onLineSelected={(range: SelectedLineRange | null) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, range)
|
||||
if (!range) setCommenting(null)
|
||||
setNote("selected", range ? cloneSelectedLineRange(range) : null)
|
||||
}}
|
||||
onLineNumberSelectionEnd={(range: SelectedLineRange | null) => {
|
||||
if (!range) return
|
||||
openDraft(range)
|
||||
}}
|
||||
onLineSelectionEnd={(range: SelectedLineRange | null) => {
|
||||
if (!range) {
|
||||
const next = range ? cloneSelectedLineRange(range) : null
|
||||
setNote("selected", next)
|
||||
const p = path()
|
||||
if (p) file.setSelectedLines(p, next ? cloneSelectedLineRange(next) : null)
|
||||
|
||||
if (!next) {
|
||||
setCommenting(null)
|
||||
return
|
||||
}
|
||||
|
||||
setNote("openedComment", null)
|
||||
setCommenting(range)
|
||||
setCommenting(null)
|
||||
}}
|
||||
overflow="scroll"
|
||||
class="select-text"
|
||||
/>
|
||||
<For each={fileComments()}>
|
||||
{(comment) => (
|
||||
<LineCommentView
|
||||
id={comment.id}
|
||||
top={note.positions[comment.id]}
|
||||
open={note.openedComment === comment.id}
|
||||
comment={comment.comment}
|
||||
selection={formatCommentLabel(comment.selection)}
|
||||
onMouseEnter={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
onClick={() => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
setCommenting(null)
|
||||
setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
|
||||
file.setSelectedLines(p, comment.selection)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={note.commenting}>
|
||||
{(range) => (
|
||||
<Show when={note.draftTop !== undefined}>
|
||||
<LineCommentEditor
|
||||
top={note.draftTop}
|
||||
value={note.draft}
|
||||
selection={formatCommentLabel(range())}
|
||||
onInput={(value) => setNote("draft", value)}
|
||||
onCancel={() => setCommenting(null)}
|
||||
onSubmit={(value) => {
|
||||
const p = path()
|
||||
if (!p) return
|
||||
addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
|
||||
setCommenting(null)
|
||||
}}
|
||||
onPopoverFocusOut={(e: FocusEvent) => {
|
||||
const current = e.currentTarget as HTMLDivElement
|
||||
const target = e.relatedTarget
|
||||
if (target instanceof Node && current.contains(target)) return
|
||||
|
||||
setTimeout(() => {
|
||||
if (!document.activeElement || !current.contains(document.activeElement)) {
|
||||
setCommenting(null)
|
||||
}
|
||||
}, 0)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { createDefaultOptions, styleVariables } from "../pierre"
|
||||
import { markCommentedFileLines } from "../pierre/commented-lines"
|
||||
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
|
||||
import { getWorkerPool } from "../pierre/worker"
|
||||
import { Icon } from "./icon"
|
||||
|
||||
@@ -29,6 +31,7 @@ export type CodeProps<T = {}> = FileOptions<T> & {
|
||||
annotations?: LineAnnotation<T>[]
|
||||
selectedLines?: SelectedLineRange | null
|
||||
commentedLines?: SelectedLineRange[]
|
||||
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
|
||||
onRendered?: () => void
|
||||
onLineSelectionEnd?: (selection: SelectedLineRange | null) => void
|
||||
class?: string
|
||||
@@ -155,6 +158,7 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
let dragMoved = false
|
||||
let lastSelection: SelectedLineRange | null = null
|
||||
let pendingSelectionEnd = false
|
||||
const bridge = createLineNumberSelectionBridge()
|
||||
|
||||
const [local, others] = splitProps(props, [
|
||||
"file",
|
||||
@@ -163,6 +167,9 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
"annotations",
|
||||
"selectedLines",
|
||||
"commentedLines",
|
||||
"onLineSelected",
|
||||
"onLineSelectionEnd",
|
||||
"onLineNumberSelectionEnd",
|
||||
"onRendered",
|
||||
])
|
||||
|
||||
@@ -198,6 +205,16 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
const options = createMemo(() => ({
|
||||
...createDefaultOptions<T>("unified"),
|
||||
...others,
|
||||
onLineSelected: (range: SelectedLineRange | null) => {
|
||||
lastSelection = range
|
||||
local.onLineSelected?.(range)
|
||||
},
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => {
|
||||
lastSelection = range
|
||||
local.onLineSelectionEnd?.(range)
|
||||
if (!bridge.consume(range)) return
|
||||
requestAnimationFrame(() => local.onLineNumberSelectionEnd?.(range))
|
||||
},
|
||||
}))
|
||||
|
||||
const getRoot = () => {
|
||||
@@ -556,41 +573,6 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
})
|
||||
})
|
||||
|
||||
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 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 = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
|
||||
if (Number.isNaN(line)) continue
|
||||
if (line < start || line > end) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = () => {
|
||||
const value = local.file.contents as unknown
|
||||
if (typeof value === "string") return value
|
||||
@@ -718,7 +700,7 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
observer.observe(container, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
const updateSelection = () => {
|
||||
const updateSelection = (preserveTextSelection = false) => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
@@ -757,6 +739,9 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
if (endSide && side && endSide !== side) selected.endSide = endSide
|
||||
|
||||
setSelectedLines(selected)
|
||||
|
||||
if (!preserveTextSelection || !domRange) return
|
||||
restoreShadowTextSelection(root, domRange.cloneRange())
|
||||
}
|
||||
|
||||
const setSelectedLines = (range: SelectedLineRange | null) => {
|
||||
@@ -769,11 +754,12 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
|
||||
selectionFrame = requestAnimationFrame(() => {
|
||||
selectionFrame = undefined
|
||||
updateSelection()
|
||||
const finishing = pendingSelectionEnd
|
||||
updateSelection(finishing)
|
||||
|
||||
if (!pendingSelectionEnd) return
|
||||
pendingSelectionEnd = false
|
||||
props.onLineSelectionEnd?.(lastSelection)
|
||||
local.onLineSelectionEnd?.(lastSelection)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -822,9 +808,13 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
if (event.button !== 0) return
|
||||
|
||||
const { line, numberColumn } = lineFromMouseEvent(event)
|
||||
if (numberColumn) return
|
||||
if (numberColumn) {
|
||||
bridge.begin(true, line)
|
||||
return
|
||||
}
|
||||
if (line === undefined) return
|
||||
|
||||
bridge.begin(false, line)
|
||||
dragStart = line
|
||||
dragEnd = line
|
||||
dragMoved = false
|
||||
@@ -832,16 +822,21 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
|
||||
const next = lineFromMouseEvent(event)
|
||||
if (bridge.track(event.buttons, next.line)) return
|
||||
|
||||
if (dragStart === undefined) return
|
||||
|
||||
if ((event.buttons & 1) === 0) {
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragMoved = false
|
||||
bridge.finish()
|
||||
return
|
||||
}
|
||||
|
||||
const { line } = lineFromMouseEvent(event)
|
||||
const { line } = next
|
||||
if (line === undefined) return
|
||||
|
||||
dragEnd = line
|
||||
@@ -851,13 +846,18 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
|
||||
if (bridge.finish() === "numbers") {
|
||||
return
|
||||
}
|
||||
|
||||
if (dragStart === undefined) return
|
||||
|
||||
if (!dragMoved) {
|
||||
pendingSelectionEnd = false
|
||||
const line = dragStart
|
||||
setSelectedLines({ start: line, end: line })
|
||||
props.onLineSelectionEnd?.(lastSelection)
|
||||
local.onLineSelectionEnd?.(lastSelection)
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragMoved = false
|
||||
@@ -920,7 +920,7 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
const value = text()
|
||||
instance.render({
|
||||
file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents: value },
|
||||
lineAnnotations: local.annotations,
|
||||
lineAnnotations: [],
|
||||
containerWrapper: container,
|
||||
})
|
||||
|
||||
@@ -942,10 +942,22 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
onCleanup(() => monitor.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
rendered()
|
||||
const active = instance
|
||||
if (!active) return
|
||||
active.setLineAnnotations(local.annotations ?? [])
|
||||
active.rerender()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
rendered()
|
||||
const ranges = local.commentedLines ?? []
|
||||
requestAnimationFrame(() => applyCommentedLines(ranges))
|
||||
requestAnimationFrame(() => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
markCommentedFileLines(root, ranges)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -998,6 +1010,7 @@ export function Code<T>(props: CodeProps<T>) {
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragMoved = false
|
||||
bridge.reset()
|
||||
lastSelection = null
|
||||
pendingSelectionEnd = false
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { findDiffSide, markCommentedDiffLines } from "../pierre/commented-lines"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { useWorkerPool } from "../context/worker-pool"
|
||||
|
||||
@@ -21,6 +22,7 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
||||
"annotations",
|
||||
"selectedLines",
|
||||
"commentedLines",
|
||||
"onLineNumberSelectionEnd",
|
||||
])
|
||||
const workerPool = useWorkerPool(props.diffStyle)
|
||||
|
||||
@@ -72,7 +74,7 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
||||
const targetSide = side ?? "additions"
|
||||
|
||||
for (const node of nodes) {
|
||||
if (findSide(node) === targetSide) return lineIndex(split, node)
|
||||
if (findDiffSide(node) === targetSide) return lineIndex(split, node)
|
||||
if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node)
|
||||
}
|
||||
}
|
||||
@@ -121,100 +123,6 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
||||
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
|
||||
|
||||
@@ -261,7 +169,10 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
||||
setSelectedLines(local.selectedLines ?? null)
|
||||
|
||||
createEffect(() => {
|
||||
fileDiffInstance?.setLineAnnotations(local.annotations ?? [])
|
||||
const diff = fileDiffInstance
|
||||
if (!diff) return
|
||||
diff.setLineAnnotations(local.annotations ?? [])
|
||||
diff.rerender()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -270,7 +181,11 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
||||
|
||||
createEffect(() => {
|
||||
const ranges = local.commentedLines ?? []
|
||||
requestAnimationFrame(() => applyCommentedLines(ranges))
|
||||
requestAnimationFrame(() => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
markCommentedDiffLines(root, ranges)
|
||||
})
|
||||
})
|
||||
|
||||
// Hydrate annotation slots with interactive SolidJS components
|
||||
|
||||
@@ -3,6 +3,8 @@ import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFile
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
|
||||
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
|
||||
import { findDiffSide, markCommentedDiffLines } from "../pierre/commented-lines"
|
||||
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
|
||||
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
|
||||
import { getWorkerPool } from "../pierre/worker"
|
||||
|
||||
@@ -35,19 +37,7 @@ function findLineNumber(node: Node | null): number | undefined {
|
||||
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"
|
||||
return findDiffSide(element)
|
||||
}
|
||||
|
||||
export function Diff<T>(props: DiffProps<T>) {
|
||||
@@ -64,6 +54,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
let dragMoved = false
|
||||
let lastSelection: SelectedLineRange | null = null
|
||||
let pendingSelectionEnd = false
|
||||
const bridge = createLineNumberSelectionBridge()
|
||||
|
||||
const [local, others] = splitProps(props, [
|
||||
"before",
|
||||
@@ -73,6 +64,9 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
"annotations",
|
||||
"selectedLines",
|
||||
"commentedLines",
|
||||
"onLineSelected",
|
||||
"onLineSelectionEnd",
|
||||
"onLineNumberSelectionEnd",
|
||||
"onRendered",
|
||||
])
|
||||
|
||||
@@ -94,6 +88,20 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
const base = {
|
||||
...createDefaultOptions(props.diffStyle),
|
||||
...others,
|
||||
onLineSelected: (range: SelectedLineRange | null) => {
|
||||
const fixed = fixSelection(range)
|
||||
const next = fixed === undefined ? range : fixed
|
||||
lastSelection = next
|
||||
local.onLineSelected?.(next)
|
||||
},
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => {
|
||||
const fixed = fixSelection(range)
|
||||
const next = fixed === undefined ? range : fixed
|
||||
lastSelection = next
|
||||
local.onLineSelectionEnd?.(next)
|
||||
if (!bridge.consume(next)) return
|
||||
requestAnimationFrame(() => local.onLineNumberSelectionEnd?.(next))
|
||||
},
|
||||
}
|
||||
|
||||
const perf = large() ? { ...base, ...largeOptions } : base
|
||||
@@ -278,61 +286,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
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 setSelectedLines = (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => {
|
||||
const active = current()
|
||||
if (!active) return
|
||||
|
||||
@@ -344,9 +298,10 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
|
||||
lastSelection = fixed
|
||||
active.setSelectedLines(fixed)
|
||||
restoreShadowTextSelection(preserve?.root, preserve?.text)
|
||||
}
|
||||
|
||||
const updateSelection = () => {
|
||||
const updateSelection = (preserveTextSelection = false) => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
@@ -384,6 +339,12 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
if (side) selected.side = side
|
||||
if (endSide && side && endSide !== side) selected.endSide = endSide
|
||||
|
||||
const text = preserveTextSelection && domRange ? domRange.cloneRange() : undefined
|
||||
if (text) {
|
||||
setSelectedLines(selected, { root, text })
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedLines(selected)
|
||||
}
|
||||
|
||||
@@ -392,11 +353,12 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
|
||||
selectionFrame = requestAnimationFrame(() => {
|
||||
selectionFrame = undefined
|
||||
updateSelection()
|
||||
const finishing = pendingSelectionEnd
|
||||
updateSelection(finishing)
|
||||
|
||||
if (!pendingSelectionEnd) return
|
||||
pendingSelectionEnd = false
|
||||
props.onLineSelectionEnd?.(lastSelection)
|
||||
local.onLineSelectionEnd?.(lastSelection)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -466,9 +428,13 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
if (event.button !== 0) return
|
||||
|
||||
const { line, numberColumn, side } = lineFromMouseEvent(event)
|
||||
if (numberColumn) return
|
||||
if (numberColumn) {
|
||||
bridge.begin(true, line)
|
||||
return
|
||||
}
|
||||
if (line === undefined) return
|
||||
|
||||
bridge.begin(false, line)
|
||||
dragStart = line
|
||||
dragEnd = line
|
||||
dragSide = side
|
||||
@@ -478,6 +444,10 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
|
||||
const next = lineFromMouseEvent(event)
|
||||
if (bridge.track(event.buttons, next.line)) return
|
||||
|
||||
if (dragStart === undefined) return
|
||||
|
||||
if ((event.buttons & 1) === 0) {
|
||||
@@ -486,10 +456,11 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
dragSide = undefined
|
||||
dragEndSide = undefined
|
||||
dragMoved = false
|
||||
bridge.finish()
|
||||
return
|
||||
}
|
||||
|
||||
const { line, side } = lineFromMouseEvent(event)
|
||||
const { line, side } = next
|
||||
if (line === undefined) return
|
||||
|
||||
dragEnd = line
|
||||
@@ -500,6 +471,11 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (props.enableLineSelection !== true) return
|
||||
|
||||
if (bridge.finish() === "numbers") {
|
||||
return
|
||||
}
|
||||
|
||||
if (dragStart === undefined) return
|
||||
|
||||
if (!dragMoved) {
|
||||
@@ -511,7 +487,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
}
|
||||
if (dragSide) selected.side = dragSide
|
||||
setSelectedLines(selected)
|
||||
props.onLineSelectionEnd?.(lastSelection)
|
||||
local.onLineSelectionEnd?.(lastSelection)
|
||||
dragStart = undefined
|
||||
dragEnd = undefined
|
||||
dragSide = undefined
|
||||
@@ -545,7 +521,6 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
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 : ""
|
||||
|
||||
@@ -572,7 +547,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
contents: afterContents,
|
||||
cacheKey: cacheKey(afterContents),
|
||||
},
|
||||
lineAnnotations: annotations,
|
||||
lineAnnotations: [],
|
||||
containerWrapper: container,
|
||||
})
|
||||
|
||||
@@ -594,10 +569,22 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
onCleanup(() => monitor.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
rendered()
|
||||
const active = current()
|
||||
if (!active) return
|
||||
active.setLineAnnotations(local.annotations ?? [])
|
||||
active.rerender()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
rendered()
|
||||
const ranges = local.commentedLines ?? []
|
||||
requestAnimationFrame(() => applyCommentedLines(ranges))
|
||||
requestAnimationFrame(() => {
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
markCommentedDiffLines(root, ranges)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -639,6 +626,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
||||
dragSide = undefined
|
||||
dragEndSide = undefined
|
||||
dragMoved = false
|
||||
bridge.reset()
|
||||
lastSelection = null
|
||||
pendingSelectionEnd = false
|
||||
|
||||
|
||||
107
packages/ui/src/components/line-comment-annotations.tsx
Normal file
107
packages/ui/src/components/line-comment-annotations.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
import { createMemo, createSignal, type JSX } from "solid-js"
|
||||
import { render as renderSolid } from "solid-js/web"
|
||||
import { LineComment, LineCommentEditor } from "./line-comment"
|
||||
|
||||
export type LineCommentAnnotationMeta<T> =
|
||||
| { kind: "comment"; key: string; comment: T }
|
||||
| { kind: "draft"; key: string; range: SelectedLineRange }
|
||||
|
||||
type CommentProps = {
|
||||
id?: string
|
||||
open: boolean
|
||||
comment: JSX.Element
|
||||
selection: JSX.Element
|
||||
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>
|
||||
}
|
||||
|
||||
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")
|
||||
const [current, setCurrent] = createSignal(meta)
|
||||
const view = createMemo<JSX.Element>(() => {
|
||||
const next = current()
|
||||
|
||||
if (next.kind === "comment") {
|
||||
const view = props.renderComment(next.comment)
|
||||
return (
|
||||
<LineComment
|
||||
inline
|
||||
id={view.id}
|
||||
open={view.open}
|
||||
comment={view.comment}
|
||||
selection={view.selection}
|
||||
onClick={view.onClick}
|
||||
onMouseEnter={view.onMouseEnter}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const view = 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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
const dispose = renderSolid(() => <>{view()}</>, 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 }
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
export const lineCommentStyles = `
|
||||
[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: inline-flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-open] {
|
||||
z-index: var(--line-comment-open-z, 100);
|
||||
}
|
||||
@@ -21,10 +29,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;
|
||||
}
|
||||
@@ -46,6 +64,21 @@
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
[data-component="line-comment"][data-inline] [data-slot="line-comment-popover"] {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
[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));
|
||||
@@ -113,3 +146,50 @@
|
||||
[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,26 +1,54 @@
|
||||
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
|
||||
@@ -28,25 +56,65 @@ export const LineCommentAnchor = (props: LineCommentAnchorProps) => {
|
||||
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>
|
||||
@@ -65,7 +133,7 @@ export const LineComment = (props: LineCommentProps) => {
|
||||
const [split, rest] = splitProps(props, ["comment", "selection"])
|
||||
|
||||
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-label">
|
||||
@@ -78,6 +146,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 +196,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 +216,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 +225,24 @@ 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
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
split.onCancel()
|
||||
return
|
||||
}
|
||||
if (e.key !== "Enter") return
|
||||
if (e.shiftKey) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
submit()
|
||||
}}
|
||||
/>
|
||||
@@ -155,12 +252,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>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { RadioGroup } from "./radio-group"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { Icon } from "./icon"
|
||||
import { LineComment, LineCommentEditor } from "./line-comment"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { ScrollView } from "./scroll-view"
|
||||
@@ -12,12 +11,15 @@ import { useDiffComponent } from "../context/diff"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
import { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { createHoverCommentUtility } from "../pierre/comment-hover"
|
||||
import { cloneSelectedLineRange, lineInSelectedRange } from "../pierre/selection-bridge"
|
||||
import { createLineCommentAnnotationRenderer, type LineCommentAnnotationMeta } from "./line-comment-annotations"
|
||||
|
||||
const MAX_DIFF_CHANGED_LINES = 500
|
||||
|
||||
@@ -137,42 +139,7 @@ type SessionReviewSelection = {
|
||||
range: SelectedLineRange
|
||||
}
|
||||
|
||||
function findSide(element: HTMLElement): "additions" | "deletions" | undefined {
|
||||
const typed = element.closest("[data-line-type]")
|
||||
if (typed instanceof HTMLElement) {
|
||||
const type = typed.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
|
||||
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
|
||||
}
|
||||
|
||||
function findMarker(root: ShadowRoot, range: SelectedLineRange) {
|
||||
const marker = (line: number, side?: "additions" | "deletions") => {
|
||||
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
|
||||
if (!side) return nodes[0]
|
||||
const match = nodes.find((node) => findSide(node) === side)
|
||||
return match ?? nodes[0]
|
||||
}
|
||||
|
||||
const a = marker(range.start, range.side)
|
||||
const b = marker(range.end, range.endSide ?? range.side)
|
||||
if (!a) return b
|
||||
if (!b) return a
|
||||
return a.getBoundingClientRect().top > b.getBoundingClientRect().top ? a : b
|
||||
}
|
||||
|
||||
function markerTop(wrapper: HTMLElement, marker: HTMLElement) {
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const rect = marker.getBoundingClientRect()
|
||||
return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
|
||||
}
|
||||
type SessionReviewAnnotation = LineCommentAnnotationMeta<SessionReviewComment>
|
||||
|
||||
export const SessionReview = (props: SessionReviewProps) => {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
@@ -236,7 +203,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
setOpened(focus)
|
||||
|
||||
const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id)
|
||||
if (comment) setSelection({ file: comment.file, range: comment.selection })
|
||||
if (comment) setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) })
|
||||
|
||||
const current = open()
|
||||
if (!current.includes(focus.file)) {
|
||||
@@ -249,11 +216,11 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const root = scroll
|
||||
if (!root) return
|
||||
|
||||
const anchor = root.querySelector(`[data-comment-id="${focus.id}"]`)
|
||||
const ready =
|
||||
anchor instanceof HTMLElement && anchor.style.pointerEvents !== "none" && anchor.style.opacity !== "0"
|
||||
const wrapper = anchors.get(focus.file)
|
||||
const anchor = wrapper?.querySelector(`[data-comment-id="${focus.id}"]`)
|
||||
const ready = anchor instanceof HTMLElement
|
||||
|
||||
const target = ready ? anchor : anchors.get(focus.file)
|
||||
const target = ready ? anchor : wrapper
|
||||
if (!target) {
|
||||
if (attempt >= 120) return
|
||||
requestAnimationFrame(() => scrollTo(attempt + 1))
|
||||
@@ -376,51 +343,114 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
})
|
||||
|
||||
const [draft, setDraft] = createSignal("")
|
||||
const [positions, setPositions] = createSignal<Record<string, number>>({})
|
||||
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
||||
|
||||
const getRoot = () => {
|
||||
const el = wrapper
|
||||
if (!el) return
|
||||
const annotationLine = (range: SelectedLineRange) => Math.max(range.start, range.end)
|
||||
const annotationSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
|
||||
const selected = () => selectedLines()
|
||||
|
||||
const host = el.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
return host.shadowRoot ?? undefined
|
||||
}
|
||||
|
||||
const updateAnchors = () => {
|
||||
const el = wrapper
|
||||
if (!el) return
|
||||
|
||||
const root = getRoot()
|
||||
if (!root) return
|
||||
|
||||
const next: Record<string, number> = {}
|
||||
for (const item of comments()) {
|
||||
const marker = findMarker(root, item.selection)
|
||||
if (!marker) continue
|
||||
next[item.id] = markerTop(el, marker)
|
||||
}
|
||||
setPositions(next)
|
||||
const annotations = createMemo<DiffLineAnnotation<SessionReviewAnnotation>[]>(() => {
|
||||
const list = comments().map((comment) => ({
|
||||
side: annotationSide(comment.selection),
|
||||
lineNumber: annotationLine(comment.selection),
|
||||
metadata: {
|
||||
kind: "comment",
|
||||
key: `comment:${comment.id}`,
|
||||
comment,
|
||||
} satisfies SessionReviewAnnotation,
|
||||
}))
|
||||
|
||||
const range = draftRange()
|
||||
if (!range) {
|
||||
setDraftTop(undefined)
|
||||
return
|
||||
if (range) {
|
||||
return [
|
||||
...list,
|
||||
{
|
||||
side: annotationSide(range),
|
||||
lineNumber: annotationLine(range),
|
||||
metadata: {
|
||||
kind: "draft",
|
||||
key: `draft:${file}`,
|
||||
range,
|
||||
} satisfies SessionReviewAnnotation,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const marker = findMarker(root, range)
|
||||
if (!marker) {
|
||||
setDraftTop(undefined)
|
||||
return
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
setDraftTop(markerTop(el, marker))
|
||||
}
|
||||
const annotationRenderer = createLineCommentAnnotationRenderer<SessionReviewComment>({
|
||||
renderComment: (comment) => ({
|
||||
id: comment.id,
|
||||
open: isCommentOpen(comment),
|
||||
comment: comment.comment,
|
||||
selection: selectionLabel(comment.selection),
|
||||
onMouseEnter: () =>
|
||||
setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) }),
|
||||
onClick: () => {
|
||||
if (isCommentOpen(comment)) {
|
||||
setOpened(null)
|
||||
return
|
||||
}
|
||||
|
||||
const scheduleAnchors = () => {
|
||||
requestAnimationFrame(updateAnchors)
|
||||
}
|
||||
openComment(comment)
|
||||
},
|
||||
}),
|
||||
renderDraft: (range) => ({
|
||||
value: draft(),
|
||||
selection: selectionLabel(range),
|
||||
onInput: setDraft,
|
||||
onCancel: () => {
|
||||
setDraft("")
|
||||
setCommenting(null)
|
||||
},
|
||||
onSubmit: (comment) => {
|
||||
props.onLineComment?.({
|
||||
file,
|
||||
selection: cloneSelectedLineRange(range),
|
||||
comment,
|
||||
preview: selectionPreview(item(), range),
|
||||
})
|
||||
setDraft("")
|
||||
setCommenting(null)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const renderAnnotation = (annotation: DiffLineAnnotation<SessionReviewAnnotation>) =>
|
||||
annotationRenderer.render(annotation)
|
||||
|
||||
const renderHoverUtility = (
|
||||
getHoveredLine: () => { lineNumber: number; side?: "additions" | "deletions" },
|
||||
) =>
|
||||
createHoverCommentUtility({
|
||||
label: i18n.t("ui.lineComment.submit"),
|
||||
getHoveredLine,
|
||||
onSelect: (hovered) => {
|
||||
const current = opened()?.file === file ? null : selected()
|
||||
const range = (() => {
|
||||
if (current && lineInSelectedRange(current, hovered.lineNumber, hovered.side)) {
|
||||
return cloneSelectedLineRange(current)
|
||||
}
|
||||
const next: SelectedLineRange = {
|
||||
start: hovered.lineNumber,
|
||||
end: hovered.lineNumber,
|
||||
}
|
||||
if (hovered.side) next.side = hovered.side
|
||||
return next
|
||||
})()
|
||||
|
||||
openDraft(range)
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
annotationRenderer.reconcile(annotations())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
anchors.delete(file)
|
||||
annotationRenderer.cleanup()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!isImage()) return
|
||||
@@ -438,15 +468,8 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
comments()
|
||||
scheduleAnchors()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const range = draftRange()
|
||||
if (!range) return
|
||||
draftRange()
|
||||
setDraft("")
|
||||
scheduleAnchors()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -506,27 +529,39 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
|
||||
if (!range) {
|
||||
setSelection(null)
|
||||
setDraft("")
|
||||
setCommenting(null)
|
||||
return
|
||||
}
|
||||
|
||||
setSelection({ file, range })
|
||||
setSelection({ file, range: cloneSelectedLineRange(range) })
|
||||
}
|
||||
|
||||
const openDraft = (range: SelectedLineRange) => {
|
||||
const next = cloneSelectedLineRange(range)
|
||||
setOpened(null)
|
||||
setSelection({ file, range: cloneSelectedLineRange(next) })
|
||||
setCommenting({ file, range: next })
|
||||
}
|
||||
|
||||
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
|
||||
if (!props.onLineComment) return
|
||||
|
||||
if (!range) {
|
||||
setDraft("")
|
||||
setCommenting(null)
|
||||
return
|
||||
}
|
||||
|
||||
setSelection({ file, range })
|
||||
setCommenting({ file, range })
|
||||
const next = cloneSelectedLineRange(range)
|
||||
setOpened(null)
|
||||
setSelection({ file, range: next })
|
||||
setCommenting(null)
|
||||
}
|
||||
|
||||
const openComment = (comment: SessionReviewComment) => {
|
||||
setOpened({ file: comment.file, id: comment.id })
|
||||
setSelection({ file: comment.file, range: comment.selection })
|
||||
setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) })
|
||||
}
|
||||
|
||||
const isCommentOpen = (comment: SessionReviewComment) => {
|
||||
@@ -607,7 +642,6 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
ref={(el) => {
|
||||
wrapper = el
|
||||
anchors.set(file, el)
|
||||
scheduleAnchors()
|
||||
}}
|
||||
>
|
||||
<Show when={expanded()}>
|
||||
@@ -658,11 +692,18 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
diffStyle={diffStyle()}
|
||||
onRendered={() => {
|
||||
props.onDiffRendered?.()
|
||||
scheduleAnchors()
|
||||
}}
|
||||
enableLineSelection={props.onLineComment != null}
|
||||
enableHoverUtility={props.onLineComment != null}
|
||||
onLineSelected={handleLineSelected}
|
||||
onLineSelectionEnd={handleLineSelectionEnd}
|
||||
onLineNumberSelectionEnd={(range: SelectedLineRange | null) => {
|
||||
if (!range) return
|
||||
openDraft(range)
|
||||
}}
|
||||
annotations={annotations()}
|
||||
renderAnnotation={renderAnnotation}
|
||||
renderHoverUtility={props.onLineComment ? renderHoverUtility : undefined}
|
||||
selectedLines={selectedLines()}
|
||||
commentedLines={commentedLines()}
|
||||
before={{
|
||||
@@ -676,50 +717,6 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<For each={comments()}>
|
||||
{(comment) => (
|
||||
<LineComment
|
||||
id={comment.id}
|
||||
top={positions()[comment.id]}
|
||||
onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
|
||||
onClick={() => {
|
||||
if (isCommentOpen(comment)) {
|
||||
setOpened(null)
|
||||
return
|
||||
}
|
||||
|
||||
openComment(comment)
|
||||
}}
|
||||
open={isCommentOpen(comment)}
|
||||
comment={comment.comment}
|
||||
selection={selectionLabel(comment.selection)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={draftRange()}>
|
||||
{(range) => (
|
||||
<Show when={draftTop() !== undefined}>
|
||||
<LineCommentEditor
|
||||
top={draftTop()}
|
||||
value={draft()}
|
||||
selection={selectionLabel(range())}
|
||||
onInput={setDraft}
|
||||
onCancel={() => setCommenting(null)}
|
||||
onSubmit={(comment) => {
|
||||
props.onLineComment?.({
|
||||
file,
|
||||
selection: range(),
|
||||
comment,
|
||||
preview: selectionPreview(item(), range()),
|
||||
})
|
||||
setCommenting(null)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
|
||||
73
packages/ui/src/pierre/comment-hover.ts
Normal file
73
packages/ui/src/pierre/comment-hover.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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(--syntax-diff-add)"
|
||||
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 = "22px"
|
||||
|
||||
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
|
||||
}
|
||||
130
packages/ui/src/pierre/commented-lines.ts
Normal file
130
packages/ui/src/pierre/commented-lines.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
|
||||
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 findDiffSide(node: HTMLElement): CommentSide {
|
||||
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"
|
||||
}
|
||||
|
||||
function lineIndex(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]
|
||||
}
|
||||
|
||||
function rowIndex(root: ShadowRoot, split: boolean, line: number, side: CommentSide | 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 lineIndex(split, row)
|
||||
if (parseInt(row.dataset.altLine ?? "", 10) === line) return lineIndex(split, row)
|
||||
}
|
||||
}
|
||||
|
||||
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 = 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 || 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", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -7,6 +8,7 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
||||
annotations?: DiffLineAnnotation<T>[]
|
||||
selectedLines?: SelectedLineRange | null
|
||||
commentedLines?: SelectedLineRange[]
|
||||
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
|
||||
onRendered?: () => void
|
||||
class?: string
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
@@ -125,7 +127,11 @@ const unsafeCSS = `
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
${lineCommentStyles}
|
||||
|
||||
`
|
||||
|
||||
export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
|
||||
return {
|
||||
|
||||
105
packages/ui/src/pierre/selection-bridge.ts
Normal file
105
packages/ui/src/pierre/selection-bridge.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
|
||||
type PointerMode = "none" | "text" | "numbers"
|
||||
type Side = SelectedLineRange["side"]
|
||||
|
||||
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 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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@
|
||||
@import "../components/icon-button.css" layer(components);
|
||||
@import "../components/image-preview.css" layer(components);
|
||||
@import "../components/keybind.css" layer(components);
|
||||
@import "../components/line-comment.css" layer(components);
|
||||
@import "../components/text-field.css" layer(components);
|
||||
@import "../components/inline-input.css" layer(components);
|
||||
@import "../components/list.css" layer(components);
|
||||
|
||||
Reference in New Issue
Block a user