From 0ce0cacb282c47943348a2af21ea00e721bcb9d9 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 21 Jan 2026 05:27:52 -0600 Subject: [PATCH] wip(app): line selection --- packages/app/src/components/prompt-input.tsx | 137 ++++++--- packages/app/src/context/prompt.tsx | 9 +- packages/app/src/pages/session.tsx | 34 ++- packages/ui/src/components/diff-ssr.tsx | 21 +- packages/ui/src/components/diff.tsx | 283 +++++++++++++++++- packages/ui/src/components/session-review.tsx | 197 +++++++++++- packages/ui/src/pierre/index.ts | 3 +- 7 files changed, 627 insertions(+), 57 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 0d6a7641a..5e936737a 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -164,6 +164,18 @@ export const PromptInput: Component = (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 = (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 = (props) => { 0 || !!activeFile()}> -
+
{(path) => ( -
- -
- {getDirectory(path())} - {getFilename(path())} - - {(sel) => ( - - {sel().startLine === sel().endLine - ? `:${sel().startLine}` - : `:${sel().startLine}-${sel().endLine}`} - - )} - - {language.t("prompt.context.active")} +
+
+ +
+ {getDirectory(path())} + {getFilename(path())} + + {(sel) => ( + + {sel().startLine === sel().endLine + ? `:${sel().startLine}` + : `:${sel().startLine}-${sel().endLine}`} + + )} + + {language.t("prompt.context.active")} +
+ prompt.context.removeActive()} + aria-label={language.t("prompt.context.removeActiveFile")} + />
- prompt.context.removeActive()} - aria-label={language.t("prompt.context.removeActiveFile")} - /> + + {(preview) => ( +
+                        {preview()}
+                      
+ )} +
)} - {(item) => ( -
- -
- {getDirectory(item.path)} - {getFilename(item.path)} - - {(sel) => ( - - {sel().startLine === sel().endLine - ? `:${sel().startLine}` - : `:${sel().startLine}-${sel().endLine}`} - + {(item) => { + const preview = createMemo(() => selectionPreview(item.path, item.selection, item.preview)) + return ( +
+
+ +
+ {getDirectory(item.path)} + {getFilename(item.path)} + + {(sel) => ( + + {sel().startLine === sel().endLine + ? `:${sel().startLine}` + : `:${sel().startLine}-${sel().endLine}`} + + )} + +
+ prompt.context.remove(item.key)} + aria-label={language.t("prompt.context.removeFile")} + /> +
+ + {(comment) =>
{comment()}
} +
+ + {(content) => ( +
+                          {content()}
+                        
)}
- prompt.context.remove(item.key)} - aria-label={language.t("prompt.context.removeFile")} - /> -
- )} + ) + }}
diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 993d7e7a8..a76d9d5f1 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -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 { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index ad6d360dc..1e0d7a89e 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -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) diff --git a/packages/ui/src/components/diff-ssr.tsx b/packages/ui/src/components/diff-ssr.tsx index 56a12c100..da99ba3b7 100644 --- a/packages/ui/src/components/diff-ssr.tsx +++ b/packages/ui/src/components/diff-ssr.tsx @@ -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 = DiffProps & { export function Diff(props: SSRDiffProps) { 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 | undefined @@ -38,6 +45,16 @@ export function Diff(props: SSRDiffProps) { 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) { diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx index 46b6709b6..20dd5c440 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -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(props: DiffProps) { 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(props: DiffProps) { }) let instance: FileDiff | undefined + const [current, setCurrent] = createSignal | undefined>(undefined) const getRoot = () => { const host = container.querySelector("diffs-container") @@ -117,6 +172,186 @@ export function Diff(props: DiffProps) { 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(props: DiffProps) { instance?.cleanUp() instance = new FileDiff(opts, workerPool) + setCurrent(instance) container.innerHTML = "" instance.render({ @@ -146,9 +382,50 @@ export function Diff(props: DiffProps) { 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
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index c47d11d08..814281723 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -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(null) + const [commenting, setCommenting] = createSignal(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) => { + 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 (
{ const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle") const [audioMime, setAudioMime] = createSignal(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[]>(() => { + 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 ( @@ -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(), diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts index 38bf6c854..0d9092c21 100644 --- a/packages/ui/src/pierre/index.ts +++ b/packages/ui/src/pierre/index.ts @@ -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 = FileDiffOptions & { before: FileContents after: FileContents annotations?: DiffLineAnnotation[] + selectedLines?: SelectedLineRange | null onRendered?: () => void class?: string classList?: ComponentProps<"div">["classList"]