mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-19 15:17:51 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df010c9ae8 | ||
|
|
cda0e981d2 | ||
|
|
5e8d4790a3 | ||
|
|
d390419abe | ||
|
|
86d5b05d30 | ||
|
|
6573f923b8 |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user