wip(app): line selection

This commit is contained in:
Adam
2026-01-21 05:27:52 -06:00
parent 640d1f1ecc
commit 0ce0cacb28
7 changed files with 627 additions and 57 deletions

View File

@@ -164,6 +164,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return files.pathFromTab(tab)
})
const selectionPreview = (path: string, selection?: FileSelection, preview?: string) => {
if (preview) return preview
if (!selection) return undefined
const content = files.get(path)?.content?.content
if (!content) return undefined
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = content.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
}
const activeFileSelection = createMemo(() => {
const path = activeFile()
if (!path) return
@@ -171,6 +183,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!range) return
return selectionFromLines(range)
})
const activeSelectionPreview = createMemo(() => {
const path = activeFile()
if (!path) return
return selectionPreview(path, activeFileSelection())
})
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const status = createMemo(
() =>
@@ -1485,40 +1502,49 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
</Show>
<Show when={prompt.context.items().length > 0 || !!activeFile()}>
<div class="flex flex-wrap items-center gap-1.5 px-3 pt-3">
<div class="flex flex-nowrap items-start gap-1.5 px-3 pt-3 overflow-x-auto no-scrollbar">
<Show when={prompt.context.activeTab() ? activeFile() : undefined}>
{(path) => (
<div class="flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-surface-base border border-border-base max-w-full">
<FileIcon node={{ path: path(), type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(path())}</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(path())}</span>
<Show when={activeFileSelection()}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap ml-1">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
<span class="text-text-weak whitespace-nowrap ml-1">{language.t("prompt.context.active")}</span>
<div class="shrink-0 flex flex-col gap-1 rounded-md bg-surface-base border border-border-base px-2 py-1 max-w-[320px]">
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: path(), type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(path())}</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(path())}</span>
<Show when={activeFileSelection()}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap ml-1">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
<span class="text-text-weak whitespace-nowrap ml-1">{language.t("prompt.context.active")}</span>
</div>
<IconButton
type="button"
icon="close"
variant="ghost"
class="h-5 w-5"
onClick={() => prompt.context.removeActive()}
aria-label={language.t("prompt.context.removeActiveFile")}
/>
</div>
<IconButton
type="button"
icon="close"
variant="ghost"
class="h-5 w-5"
onClick={() => prompt.context.removeActive()}
aria-label={language.t("prompt.context.removeActiveFile")}
/>
<Show when={activeSelectionPreview()}>
{(preview) => (
<pre class="text-10-regular text-text-weak font-mono whitespace-pre-wrap leading-4">
{preview()}
</pre>
)}
</Show>
</div>
)}
</Show>
<Show when={!prompt.context.activeTab() && !!activeFile()}>
<button
type="button"
class="flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-surface-base border border-border-base text-11-regular text-text-weak hover:bg-surface-raised-base-hover"
class="shrink-0 flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-surface-base border border-border-base text-11-regular text-text-weak hover:bg-surface-raised-base-hover"
onClick={() => prompt.context.addActive()}
>
<Icon name="plus-small" size="small" />
@@ -1526,32 +1552,47 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</button>
</Show>
<For each={prompt.context.items()}>
{(item) => (
<div class="flex items-center gap-1.5 px-1.5 py-0.5 rounded-md bg-surface-base border border-border-base max-w-full">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(item.path)}</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(item.path)}</span>
<Show when={item.selection}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap ml-1">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
{(item) => {
const preview = createMemo(() => selectionPreview(item.path, item.selection, item.preview))
return (
<div class="shrink-0 flex flex-col gap-1 rounded-md bg-surface-base border border-border-base px-2 py-1 max-w-[320px]">
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(item.path)}</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(item.path)}</span>
<Show when={item.selection}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap ml-1">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
</div>
<IconButton
type="button"
icon="close"
variant="ghost"
class="h-5 w-5"
onClick={() => prompt.context.remove(item.key)}
aria-label={language.t("prompt.context.removeFile")}
/>
</div>
<Show when={item.comment}>
{(comment) => <div class="text-11-regular text-text-strong">{comment()}</div>}
</Show>
<Show when={preview()}>
{(content) => (
<pre class="text-10-regular text-text-weak font-mono whitespace-pre-wrap leading-4">
{content()}
</pre>
)}
</Show>
</div>
<IconButton
type="button"
icon="close"
variant="ghost"
class="h-5 w-5"
onClick={() => prompt.context.remove(item.key)}
aria-label={language.t("prompt.context.removeFile")}
/>
</div>
)}
)
}}
</For>
</div>
</Show>

