summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-21 18:36:31 -0600
committerAdam <[email protected]>2026-01-22 22:12:12 -0600
commit99e15caaf6c736e0c8ebc702e264e4f7a0113e3c (patch)
tree80aef5232afdf4e1394b415a0a7ff34a473f4236
parent1e1872aada10fc39126e13675560e692e80258d0 (diff)
downloadopencode-99e15caaf6c736e0c8ebc702e264e4f7a0113e3c.tar.gz
opencode-99e15caaf6c736e0c8ebc702e264e4f7a0113e3c.zip
wip(app): line selection
-rw-r--r--packages/ui/src/components/code.tsx73
-rw-r--r--packages/ui/src/components/session-review.css14
-rw-r--r--packages/ui/src/components/session-review.tsx59
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)