Compare commits

...
13 changed files with 452 additions and 58 deletions
@@ -409,6 +409,7 @@ export function SessionFileView(props: SessionFileViewProps) {
}}
enableLineSelection
enableGutterUtility
textSelectionAction={{ label: language.t("ui.lineComment.add") }}
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
@@ -1,6 +1,33 @@
import { expect, story } from "../../storybook/playwright/story"
story("renders the line comment cancel action as a ghost button", async ({ mount }) => {
story("renders the line comment content editor and compact actions", async ({ mount }) => {
const root = await mount("ui-line-comment--editor-filled")
await expect(root.getByRole("button", { name: "Cancel" })).toHaveAttribute("data-variant", "ghost")
const editor = root.getByRole("textbox")
expect(await editor.evaluate((element) => element.tagName)).toBe("TEXTAREA")
await editor.fill("Updated comment\nwith context")
await expect(editor).toHaveValue("Updated comment\nwith context")
await editor.fill("x")
await editor.press("Backspace")
await expect(editor).toHaveValue("")
expect(await editor.evaluate((element) => element.matches(":placeholder-shown"))).toBe(true)
await expect(root.locator("textarea")).toHaveCount(1)
await expect(root.locator('[data-slot="line-comment-v2-label"]')).toHaveCount(0)
await expect(root.locator('[data-slot="line-comment-v2-footer-meta"]')).toHaveCount(0)
await expect(root.locator('[data-slot="line-comment-v2-shell"]')).toHaveCSS("padding", "0px")
await expect(editor).toHaveCSS("border-top-width", "0px")
await expect(editor).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
await expect(editor).toHaveCSS("padding", "12px")
await expect(root.getByRole("button", { name: "Cancel" })).toHaveAttribute("data-variant", "ghost-muted")
await expect(root.getByRole("button", { name: "Cancel" })).toHaveAttribute("data-size", "small")
await expect(root.getByRole("button", { name: "Comment" })).toHaveAttribute("data-variant", "submit")
await expect(root.getByRole("button", { name: "Comment" })).toHaveAttribute("data-size", "small")
})
story("preserves native undo in the line comment editor", async ({ mount }) => {
const root = await mount("ui-line-comment--editor")
const editor = root.getByRole("textbox")
await editor.pressSequentially("undo me")
await editor.press("Meta+z")
await expect(editor).toHaveValue("")
expect(await editor.evaluate((element) => element.matches(":placeholder-shown"))).toBe(true)
})
@@ -48,3 +48,136 @@ story("shows a comment button when a diff line is hovered", async ({ mount }) =>
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
for (const direction of ["ltr", "rtl"]) {
story(`offers a comment action for selected review text in ${direction}`, async ({ mount, page }) => {
const root = await mount("components-session-review--interactive-comments-panel", { globals: { direction } })
const action = page.getByRole("button", { name: "Add comment", exact: true })
await expect(async () => {
await root.locator('[data-line-type="change-addition"] [data-diff-span]').selectText()
await expect(action).toBeVisible()
}).toPass()
await expect(root.getByRole("textbox")).not.toBeVisible()
await expect(action).toHaveAttribute("data-variant", "submit")
await expect(action).toHaveCSS("z-index", "110")
const box = await action.boundingBox()
const code = root.locator("[data-code]").first()
const gutterRight = await code.evaluate((element) => element.firstElementChild?.getBoundingClientRect().right)
expect((box?.x ?? 0) - (gutterRight ?? 0)).toBe(8)
expect(box?.x).toBeGreaterThanOrEqual(0)
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(
await page.evaluate(() => document.documentElement.clientWidth),
)
await action.click()
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("")
await expect(root.getByRole("textbox")).toBeVisible()
await expect(root.locator('[data-line="2"][data-line-type="change-addition"]')).toHaveAttribute(
"data-selected-line",
/.*/,
)
})
}
for (const direction of ["up", "down"] as const) {
story(
`positions the comment action with ${direction === "up" ? "an upward" : "a downward"} selection`,
async ({ mount, page }) => {
const root = await mount("components-session-review--interactive-comments-panel")
const action = page.getByRole("button", { name: "Add comment", exact: true })
await expect(async () => {
await root.getByText("export const first = 1", { exact: true }).evaluate((element, value) => {
const root = element.getRootNode()
if (!(root instanceof ShadowRoot)) throw new Error("Expected a shadow root")
const text = (line: number) => {
const row = root.querySelector(`[data-line="${line}"]`)
if (!row) throw new Error(`Missing line ${line}`)
const node = document.createTreeWalker(row, NodeFilter.SHOW_TEXT).nextNode()
if (!node) throw new Error(`Missing text for line ${line}`)
return node
}
const first = text(1)
const last = text(3)
const selection = window.getSelection()
if (!selection) throw new Error("Missing selection")
if (value === "up") {
selection.setBaseAndExtent(last, last.textContent?.length ?? 0, first, 0)
} else {
selection.setBaseAndExtent(first, 0, last, last.textContent?.length ?? 0)
}
document.dispatchEvent(new Event("selectionchange"))
}, direction)
await expect(action).toHaveAttribute("data-placement", direction === "up" ? "top" : "bottom")
await expect(action).toHaveClass(/transition-transform/)
await expect(action).toHaveClass(/ease-out/)
await expect(action).not.toHaveClass(/fade-in/)
await expect
.poll(() =>
action.evaluate((button) => {
const host = button.closest('[data-component="file"]')?.querySelector("diffs-container")
const root = host?.shadowRoot
if (!root) return NaN
const selection =
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ??
window.getSelection()
const source = (
selection as unknown as {
getComposedRanges?: (options: { shadowRoots: ShadowRoot[] }) => StaticRange[]
}
)?.getComposedRanges?.({ shadowRoots: [root] })?.[0]
if (!source) return NaN
const range = new Range()
range.setStart(source.startContainer, source.startOffset)
range.setEnd(source.endContainer, source.endOffset)
const selected = range.getBoundingClientRect()
const action = button.getBoundingClientRect()
return button.getAttribute("data-placement") === "top"
? selected.top - action.bottom
: action.top - selected.bottom
}),
)
.toBeCloseTo(8, 0)
}).toPass()
},
)
}
story("leaves a review code click as regular text interaction", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments-panel")
await root.locator('[data-line-type="change-addition"] [data-diff-span]').click()
await expect(root.getByRole("textbox")).not.toBeVisible()
})
story("keeps direct line-number range comments in the review panel", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments-panel")
await root.locator('[data-column-number="1"]').dragTo(root.locator('[data-column-number="3"]'))
await expect(root.getByRole("textbox")).toBeVisible()
await expect(root.locator("[data-selected-line]")).not.toHaveCount(0)
})
story("keeps the direct gutter comment action in the review panel", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments-panel")
const comment = root.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
await expect(async () => {
await root.getByText("export const first = 1", { exact: true }).hover()
await expect(comment).toBeVisible()
}).toPass()
expect(await comment.evaluate((element) => (element as HTMLElement).style.background)).toBe(
"var(--v2-background-bg-inverse)",
)
expect(await comment.evaluate((element) => (element as HTMLElement).style.left)).toBe("-4px")
await expect(comment).toHaveCSS("z-index", "110")
await expect
.poll(async () => {
const box = await comment.boundingBox()
const gutterRight = await comment.evaluate(
(element) => element.parentElement?.assignedSlot?.parentElement?.parentElement?.getBoundingClientRect().right,
)
return (box?.x ?? 0) + (box?.width ?? 0) - (gutterRight ?? 0)
})
.toBe(-4)
await comment.dispatchEvent("click")
await expect(root.getByRole("textbox")).toBeVisible()
await expect(root.locator('[data-line="1"]')).toHaveAttribute("data-selected-line", /.*/)
})
@@ -47,6 +47,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
"onLineNumberSelectionEnd",
"onRendered",
"preloadedDiff",
"textSelectionAction",
])
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
+178 -2
View File
@@ -21,10 +21,12 @@ import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pi
import { createMediaQuery } from "@solid-primitives/media"
import { makeEventListener } from "@solid-primitives/event-listener"
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Button } from "@opencode/ui/button"
import { createDefaultOptions, styleVariables } from "../pierre"
import { markCommentedDiffLines, markCommentedFileLines } from "../pierre/commented-lines"
import { fixDiffSelection, findDiffSide, type DiffSelectionSide } from "../pierre/diff-selection"
import { createFileFind } from "../pierre/file-find"
import { LINE_COMMENT_ACTION_GAP } from "../pierre/comment-hover"
import {
applyViewerScheme,
clearReadyWatcher,
@@ -48,6 +50,8 @@ import { FileMedia, type FileMediaOptions } from "./file-media"
import { FileSearchBar } from "./file-search"
const VIRTUALIZE_BYTES = 500_000
const TEXT_SELECTION_ACTION_HEIGHT = 24
const TEXT_SELECTION_ACTION_GAP = 8
const codeMetrics = {
...DEFAULT_VIRTUAL_FILE_METRICS,
@@ -65,6 +69,9 @@ type SharedProps<T> = {
classList?: ComponentProps<"div">["classList"]
media?: FileMediaOptions
search?: FileSearchControl
textSelectionAction?: {
label: string
}
}
export type FileSearchHandle = {
@@ -123,6 +130,7 @@ const sharedKeys = [
"onLineNumberSelectionEnd",
"onRendered",
"preloadedDiff",
"textSelectionAction",
] as const
const textKeys = ["file", ...sharedKeys] as const
@@ -140,6 +148,7 @@ type MouseHit = {
type ViewerConfig = {
enableLineSelection: () => boolean
textSelectionAction: () => { label: string } | undefined
selectedLines: () => SelectedLineRange | null | undefined
commentedLines: () => SelectedLineRange[]
onLineSelectionEnd: (range: SelectedLineRange | null) => void
@@ -148,6 +157,14 @@ type ViewerConfig = {
lineFromMouseEvent: (event: MouseEvent) => MouseHit
setSelectedLines: (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => void
updateSelection: (preserveTextSelection: boolean) => void
readTextSelection: () =>
| {
range: SelectedLineRange
text: Range
direction: "up" | "down" | "same"
gutterRight?: number
}
| undefined
buildDragSelection: () => SelectedLineRange | undefined
buildClickSelection: () => SelectedLineRange | undefined
onDragStart: (hit: MouseHit) => void
@@ -162,6 +179,7 @@ function useFileViewer(config: ViewerConfig) {
let overlay!: HTMLDivElement
let selectionFrame: number | undefined
let dragFrame: number | undefined
let textSelectionFrame: number | undefined
let dragStart: number | undefined
let dragEnd: number | undefined
let dragMoved = false
@@ -171,6 +189,14 @@ function useFileViewer(config: ViewerConfig) {
const ready = createReadyWatcher()
const bridge = createLineNumberSelectionBridge()
const [rendered, setRendered] = createSignal(0)
const [textSelection, setTextSelection] = createSignal<{
range: SelectedLineRange
rect: DOMRect
label: string
below: boolean
gutterEdge: number
}>()
const hasTextSelection = createMemo(() => textSelection() !== undefined)
const getRoot = () => getViewerRoot(container)
const getHost = () => getViewerHost(container)
@@ -204,6 +230,55 @@ function useFileViewer(config: ViewerConfig) {
})
}
const updateTextSelection = () => {
textSelectionFrame = undefined
const action = config.textSelectionAction()
if (!action) {
setTextSelection(undefined)
return
}
const selected = config.readTextSelection()
if (!selected) {
setTextSelection(undefined)
return
}
const rect = selected.text.getBoundingClientRect()
if (rect.width === 0 && rect.height === 0) {
setTextSelection(undefined)
return
}
const roomBelow = rect.bottom + TEXT_SELECTION_ACTION_HEIGHT + TEXT_SELECTION_ACTION_GAP <= window.innerHeight
const roomAbove = rect.top - TEXT_SELECTION_ACTION_HEIGHT - TEXT_SELECTION_ACTION_GAP >= 0
const preferBelow = selected.direction !== "up"
const below = preferBelow ? roomBelow || !roomAbove : !roomAbove && roomBelow
const gutterEdge =
(selected.gutterRight ?? wrapper.getBoundingClientRect().left) - wrapper.getBoundingClientRect().left
setTextSelection({ range: selected.range, rect, label: action.label, below, gutterEdge })
}
const scheduleTextSelectionUpdate = () => {
if (textSelectionFrame !== undefined) return
textSelectionFrame = requestAnimationFrame(updateTextSelection)
}
const clearTextSelection = () => {
setTextSelection(undefined)
const root = getRoot()
const selection =
(root as unknown as { getSelection?: () => Selection | null } | undefined)?.getSelection?.() ??
window.getSelection()
selection?.removeAllRanges()
}
const activateTextSelection = () => {
const selected = textSelection()
if (!selected) return
clearTextSelection()
config.setSelectedLines(selected.range)
config.onLineSelectionEnd(selected.range)
}
// -- mouse handlers --
const handleMouseDown = (event: MouseEvent) => {
@@ -218,6 +293,11 @@ function useFileViewer(config: ViewerConfig) {
if (hit.line === undefined) return
bridge.begin(false, hit.line)
if (config.textSelectionAction()) {
setTextSelection(undefined)
if (lastSelection) config.setSelectedLines(null)
return
}
dragStart = hit.line
dragEnd = hit.line
dragMoved = false
@@ -250,6 +330,10 @@ function useFileViewer(config: ViewerConfig) {
const handleMouseUp = () => {
if (!config.enableLineSelection()) return
if (bridge.finish() === "numbers") return
if (config.textSelectionAction()) {
scheduleTextSelectionUpdate()
return
}
if (dragStart === undefined) return
if (!dragMoved) {
@@ -284,6 +368,10 @@ function useFileViewer(config: ViewerConfig) {
const handleSelectionChange = () => {
if (!config.enableLineSelection()) return
if (config.textSelectionAction()) {
scheduleTextSelectionUpdate()
return
}
if (dragStart === undefined) return
const selection = window.getSelection()
if (!selection || selection.isCollapsed) return
@@ -326,7 +414,9 @@ function useFileViewer(config: ViewerConfig) {
})
createEffect(() => {
config.setSelectedLines(config.selectedLines() ?? null)
const selected = config.selectedLines() ?? null
if (selected && config.textSelectionAction()) clearTextSelection()
config.setSelectedLines(selected)
})
createEffect(() => {
@@ -338,14 +428,26 @@ function useFileViewer(config: ViewerConfig) {
makeEventListener(document, "selectionchange", handleSelectionChange)
})
createEffect(() => {
if (!config.enableLineSelection() || !config.textSelectionAction() || !hasTextSelection()) return
makeEventListener(document, "scroll", scheduleTextSelectionUpdate, true)
makeEventListener(window, "resize", scheduleTextSelectionUpdate)
makeEventListener(document, "keydown", (event) => {
if (event.key !== "Escape") return
clearTextSelection()
})
})
onCleanup(() => {
clearReadyWatcher(ready)
if (selectionFrame !== undefined) cancelAnimationFrame(selectionFrame)
if (dragFrame !== undefined) cancelAnimationFrame(dragFrame)
if (textSelectionFrame !== undefined) cancelAnimationFrame(textSelectionFrame)
selectionFrame = undefined
dragFrame = undefined
textSelectionFrame = undefined
dragStart = undefined
dragEnd = undefined
dragMoved = false
@@ -393,15 +495,21 @@ function useFileViewer(config: ViewerConfig) {
getHost,
find,
scheduleSelectionUpdate,
textSelection,
activateTextSelection,
}
}
type Viewer = ReturnType<typeof useFileViewer>
type ModeAdapter = Omit<ViewerConfig, "enableLineSelection" | "selectedLines" | "commentedLines" | "onLineSelectionEnd">
type ModeAdapter = Omit<
ViewerConfig,
"enableLineSelection" | "textSelectionAction" | "selectedLines" | "commentedLines" | "onLineSelectionEnd"
>
type ModeConfig = {
enableLineSelection: () => boolean
textSelectionAction: () => { label: string } | undefined
selectedLines: () => SelectedLineRange | null | undefined
commentedLines: () => SelectedLineRange[] | undefined
onLineSelectionEnd: (range: SelectedLineRange | null) => void
@@ -424,6 +532,7 @@ type VirtualStrategy = {
function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
return useFileViewer({
enableLineSelection: config.enableLineSelection,
textSelectionAction: config.textSelectionAction,
selectedLines: config.selectedLines,
commentedLines: () => config.commentedLines() ?? [],
onLineSelectionEnd: config.onLineSelectionEnd,
@@ -728,6 +837,40 @@ function ViewerShell(props: {
</Show>
<div ref={(el) => (props.viewer.container = el)} />
<div ref={(el) => (props.viewer.overlay = el)} class="pointer-events-none absolute inset-0 z-0" />
<Show when={props.viewer.textSelection()}>
{(selection) => (
<Button
data-slot="file-text-selection-action"
data-placement={selection().below ? "bottom" : "top"}
size="small"
variant="submit"
class="z-[110] whitespace-nowrap motion-safe:transition-transform duration-100 ease-out motion-reduce:transition-none"
style={{
position: "absolute",
"--line-comment-gutter-edge": `${selection().gutterEdge}px`,
left: `calc(var(--line-comment-gutter-edge) + ${LINE_COMMENT_ACTION_GAP}px)`,
top: `${
(selection().below ? selection().rect.bottom : selection().rect.top) -
props.viewer.wrapper.getBoundingClientRect().top
}px`,
transform: selection().below
? `translateY(${TEXT_SELECTION_ACTION_GAP}px)`
: `translateY(calc(-100% - ${TEXT_SELECTION_ACTION_GAP}px))`,
}}
onPointerDown={(event: PointerEvent) => {
event.preventDefault()
event.stopPropagation()
}}
onMouseDown={(event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={props.viewer.activateTextSelection}
>
{selection().label}
</Button>
)}
</Show>
</div>
)
}
@@ -845,6 +988,23 @@ function TextViewer<T>(props: TextFileProps<T>) {
if (!preserveTextSelection || !selected.text) return
restoreShadowTextSelection(root, selected.text)
},
readTextSelection: () => {
const root = viewer.getRoot()
if (!root) return
const selected = readShadowLineSelection({
root,
lineForNode: findFileLineNumber,
sideForNode: findCodeSelectionSide,
preserveTextSelection: true,
})
if (!selected?.text) return
return {
range: selected.range,
text: selected.text,
direction: selected.direction,
gutterRight: selected.gutterRight,
}
},
buildDragSelection: () => {
if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
return { start: Math.min(viewer.dragStart, viewer.dragEnd), end: Math.max(viewer.dragStart, viewer.dragEnd) }
@@ -862,6 +1022,7 @@ function TextViewer<T>(props: TextFileProps<T>) {
viewer = useModeViewer(
{
enableLineSelection: () => props.enableLineSelection === true,
textSelectionAction: () => local.textSelectionAction,
selectedLines: () => local.selectedLines,
commentedLines: () => local.commentedLines,
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
@@ -1008,6 +1169,20 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
setSelectedLines(selected.range)
},
readTextSelection: () => {
const root = viewer.getRoot()
if (!root) return
const selected = readShadowLineSelection({
root,
lineForNode: findDiffLineNumber,
sideForNode: diffSelectionSide,
preserveTextSelection: true,
})
if (!selected?.text) return
const range = fixDiffSelection(root, selected.range)
if (!range) return
return { range, text: selected.text, direction: selected.direction, gutterRight: selected.gutterRight }
},
buildDragSelection: () => {
if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragEnd }
@@ -1038,6 +1213,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
viewer = useModeViewer(
{
enableLineSelection: () => props.enableLineSelection === true,
textSelectionAction: () => local.textSelectionAction,
selectedLines: () => local.selectedLines,
commentedLines: () => local.commentedLines,
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
@@ -4,6 +4,7 @@ import { CurrentSessionProviders } from "../storybook/current-session-story"
import { editThenTestDocument, reviewDiffs } from "../storybook/current-session-fixtures"
import { File } from "./file"
import { SessionReview, type SessionReviewComment } from "./session-review"
import { SessionReviewFilePreviewV2 } from "../v2/components/session-review-file-preview-v2"
function ReviewStory(props: { split?: boolean }) {
return (
@@ -80,6 +81,36 @@ function InteractiveCommentsStory() {
export const InteractiveComments = { render: () => <InteractiveCommentsStory /> }
function InteractiveCommentsV2Story() {
const [state, setState] = createStore({ comments: [] as SessionReviewComment[] })
const file = "src/review.ts"
const diff = {
file,
additions: 1,
deletions: 1,
status: "modified" as const,
patch:
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
}
return (
<CurrentSessionProviders document={editThenTestDocument}>
<div class="mx-auto h-screen min-h-[620px] w-full max-w-[900px] overflow-auto bg-background-base">
<SessionReviewFilePreviewV2
file={file}
diff={diff}
diffStyle="unified"
comments={state.comments}
onLineComment={(comment) =>
setState("comments", (comments) => [...comments, { id: `comment-${comments.length + 1}`, ...comment }])
}
/>
</div>
</CurrentSessionProviders>
)
}
export const InteractiveCommentsPanel = { render: () => <InteractiveCommentsV2Story /> }
const gitDiffs = [
{
// OpenCode 93e1f383dd79683af4fc5ad139cea0516603c838, unchanged git-show output.
@@ -3,6 +3,9 @@ export type HoverCommentLine = {
side?: "additions" | "deletions"
}
export const LINE_COMMENT_ACTION_GAP = 8
const LINE_COMMENT_ACTION_SIZE = 20
export function createHoverCommentUtility(props: {
label: string
getHoveredLine: () => HoverCommentLine | undefined
@@ -14,21 +17,22 @@ export function createHoverCommentUtility(props: {
button.type = "button"
button.ariaLabel = props.label
button.textContent = "+"
button.style.width = "20px"
button.style.height = "20px"
button.style.width = `${LINE_COMMENT_ACTION_SIZE}px`
button.style.height = `${LINE_COMMENT_ACTION_SIZE}px`
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(--icon-interactive-base)"
button.style.color = "var(--white)"
button.style.background = "var(--v2-background-bg-inverse)"
button.style.color = "var(--v2-icon-icon-inverse)"
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 = "30px"
button.style.zIndex = "110"
button.style.left = "-4px"
button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)"
let line: HoverCommentLine | undefined
@@ -59,6 +63,29 @@ export function createHoverCommentUtility(props: {
props.onSelect(next)
}
const startLineSelection = (event: PointerEvent) => {
const number = button.parentElement?.assignedSlot?.parentElement?.parentElement
if (!(number instanceof HTMLElement)) return
number.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
composed: true,
pointerId: event.pointerId,
pointerType: event.pointerType,
isPrimary: event.isPrimary,
button: event.button,
buttons: event.buttons,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
}),
)
}
document.addEventListener("pointermove", onHoverInvalidated, { passive: true })
document.addEventListener("scroll", onHoverInvalidated, { passive: true, capture: true })
button.addEventListener("mouseenter", sync)
@@ -67,6 +94,7 @@ export function createHoverCommentUtility(props: {
event.preventDefault()
event.stopPropagation()
sync()
startLineSelection(event)
})
button.addEventListener("mousedown", (event) => {
event.preventDefault()
@@ -73,6 +73,19 @@ export function readShadowLineSelection(opts: {
const startSide = opts.sideForNode?.(startNode)
const endSide = opts.sideForNode?.(endNode)
const side = startSide ?? endSide
const anchorTop = findElement(selection.anchorNode)
?.closest("[data-line], [data-alt-line]")
?.getBoundingClientRect().top
const focusElement = findElement(selection.focusNode)
const focusTop = focusElement?.closest("[data-line], [data-alt-line]")?.getBoundingClientRect().top
const code = focusElement?.closest("[data-code]")
const gutterRight = code?.firstElementChild?.getBoundingClientRect().right
const direction =
anchorTop === undefined || focusTop === undefined || anchorTop === focusTop
? ("same" as const)
: focusTop < anchorTop
? ("up" as const)
: ("down" as const)
const range: SelectedLineRange = { start, end }
if (side) range.side = side
@@ -81,5 +94,7 @@ export function readShadowLineSelection(opts: {
return {
range,
text: opts.preserveTextSelection && domRange ? toRange(domRange).cloneRange() : undefined,
direction,
gutterRight,
}
}
+6
View File
@@ -130,6 +130,12 @@ const unsafeCSS = `
color: var(--diffs-selection-number-fg);
}
[data-gutter-utility-slot] {
left: unset;
right: 0;
justify-content: flex-end;
}
[data-diff] [data-column-number][data-line-type='context'][data-selected-line],
[data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line],
[data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line],
@@ -228,6 +228,7 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop
hunkSeparators={view().fileDiff.isPartial ? "simple" : "line-info-basic"}
enableLineSelection={lineCommentsEnabled()}
enableGutterUtility={lineCommentsEnabled()}
textSelectionAction={lineCommentsEnabled() ? { label: i18n.t("ui.lineComment.add") } : undefined}
onLineSelected={(range: SelectedLineRange | null) => {
if (!lineCommentsEnabled()) return
commentsUi.onLineSelected(range)
@@ -117,85 +117,61 @@
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 12px;
gap: 12px;
padding: 0;
gap: 0;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-field"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
gap: 0;
width: 100%;
min-width: 0;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-label"] {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
font-size: 13px;
font-style: normal;
font-weight: 530;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
user-select: none;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"] {
display: block;
width: 100%;
min-width: 0;
min-height: 80px;
padding: 8px;
height: 80px;
padding: 12px;
margin: 0;
resize: vertical;
border: 1px solid var(--v2-border-border-base);
border-radius: 6px;
background:
linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-base);
resize: none;
border: 0;
background: transparent;
font-size: 13px;
font-style: normal;
font-weight: 440;
line-height: 1.35;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
scrollbar-width: none;
outline: none;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]::-webkit-scrollbar {
display: none;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]::placeholder {
color: var(--v2-text-text-faint);
user-select: none;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]:focus {
border-color: var(--v2-border-border-focus);
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer"] {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 4px;
padding-block: 0 12px;
padding-inline: 12px;
width: 100%;
min-width: 0;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer-meta"] {
flex: 1 1 auto;
min-width: 0;
font-size: 11px;
font-style: normal;
font-weight: 530;
line-height: 1;
letter-spacing: 0.05px;
color: var(--v2-text-text-faint);
font-variation-settings: "slnt" 0;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer-actions"] {
display: flex;
flex-direction: row;
@@ -61,7 +61,7 @@ export type LineCommentEditorMention = {
}
export interface LineCommentEditorProps extends Omit<ComponentProps<"div">, "children" | "onInput" | "onSubmit"> {
/** Visible field label above the textarea (default: “Comment”). */
/** Accessible editor label (default: “Comment”). */
heading?: JSX.Element | string
value: string
onInput: (value: string) => void
@@ -108,7 +108,6 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
"classList",
])
const heading = () => local.heading ?? i18n.t("ui.lineComment.submit")
const canSubmit = () => local.value.trim().length > 0
const closeMention = () => {
@@ -206,19 +205,19 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
>
<div data-slot="line-comment-v2-shell">
<div data-slot="line-comment-v2-field">
<div data-slot="line-comment-v2-label">{heading()}</div>
<textarea
ref={(el) => {
textareaRef = el
}}
data-slot="line-comment-v2-textarea"
aria-label={typeof local.heading === "string" ? local.heading : i18n.t("ui.lineComment.submit")}
dir="auto"
rows={local.rows ?? 3}
placeholder={local.placeholder ?? i18n.t("ui.lineComment.contextPlaceholder")}
value={local.value}
style={{ "unicode-bidi": "plaintext", "text-align": "start" }}
onInput={(e) => {
local.onInput(e.currentTarget.value)
onInput={(event) => {
local.onInput(event.currentTarget.value)
syncMention()
}}
onClick={() => syncMention()}
@@ -292,12 +291,11 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
</Show>
</div>
<div data-slot="line-comment-v2-footer">
<div data-slot="line-comment-v2-footer-meta">{local.selection}</div>
<div data-slot="line-comment-v2-footer-actions">
<Button type="button" size="normal" variant="ghost" onClick={() => local.onCancel()}>
<Button type="button" size="small" variant="ghost-muted" onClick={() => local.onCancel()}>
{local.cancelLabel ?? i18n.t("ui.lineComment.cancel")}
</Button>
<Button type="button" size="normal" variant="contrast" disabled={!canSubmit()} onClick={submit}>
<Button type="button" size="small" variant="submit" disabled={!canSubmit()} onClick={submit}>
{local.submitLabel ?? i18n.t("ui.lineComment.submit")}
</Button>
</div>
+1
View File
@@ -51,6 +51,7 @@ const source = {
"ui.lineComment.editorLabel.prefix": "Commenting on ",
"ui.lineComment.editorLabel.suffix": "",
"ui.lineComment.placeholder": "Add comment",
"ui.lineComment.add": "Add comment",
"ui.lineComment.contextPlaceholder": "Add context for this change",
"ui.lineComment.submit": "Comment",
"ui.lineComment.cancel": "Cancel",