View File

@@ -4,6 +4,7 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import { checksum } from "@opencode-ai/util/encode"
interface PartBase {
content: string
@@ -41,6 +42,8 @@ export type FileContextItem = {
type: "file"
path: string
selection?: FileSelection
comment?: string
preview?: string
}
export type ContextItem = FileContextItem
@@ -135,7 +138,11 @@ function createPromptSession(dir: string, id: string | undefined) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
return `${item.type}:${item.path}:${start}:${end}`
const key = `${item.type}:${item.path}:${start}:${end}`
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
return {

View File

@@ -81,6 +81,7 @@ interface SessionReviewTabProps {
diffStyle: DiffStyle
onDiffStyleChange?: (style: DiffStyle) => void
onViewFile?: (file: string) => void
onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
classes?: {
root?: string
header?: string
@@ -166,6 +167,7 @@ function SessionReviewTab(props: SessionReviewTabProps) {
onDiffStyleChange={props.onDiffStyleChange}
onViewFile={props.onViewFile}
readFile={readFile}
onLineComment={props.onLineComment}
/>
)
}
@@ -488,8 +490,36 @@ export default function Page() {
setStore("expanded", id, status().type !== "idle")
})
const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content
if (!content) return undefined
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = content.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
}
const addSelectionToContext = (path: string, selection: FileSelection) => {
prompt.context.add({ type: "file", path, selection })
const preview = selectionPreview(path, selection)
prompt.context.add({ type: "file", path, selection, preview })
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
comment: string
preview?: string
}) => {
const selection = selectionFromLines(input.selection)
const preview = input.preview ?? selectionPreview(input.file, selection)
prompt.context.add({
type: "file",
path: input.file,
selection,
comment: input.comment,
preview,
})
}
command.register(() => [
@@ -1402,6 +1432,7 @@ export default function Page() {
diffs={diffs}
view={view}
diffStyle="unified"
onLineComment={addCommentToContext}
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
@@ -1717,6 +1748,7 @@ export default function Page() {
view={view}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
onLineComment={addCommentToContext}
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)

View File

@@ -1,6 +1,6 @@
import { DIFFS_TAG_NAME, FileDiff } from "@pierre/diffs"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { onCleanup, onMount, Show, splitProps } from "solid-js"
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Dynamic, isServer } from "solid-js/web"
import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre"
import { useWorkerPool } from "../context/worker-pool"
@@ -12,7 +12,14 @@ export type SSRDiffProps<T = {}> = DiffProps<T> & {
export function Diff<T>(props: SSRDiffProps<T>) {
let container!: HTMLDivElement
let fileDiffRef!: HTMLElement
const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"])
const [local, others] = splitProps(props, [
"before",
"after",
"class",
"classList",
"annotations",
"selectedLines",
])
const workerPool = useWorkerPool(props.diffStyle)
let fileDiffInstance: FileDiff<T> | undefined
@@ -38,6 +45,16 @@ export function Diff<T>(props: SSRDiffProps<T>) {
containerWrapper: container,
})
fileDiffInstance.setSelectedLines(local.selectedLines ?? null)
createEffect(() => {
fileDiffInstance?.setLineAnnotations(local.annotations ?? [])
})
createEffect(() => {
fileDiffInstance?.setSelectedLines(local.selectedLines ?? null)
})
// Hydrate annotation slots with interactive SolidJS components
// if (props.annotations.length > 0 && props.renderAnnotation != null) {
// for (const annotation of props.annotations) {

View File

@@ -1,16 +1,70 @@
import { checksum } from "@opencode-ai/util/encode"
import { FileDiff } from "@pierre/diffs"
import { FileDiff, type SelectedLineRange } from "@pierre/diffs"
import { createMediaQuery } from "@solid-primitives/media"
import { createEffect, createMemo, onCleanup, splitProps } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
import { getWorkerPool } from "../pierre/worker"
type SelectionSide = "additions" | "deletions"
function findElement(node: Node | null): HTMLElement | undefined {
if (!node) return
if (node instanceof HTMLElement) return node
return node.parentElement ?? undefined
}
function findLineNumber(node: Node | null): number | undefined {
const element = findElement(node)
if (!element) return
const line = element.closest("[data-line], [data-alt-line]")
if (!(line instanceof HTMLElement)) return
const value = (() => {
const primary = parseInt(line.dataset.line ?? "", 10)
if (!Number.isNaN(primary)) return primary
const alt = parseInt(line.dataset.altLine ?? "", 10)
if (!Number.isNaN(alt)) return alt
})()
return value
}
function findSide(node: Node | null): SelectionSide | undefined {
const element = findElement(node)
if (!element) return
const code = element.closest("[data-code]")
if (!(code instanceof HTMLElement)) return
if (code.hasAttribute("data-deletions")) return "deletions"
return "additions"
}
export function Diff<T>(props: DiffProps<T>) {
let container!: HTMLDivElement
let observer: MutationObserver | undefined
let renderToken = 0
let selectionFrame: number | undefined
let dragFrame: number | undefined
let dragStart: number | undefined
let dragEnd: number | undefined
let dragSide: SelectionSide | undefined
let dragEndSide: SelectionSide | undefined
let dragMoved = false
let lastSelection: SelectedLineRange | null = null
let pendingSelectionEnd = false
const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations", "onRendered"])
const [local, others] = splitProps(props, [
"before",
"after",
"class",
"classList",
"annotations",
"selectedLines",
"onRendered",
])
const mobile = createMediaQuery("(max-width: 640px)")
@@ -27,6 +81,7 @@ export function Diff<T>(props: DiffProps<T>) {
})
let instance: FileDiff<T> | undefined
const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined)
const getRoot = () => {
const host = container.querySelector("diffs-container")
@@ -117,6 +172,186 @@ export function Diff<T>(props: DiffProps<T>) {
observer.observe(container, { childList: true, subtree: true })
}
const setSelectedLines = (range: SelectedLineRange | null) => {
const active = current()
if (!active) return
lastSelection = range
active.setSelectedLines(range)
}
const updateSelection = () => {
const root = getRoot()
if (!root) return
const selection =
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
if (!selection || selection.isCollapsed) return
const domRange =
(
selection as unknown as {
getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[]
}
).getComposedRanges?.({ shadowRoots: [root] })?.[0] ??
(selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
const startNode = domRange?.startContainer ?? selection.anchorNode
const endNode = domRange?.endContainer ?? selection.focusNode
if (!startNode || !endNode) return
if (!root.contains(startNode) || !root.contains(endNode)) return
const start = findLineNumber(startNode)
const end = findLineNumber(endNode)
if (start === undefined || end === undefined) return
const startSide = findSide(startNode)
const endSide = findSide(endNode)
const side = startSide ?? endSide
const selected: SelectedLineRange = {
start,
end,
}
if (side) selected.side = side
if (endSide && side && endSide !== side) selected.endSide = endSide
setSelectedLines(selected)
}
const scheduleSelectionUpdate = () => {
if (selectionFrame !== undefined) return
selectionFrame = requestAnimationFrame(() => {
selectionFrame = undefined
updateSelection()
if (!pendingSelectionEnd) return
pendingSelectionEnd = false
props.onLineSelectionEnd?.(lastSelection)
})
}
const updateDragSelection = () => {
if (dragStart === undefined || dragEnd === undefined) return
const selected: SelectedLineRange = {
start: dragStart,
end: dragEnd,
}
if (dragSide) selected.side = dragSide
if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide
setSelectedLines(selected)
}
const scheduleDragUpdate = () => {
if (dragFrame !== undefined) return
dragFrame = requestAnimationFrame(() => {
dragFrame = undefined
updateDragSelection()
})
}
const lineFromMouseEvent = (event: MouseEvent) => {
const path = event.composedPath()
let numberColumn = false
let line: number | undefined
let side: SelectionSide | undefined
for (const item of path) {
if (!(item instanceof HTMLElement)) continue
numberColumn = numberColumn || item.dataset.columnNumber != null
if (side === undefined && item.dataset.code != null) {
side = item.hasAttribute("data-deletions") ? "deletions" : "additions"
}
if (line === undefined) {
const primary = item.dataset.line ? parseInt(item.dataset.line, 10) : Number.NaN
if (!Number.isNaN(primary)) {
line = primary
} else {
const alt = item.dataset.altLine ? parseInt(item.dataset.altLine, 10) : Number.NaN
if (!Number.isNaN(alt)) line = alt
}
}
if (numberColumn && line !== undefined && side !== undefined) break
}
return { line, numberColumn, side }
}
const handleMouseDown = (event: MouseEvent) => {
if (props.enableLineSelection !== true) return
if (event.button !== 0) return
const { line, numberColumn, side } = lineFromMouseEvent(event)
if (numberColumn) return
if (line === undefined) return
dragStart = line
dragEnd = line
dragSide = side
dragEndSide = side
dragMoved = false
}
const handleMouseMove = (event: MouseEvent) => {
if (props.enableLineSelection !== true) return
if (dragStart === undefined) return
if ((event.buttons & 1) === 0) {
dragStart = undefined
dragEnd = undefined
dragSide = undefined
dragEndSide = undefined
dragMoved = false
return
}
const { line, side } = lineFromMouseEvent(event)
if (line === undefined) return
dragEnd = line
dragEndSide = side
dragMoved = true
scheduleDragUpdate()
}
const handleMouseUp = () => {
if (props.enableLineSelection !== true) return
if (dragStart === undefined) return
if (dragMoved) {
pendingSelectionEnd = true
scheduleDragUpdate()
scheduleSelectionUpdate()
}
dragStart = undefined
dragEnd = undefined
dragSide = undefined
dragEndSide = undefined
dragMoved = false
}
const handleSelectionChange = () => {
if (props.enableLineSelection !== true) return
if (dragStart === undefined) return
const selection = window.getSelection()
if (!selection || selection.isCollapsed) return
scheduleSelectionUpdate()
}
createEffect(() => {
const opts = options()
const workerPool = getWorkerPool(props.diffStyle)
@@ -126,6 +361,7 @@ export function Diff<T>(props: DiffProps<T>) {
instance?.cleanUp()
instance = new FileDiff<T>(opts, workerPool)
setCurrent(instance)
container.innerHTML = ""
instance.render({
@@ -146,9 +382,50 @@ export function Diff<T>(props: DiffProps<T>) {
notifyRendered()
})
createEffect(() => {
const selected = local.selectedLines ?? null
setSelectedLines(selected)
})
createEffect(() => {
if (props.enableLineSelection !== true) return
container.addEventListener("mousedown", handleMouseDown)
container.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
document.addEventListener("selectionchange", handleSelectionChange)
onCleanup(() => {
container.removeEventListener("mousedown", handleMouseDown)
container.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
document.removeEventListener("selectionchange", handleSelectionChange)
})
})
onCleanup(() => {
observer?.disconnect()
if (selectionFrame !== undefined) {
cancelAnimationFrame(selectionFrame)
selectionFrame = undefined
}
if (dragFrame !== undefined) {
cancelAnimationFrame(dragFrame)
dragFrame = undefined
}
dragStart = undefined
dragEnd = undefined
dragSide = undefined
dragEndSide = undefined
dragMoved = false
lastSelection = null
pendingSelectionEnd = false
instance?.cleanUp()
setCurrent(undefined)
})
return <div data-component="diff" style={styleVariables} ref={container} />

View File

@@ -10,10 +10,11 @@ import { useDiffComponent } from "../context/diff"
import { useI18n } from "../context/i18n"
import { checksum } from "@opencode-ai/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { createEffect, createMemo, createSignal, For, Match, 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 DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs"
import { Dynamic } from "solid-js/web"
export type SessionReviewDiffStyle = "unified" | "split"
@@ -23,6 +24,7 @@ export interface SessionReviewProps {
diffStyle?: SessionReviewDiffStyle
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
onDiffRendered?: () => void
onLineComment?: (comment: SessionReviewLineComment) => void
open?: string[]
onOpenChange?: (open: string[]) => void
scrollRef?: (el: HTMLDivElement) => void
@@ -98,6 +100,25 @@ function dataUrlFromValue(value: unknown): string | undefined {
return `data:${mime};base64,${content}`
}
type SessionReviewSelection = {
file: string
range: SelectedLineRange
}
type SessionReviewLineComment = {
file: string
selection: SelectedLineRange
comment: string
preview?: string
}
type CommentAnnotationMeta = {
file: string
selection: SelectedLineRange
label: string
preview?: string
}
export const SessionReview = (props: SessionReviewProps) => {
const i18n = useI18n()
const diffComponent = useDiffComponent()
@@ -105,6 +126,8 @@ export const SessionReview = (props: SessionReviewProps) => {
const [store, setStore] = createStore({
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
})
const [selection, setSelection] = createSignal<SessionReviewSelection | null>(null)
const [commenting, setCommenting] = createSignal<SessionReviewSelection | null>(null)
const open = () => props.open ?? store.open
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
@@ -120,6 +143,113 @@ export const SessionReview = (props: SessionReviewProps) => {
handleChange(next)
}
const selectionLabel = (range: SelectedLineRange) => {
const start = Math.min(range.start, range.end)
const end = Math.max(range.start, range.end)
if (start === end) return `line ${start}`
return `lines ${start}-${end}`
}
const isRangeEqual = (a: SelectedLineRange, b: SelectedLineRange) =>
a.start === b.start && a.end === b.end && a.side === b.side && a.endSide === b.endSide
const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => {
const side = selectionSide(range)
const contents = side === "deletions" ? diff.before : diff.after
if (typeof contents !== "string" || contents.length === 0) return undefined
const start = Math.max(1, Math.min(range.start, range.end))
const end = Math.max(range.start, range.end)
const lines = contents.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
}
const renderAnnotation = (annotation: DiffLineAnnotation<CommentAnnotationMeta>) => {
if (!props.onLineComment) return undefined
const meta = annotation.metadata
if (!meta) return undefined
const wrapper = document.createElement("div")
wrapper.className = "relative"
const card = document.createElement("div")
card.className =
"min-w-[240px] max-w-[320px] flex flex-col gap-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha p-2 shadow-md"
const textarea = document.createElement("textarea")
textarea.rows = 3
textarea.placeholder = "Add a comment"
textarea.className =
"w-full resize-none rounded-md border border-border-base bg-surface-base px-2 py-1 text-12-regular text-text-strong placeholder:text-text-subtle"
const footer = document.createElement("div")
footer.className = "flex items-center justify-between gap-2 text-11-regular text-text-weak"
const label = document.createElement("span")
label.textContent = `Commenting on ${meta.label}`
const actions = document.createElement("div")
actions.className = "flex items-center gap-2"
const cancel = document.createElement("button")
cancel.type = "button"
cancel.textContent = "Cancel"
cancel.className = "text-11-regular text-text-weak hover:text-text-strong"
const submit = document.createElement("button")
submit.type = "button"
submit.textContent = "Comment"
submit.className =
"rounded-md border border-border-base bg-surface-base px-2 py-1 text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
const updateState = () => {
const active = textarea.value.trim().length > 0
submit.disabled = !active
submit.classList.toggle("opacity-50", !active)
submit.classList.toggle("cursor-not-allowed", !active)
}
updateState()
textarea.addEventListener("input", updateState)
textarea.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return
if (event.shiftKey) return
event.preventDefault()
submit.click()
})
cancel.addEventListener("click", () => {
setSelection(null)
setCommenting(null)
})
submit.addEventListener("click", () => {
const value = textarea.value.trim()
if (!value) return
props.onLineComment?.({
file: meta.file,
selection: meta.selection,
comment: value,
preview: meta.preview,
})
setSelection(null)
setCommenting(null)
})
actions.appendChild(cancel)
actions.appendChild(submit)
footer.appendChild(label)
footer.appendChild(actions)
card.appendChild(textarea)
card.appendChild(footer)
wrapper.appendChild(card)
requestAnimationFrame(() => textarea.focus())
return wrapper
}
return (
<div
data-component="session-review"
@@ -185,6 +315,35 @@ export const SessionReview = (props: SessionReviewProps) => {
const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
const selectedLines = createMemo(() => {
const current = selection()
if (!current || current.file !== diff.file) return null
return current.range
})
const commentingLines = createMemo(() => {
const current = commenting()
if (!current || current.file !== diff.file) return null
return current.range
})
const annotations = createMemo<DiffLineAnnotation<CommentAnnotationMeta>[]>(() => {
const range = commentingLines()
if (!range) return []
return [
{
lineNumber: Math.max(range.start, range.end),
side: selectionSide(range),
metadata: {
file: diff.file,
selection: range,
label: selectionLabel(range),
preview: selectionPreview(diff, range),
},
},
]
})
createEffect(() => {
if (!open().includes(diff.file)) return
if (!isImage()) return
@@ -245,6 +404,36 @@ export const SessionReview = (props: SessionReviewProps) => {
}
}
const handleLineSelected = (range: SelectedLineRange | null) => {
if (!props.onLineComment) return
if (!range) {
setSelection(null)
setCommenting(null)
return
}
setSelection({ file: diff.file, range })
const current = commenting()
if (!current) return
if (current.file !== diff.file) return
if (isRangeEqual(current.range, range)) return
setCommenting(null)
}
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
if (!props.onLineComment) return
if (!range) {
setCommenting(null)
return
}
setSelection({ file: diff.file, range })
setCommenting({ file: diff.file, range })
}
return (
<Accordion.Item value={diff.file} data-slot="session-review-accordion-item">
<StickyAccordionHeader>
@@ -348,6 +537,12 @@ export const SessionReview = (props: SessionReviewProps) => {
preloadedDiff={diff.preloaded}
diffStyle={diffStyle()}
onRendered={props.onDiffRendered}
enableLineSelection={props.onLineComment != null}
onLineSelected={handleLineSelected}
onLineSelectionEnd={handleLineSelectionEnd}
selectedLines={selectedLines()}
annotations={annotations()}
renderAnnotation={renderAnnotation}
before={{
name: diff.file!,
contents: beforeText(),

View File

@@ -1,10 +1,11 @@
import { DiffLineAnnotation, FileContents, FileDiffOptions } from "@pierre/diffs"
import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
import { ComponentProps } from "solid-js"
export type DiffProps<T = {}> = FileDiffOptions<T> & {
before: FileContents
after: FileContents
annotations?: DiffLineAnnotation<T>[]
selectedLines?: SelectedLineRange | null
onRendered?: () => void
class?: string
classList?: ComponentProps<"div">["classList"]