diff options
| author | Adam <[email protected]> | 2026-01-21 18:36:31 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-22 22:12:12 -0600 |
| commit | 99e15caaf6c736e0c8ebc702e264e4f7a0113e3c (patch) | |
| tree | 80aef5232afdf4e1394b415a0a7ff34a473f4236 | |
| parent | 1e1872aada10fc39126e13675560e692e80258d0 (diff) | |
| download | opencode-99e15caaf6c736e0c8ebc702e264e4f7a0113e3c.tar.gz opencode-99e15caaf6c736e0c8ebc702e264e4f7a0113e3c.zip | |
wip(app): line selection
| -rw-r--r-- | packages/ui/src/components/code.tsx | 73 | ||||
| -rw-r--r-- | packages/ui/src/components/session-review.css | 14 | ||||
| -rw-r--r-- | packages/ui/src/components/session-review.tsx | 59 |
3 files changed, 115 insertions, 31 deletions
diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index c6f702fb5..16a915d9d 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -1,5 +1,5 @@ import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs" -import { ComponentProps, createEffect, createMemo, onCleanup, splitProps } from "solid-js" +import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js" import { createDefaultOptions, styleVariables } from "../pierre" import { getWorkerPool } from "../pierre/worker" @@ -9,7 +9,9 @@ export type CodeProps<T = {}> = FileOptions<T> & { file: FileContents annotations?: LineAnnotation<T>[] selectedLines?: SelectedLineRange | null + commentedLines?: SelectedLineRange[] onRendered?: () => void + onLineSelectionEnd?: (selection: SelectedLineRange | null) => void class?: string classList?: ComponentProps<"div">["classList"] } @@ -53,6 +55,8 @@ export function Code<T>(props: CodeProps<T>) { let dragStart: number | undefined let dragEnd: number | undefined let dragMoved = false + let lastSelection: SelectedLineRange | null = null + let pendingSelectionEnd = false const [local, others] = splitProps(props, [ "file", @@ -60,9 +64,13 @@ export function Code<T>(props: CodeProps<T>) { "classList", "annotations", "selectedLines", + "commentedLines", "onRendered", + "onLineSelectionEnd", ]) + const [rendered, setRendered] = createSignal(0) + const handleLineClick: FileOptions<T>["onLineClick"] = (info) => { props.onLineClick?.(info) @@ -95,6 +103,30 @@ export function Code<T>(props: CodeProps<T>) { return root } + 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") + } + + 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}"]`)) + for (const node of nodes) { + if (!(node instanceof HTMLElement)) continue + node.setAttribute("data-comment-selected", "") + } + } + } + } + const notifyRendered = () => { if (!local.onRendered) return @@ -203,7 +235,12 @@ export function Code<T>(props: CodeProps<T>) { if (side) selected.side = side if (endSide && side && endSide !== side) selected.endSide = endSide - file().setSelectedLines(selected) + setSelectedLines(selected) + } + + const setSelectedLines = (range: SelectedLineRange | null) => { + lastSelection = range + file().setSelectedLines(range) } const scheduleSelectionUpdate = () => { @@ -212,6 +249,10 @@ export function Code<T>(props: CodeProps<T>) { selectionFrame = requestAnimationFrame(() => { selectionFrame = undefined updateSelection() + + if (!pendingSelectionEnd) return + pendingSelectionEnd = false + props.onLineSelectionEnd?.(lastSelection) }) } @@ -221,7 +262,7 @@ export function Code<T>(props: CodeProps<T>) { const start = Math.min(dragStart, dragEnd) const end = Math.max(dragStart, dragEnd) - file().setSelectedLines({ start, end }) + setSelectedLines({ start, end }) } const scheduleDragUpdate = () => { @@ -289,19 +330,22 @@ export function Code<T>(props: CodeProps<T>) { const handleMouseUp = () => { if (props.enableLineSelection !== true) return + if (dragStart === undefined) return - if (dragStart !== undefined) { - if (dragMoved) scheduleDragUpdate() - dragStart = undefined - dragEnd = undefined - dragMoved = false + if (dragMoved) { + pendingSelectionEnd = true + scheduleDragUpdate() + scheduleSelectionUpdate() } - scheduleSelectionUpdate() + dragStart = undefined + dragEnd = undefined + dragMoved = false } const handleSelectionChange = () => { if (props.enableLineSelection !== true) return + if (dragStart === undefined) return const selection = window.getSelection() if (!selection || selection.isCollapsed) return @@ -328,11 +372,18 @@ export function Code<T>(props: CodeProps<T>) { containerWrapper: container, }) + setRendered((value) => value + 1) notifyRendered() }) createEffect(() => { - file().setSelectedLines(local.selectedLines ?? null) + rendered() + const ranges = local.commentedLines ?? [] + requestAnimationFrame(() => applyCommentedLines(ranges)) + }) + + createEffect(() => { + setSelectedLines(local.selectedLines ?? null) }) createEffect(() => { @@ -367,6 +418,8 @@ export function Code<T>(props: CodeProps<T>) { dragStart = undefined dragEnd = undefined dragMoved = false + lastSelection = null + pendingSelectionEnd = false }) return ( diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index d271da5f9..26ca73265 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -70,6 +70,20 @@ user-select: text; } + [data-slot="session-review-accordion-content"] { + position: relative; + overflow: hidden; + } + + [data-component="popover-content"] { + position: absolute !important; + } + + .session-review-comment-popover-content { + left: auto !important; + right: calc(100% + 12px) !important; + } + [data-slot="session-review-trigger-content"] { display: flex; align-items: center; diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index f3e7736f8..4096f341b 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -1,6 +1,5 @@ import { Accordion } from "./accordion" import { Button } from "./button" -import { HoverCard } from "./hover-card" import { Popover } from "./popover" import { RadioGroup } from "./radio-group" import { DiffChanges } from "./diff-changes" @@ -151,6 +150,7 @@ function markerTop(wrapper: HTMLElement, marker: HTMLElement) { } export const SessionReview = (props: SessionReviewProps) => { + let scroll: HTMLDivElement | undefined const i18n = useI18n() const diffComponent = useDiffComponent() const anchors = new Map<string, HTMLElement>() @@ -212,7 +212,29 @@ export const SessionReview = (props: SessionReviewProps) => { } requestAnimationFrame(() => { - anchors.get(focus.file)?.scrollIntoView({ block: "center" }) + requestAnimationFrame(() => { + const root = scroll + if (!root) return + + const anchor = root.querySelector(`[data-comment-id="${focus.id}"]`) + if (anchor instanceof HTMLElement) { + const rootRect = root.getBoundingClientRect() + const anchorRect = anchor.getBoundingClientRect() + const offset = anchorRect.top - rootRect.top + const next = root.scrollTop + offset - rootRect.height / 2 + anchorRect.height / 2 + root.scrollTop = Math.max(0, next) + return + } + + const target = anchors.get(focus.file) + if (!target) return + + const rootRect = root.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const offset = targetRect.top - rootRect.top + const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2 + root.scrollTop = Math.max(0, next) + }) }) requestAnimationFrame(() => props.onFocusedCommentChange?.(null)) @@ -221,7 +243,10 @@ export const SessionReview = (props: SessionReviewProps) => { return ( <div data-component="session-review" - ref={props.scrollRef} + ref={(el) => { + scroll = el + props.scrollRef?.(el) + }} onScroll={props.onScroll} classList={{ ...(props.classList ?? {}), @@ -574,6 +599,7 @@ export const SessionReview = (props: SessionReviewProps) => { {(comment) => ( <div data-slot="session-review-comment-anchor" + data-comment-id={comment.id} style={{ top: `${positions()[comment.id] ?? 0}px`, opacity: positions()[comment.id] === undefined ? 0 : 1, @@ -583,6 +609,7 @@ export const SessionReview = (props: SessionReviewProps) => { <Popover portal={false} open={isCommentOpen(comment)} + class="session-review-comment-popover-content" onOpenChange={(open) => { if (open) { openComment(comment) @@ -592,26 +619,15 @@ export const SessionReview = (props: SessionReviewProps) => { setOpened(null) }} trigger={ - <HoverCard - trigger={ - <button - type="button" - data-slot="session-review-comment-button" - onMouseEnter={() => - setSelection({ file: comment.file, range: comment.selection }) - } - > - <Icon name="speech-bubble" size="small" /> - </button> + <button + type="button" + data-slot="session-review-comment-button" + onMouseEnter={() => + setSelection({ file: comment.file, range: comment.selection }) } > - <div data-slot="session-review-comment-hover"> - <div data-slot="session-review-comment-hover-label"> - {getFilename(comment.file)}:{selectionLabel(comment.selection)} - </div> - <div data-slot="session-review-comment-hover-text">{comment.comment}</div> - </div> - </HoverCard> + <Icon name="speech-bubble" size="small" /> + </button> } > <div data-slot="session-review-comment-popover"> @@ -635,6 +651,7 @@ export const SessionReview = (props: SessionReviewProps) => { <Popover portal={false} open={true} + class="session-review-comment-popover-content" onOpenChange={(open) => { if (open) return setCommenting(null) |
