diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 56d6ec406..4fee0852f 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -19,6 +19,7 @@ import { SettingsProvider } from "@/context/settings" import { TerminalProvider } from "@/context/terminal" import { PromptProvider } from "@/context/prompt" import { FileProvider } from "@/context/file" +import { CommentsProvider } from "@/context/comments" import { NotificationProvider } from "@/context/notification" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" @@ -128,13 +129,15 @@ export function AppInterface(props: { defaultUrl?: string }) { component={(p) => ( - - - }> - - - - + + + + }> + + + + + )} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5e936737a..b2c8cccca 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -30,6 +30,7 @@ import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useNavigate, useParams } from "@solidjs/router" import { useSync } from "@/context/sync" +import { useComments } from "@/context/comments" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -115,6 +116,7 @@ export const PromptInput: Component = (props) => { const files = useFile() const prompt = usePrompt() const layout = useLayout() + const comments = useComments() const params = useParams() const dialog = useDialog() const providers = useProviders() @@ -158,6 +160,7 @@ export const PromptInput: Component = (props) => { const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey())) + const view = createMemo(() => layout.view(sessionKey())) const activeFile = createMemo(() => { const tab = tabs().active() if (!tab) return @@ -1555,7 +1558,18 @@ export const PromptInput: Component = (props) => { {(item) => { const preview = createMemo(() => selectionPreview(item.path, item.selection, item.preview)) return ( -
+
{ + if (!item.commentID) return + comments.setFocus({ file: item.path, id: item.commentID }) + view().reviewPanel.open() + tabs().open("review") + }} + >
@@ -1576,7 +1590,10 @@ export const PromptInput: Component = (props) => { icon="close" variant="ghost" class="h-5 w-5" - onClick={() => prompt.context.remove(item.key)} + onClick={(e) => { + e.stopPropagation() + prompt.context.remove(item.key) + }} aria-label={language.t("prompt.context.removeFile")} />
diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx new file mode 100644 index 000000000..12ee977e9 --- /dev/null +++ b/packages/app/src/context/comments.tsx @@ -0,0 +1,140 @@ +import { batch, createMemo, createRoot, createSignal, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useParams } from "@solidjs/router" +import { Persist, persisted } from "@/utils/persist" +import type { SelectedLineRange } from "@/context/file" + +export type LineComment = { + id: string + file: string + selection: SelectedLineRange + comment: string + time: number +} + +type CommentFocus = { file: string; id: string } + +const WORKSPACE_KEY = "__workspace__" +const MAX_COMMENT_SESSIONS = 20 + +type CommentSession = ReturnType + +type CommentCacheEntry = { + value: CommentSession + dispose: VoidFunction +} + +function createCommentSession(dir: string, id: string | undefined) { + const legacy = `${dir}/comments${id ? "/" + id : ""}.v1` + + const [store, setStore, _, ready] = persisted( + Persist.scoped(dir, id, "comments", [legacy]), + createStore<{ + comments: Record + }>({ + comments: {}, + }), + ) + + const [focus, setFocus] = createSignal(null) + + const list = (file: string) => store.comments[file] ?? [] + + const add = (input: Omit) => { + const next: LineComment = { + id: crypto.randomUUID(), + time: Date.now(), + ...input, + } + + batch(() => { + setStore("comments", input.file, (items) => [...(items ?? []), next]) + setFocus({ file: input.file, id: next.id }) + }) + + return next + } + + const remove = (file: string, id: string) => { + setStore("comments", file, (items) => (items ?? []).filter((x) => x.id !== id)) + setFocus((current) => (current?.id === id ? null : current)) + } + + const all = createMemo(() => { + const files = Object.keys(store.comments) + const items = files.flatMap((file) => store.comments[file] ?? []) + return items.slice().sort((a, b) => a.time - b.time) + }) + + return { + ready, + list, + all, + add, + remove, + focus: createMemo(() => focus()), + setFocus, + clearFocus: () => setFocus(null), + } +} + +export const { use: useComments, provider: CommentsProvider } = createSimpleContext({ + name: "Comments", + gate: false, + init: () => { + const params = useParams() + const cache = new Map() + + const disposeAll = () => { + for (const entry of cache.values()) { + entry.dispose() + } + cache.clear() + } + + onCleanup(disposeAll) + + const prune = () => { + while (cache.size > MAX_COMMENT_SESSIONS) { + const first = cache.keys().next().value + if (!first) return + const entry = cache.get(first) + entry?.dispose() + cache.delete(first) + } + } + + const load = (dir: string, id: string | undefined) => { + const key = `${dir}:${id ?? WORKSPACE_KEY}` + const existing = cache.get(key) + if (existing) { + cache.delete(key) + cache.set(key, existing) + return existing.value + } + + const entry = createRoot((dispose) => ({ + value: createCommentSession(dir, id), + dispose, + })) + + cache.set(key, entry) + prune() + return entry.value + } + + const session = createMemo(() => load(params.dir!, params.id)) + + return { + ready: () => session().ready(), + list: (file: string) => session().list(file), + all: () => session().all(), + add: (input: Omit) => session().add(input), + remove: (file: string, id: string) => session().remove(file, id), + focus: () => session().focus(), + setFocus: (focus: CommentFocus | null) => session().setFocus(focus), + clearFocus: () => session().clearFocus(), + } + }, +}) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index a76d9d5f1..40baa0ef5 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -43,6 +43,7 @@ export type FileContextItem = { path: string selection?: FileSelection comment?: string + commentID?: string preview?: string } @@ -139,6 +140,11 @@ function createPromptSession(dir: string, id: string | undefined) { const start = item.selection?.startLine const end = item.selection?.endLine const key = `${item.type}:${item.path}:${start}:${end}` + + if (item.commentID) { + return `${key}:c=${item.commentID}` + } + const comment = item.comment?.trim() if (!comment) return key const digest = checksum(comment) ?? comment diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 1e0d7a89e..b2d9747c7 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -51,6 +51,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2" import type { FileDiff } from "@opencode-ai/sdk/v2/client" import { useSDK } from "@/context/sdk" import { usePrompt } from "@/context/prompt" +import { useComments, type LineComment } from "@/context/comments" import { extractPromptFromParts } from "@/utils/prompt" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { usePermission } from "@/context/permission" @@ -82,6 +83,9 @@ interface SessionReviewTabProps { onDiffStyleChange?: (style: DiffStyle) => void onViewFile?: (file: string) => void onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void + comments?: LineComment[] + focusedComment?: { file: string; id: string } | null + onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void classes?: { root?: string header?: string @@ -168,6 +172,9 @@ function SessionReviewTab(props: SessionReviewTabProps) { onViewFile={props.onViewFile} readFile={readFile} onLineComment={props.onLineComment} + comments={props.comments} + focusedComment={props.focusedComment} + onFocusedCommentChange={props.onFocusedCommentChange} /> ) } @@ -187,6 +194,7 @@ export default function Page() { const navigate = useNavigate() const sdk = useSDK() const prompt = usePrompt() + const comments = useComments() const permission = usePermission() const [pendingMessage, setPendingMessage] = createSignal(undefined) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) @@ -513,11 +521,17 @@ export default function Page() { }) => { const selection = selectionFromLines(input.selection) const preview = input.preview ?? selectionPreview(input.file, selection) + const saved = comments.add({ + file: input.file, + selection: input.selection, + comment: input.comment, + }) prompt.context.add({ type: "file", path: input.file, selection, comment: input.comment, + commentID: saved.id, preview, }) } @@ -1433,6 +1447,9 @@ export default function Page() { view={view} diffStyle="unified" onLineComment={addCommentToContext} + comments={comments.all()} + focusedComment={comments.focus()} + onFocusedCommentChange={comments.setFocus} onViewFile={(path) => { const value = file.tab(path) tabs().open(value) @@ -1749,6 +1766,9 @@ export default function Page() { diffStyle={layout.review.diffStyle()} onDiffStyleChange={layout.review.setDiffStyle} onLineComment={addCommentToContext} + comments={comments.all()} + focusedComment={comments.focus()} + onFocusedCommentChange={comments.setFocus} 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 da99ba3b7..ac98a6d24 100644 --- a/packages/ui/src/components/diff-ssr.tsx +++ b/packages/ui/src/components/diff-ssr.tsx @@ -1,4 +1,4 @@ -import { DIFFS_TAG_NAME, FileDiff } from "@pierre/diffs" +import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange } from "@pierre/diffs" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js" import { Dynamic, isServer } from "solid-js/web" @@ -19,12 +19,50 @@ export function Diff(props: SSRDiffProps) { "classList", "annotations", "selectedLines", + "commentedLines", ]) const workerPool = useWorkerPool(props.diffStyle) let fileDiffInstance: FileDiff | undefined const cleanupFunctions: Array<() => void> = [] + const getRoot = () => fileDiffRef?.shadowRoot ?? undefined + + const findSide = (element: HTMLElement): "additions" | "deletions" => { + const code = element.closest("[data-code]") + if (!(code instanceof HTMLElement)) return "additions" + if (code.hasAttribute("data-deletions")) return "deletions" + return "additions" + } + + 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 expectedSide = + line === end ? (range.endSide ?? range.side) : line === start ? range.side : (range.side ?? range.endSide) + + const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)) + for (const node of nodes) { + if (!(node instanceof HTMLElement)) continue + if (expectedSide && findSide(node) !== expectedSide) continue + node.setAttribute("data-comment-selected", "") + } + } + } + } + onMount(() => { if (isServer || !props.preloadedDiff) return fileDiffInstance = new FileDiff( @@ -55,6 +93,11 @@ export function Diff(props: SSRDiffProps) { fileDiffInstance?.setSelectedLines(local.selectedLines ?? null) }) + createEffect(() => { + const ranges = local.commentedLines ?? [] + requestAnimationFrame(() => applyCommentedLines(ranges)) + }) + // 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 20dd5c440..825a7e076 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -63,6 +63,7 @@ export function Diff(props: DiffProps) { "classList", "annotations", "selectedLines", + "commentedLines", "onRendered", ]) @@ -82,6 +83,7 @@ export function Diff(props: DiffProps) { let instance: FileDiff | undefined const [current, setCurrent] = createSignal | undefined>(undefined) + const [rendered, setRendered] = createSignal(0) const getRoot = () => { const host = container.querySelector("diffs-container") @@ -172,6 +174,39 @@ export function Diff(props: DiffProps) { observer.observe(container, { childList: true, subtree: true }) } + 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 expectedSide = + line === end ? (range.endSide ?? range.side) : line === start ? range.side : (range.side ?? range.endSide) + + const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)) + for (const node of nodes) { + if (!(node instanceof HTMLElement)) continue + + if (expectedSide) { + const side = findSide(node) + if (side && side !== expectedSide) continue + } + + node.setAttribute("data-comment-selected", "") + } + } + } + } + const setSelectedLines = (range: SelectedLineRange | null) => { const active = current() if (!active) return @@ -379,9 +414,16 @@ export function Diff(props: DiffProps) { containerWrapper: container, }) + setRendered((value) => value + 1) notifyRendered() }) + createEffect(() => { + rendered() + const ranges = local.commentedLines ?? [] + requestAnimationFrame(() => applyCommentedLines(ranges)) + }) + createEffect(() => { const selected = local.selectedLines ?? null setSelectedLines(selected) diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index a53289b9a..775d3d444 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -195,4 +195,103 @@ font-size: var(--font-size-small); color: var(--text-weak); } + + [data-slot="session-review-diff-wrapper"] { + position: relative; + } + + [data-slot="session-review-comment-anchor"] { + position: absolute; + right: 12px; + z-index: 30; + } + + [data-slot="session-review-comment-button"] { + width: 20px; + height: 20px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + background: var(--surface-base); + border: 1px solid color-mix(in oklch, var(--icon-info-active) 60%, transparent); + color: var(--icon-info-active); + box-shadow: var(--shadow-xs-border); + cursor: pointer; + + &:hover { + background: var(--surface-raised-base-hover); + border-color: var(--icon-info-active); + } + + &:focus { + outline: none; + } + + &:focus-visible { + box-shadow: var(--shadow-xs-border-focus); + } + } + + [data-slot="session-review-comment-hover"] { + display: flex; + flex-direction: column; + gap: 6px; + max-width: 320px; + } + + [data-slot="session-review-comment-hover-label"], + [data-slot="session-review-comment-popover-label"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + color: var(--text-strong); + } + + [data-slot="session-review-comment-hover-text"], + [data-slot="session-review-comment-popover-text"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-regular); + color: var(--text-base); + white-space: pre-wrap; + } + + [data-slot="session-review-comment-preview"] { + margin: 0; + padding: 8px; + border-radius: var(--radius-sm); + background: var(--surface-base); + border: 1px solid color-mix(in oklch, var(--border-base) 55%, transparent); + color: var(--text-base); + font-family: var(--font-family-mono); + font-size: var(--font-size-small); + line-height: 1.4; + white-space: pre-wrap; + } + + [data-slot="session-review-comment-textarea"] { + width: 320px; + max-width: calc(100vw - 48px); + resize: vertical; + padding: 8px; + border-radius: var(--radius-sm); + background: var(--surface-base); + border: 1px solid color-mix(in oklch, var(--border-base) 55%, transparent); + color: var(--text-strong); + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + line-height: 1.4; + + &:focus { + outline: none; + box-shadow: var(--shadow-xs-border-focus); + } + } + + [data-slot="session-review-comment-actions"] { + display: flex; + justify-content: flex-end; + gap: 8px; + } } diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 814281723..7afebdced 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -1,30 +1,49 @@ 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" import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { StickyAccordionHeader } from "./sticky-accordion-header" -import { useCodeComponent } from "../context/code" 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, 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 { type SelectedLineRange } from "@pierre/diffs" import { Dynamic } from "solid-js/web" export type SessionReviewDiffStyle = "unified" | "split" +export type SessionReviewComment = { + id: string + file: string + selection: SelectedLineRange + comment: string +} + +export type SessionReviewLineComment = { + file: string + selection: SelectedLineRange + comment: string + preview?: string +} + +export type SessionReviewFocus = { file: string; id: string } + export interface SessionReviewProps { split?: boolean diffStyle?: SessionReviewDiffStyle onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void onDiffRendered?: () => void onLineComment?: (comment: SessionReviewLineComment) => void + comments?: SessionReviewComment[] + focusedComment?: SessionReviewFocus | null + onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void open?: string[] onOpenChange?: (open: string[]) => void scrollRef?: (el: HTMLDivElement) => void @@ -105,29 +124,43 @@ type SessionReviewSelection = { range: SelectedLineRange } -type SessionReviewLineComment = { - file: string - selection: SelectedLineRange - comment: string - preview?: string +function findSide(element: HTMLElement): "additions" | "deletions" { + const code = element.closest("[data-code]") + if (!(code instanceof HTMLElement)) return "additions" + if (code.hasAttribute("data-deletions")) return "deletions" + return "additions" } -type CommentAnnotationMeta = { - file: string - selection: SelectedLineRange - label: string - preview?: string +function findMarker(root: ShadowRoot, range: SelectedLineRange) { + const line = Math.max(range.start, range.end) + const side = range.endSide ?? range.side + const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + if (nodes.length === 0) return + if (!side) return nodes[0] + + const match = nodes.find((node) => findSide(node) === side) + return match ?? nodes[0] +} + +function markerTop(wrapper: HTMLElement, marker: HTMLElement) { + const wrapperRect = wrapper.getBoundingClientRect() + const rect = marker.getBoundingClientRect() + return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2) } export const SessionReview = (props: SessionReviewProps) => { const i18n = useI18n() const diffComponent = useDiffComponent() - const codeComponent = useCodeComponent() + const anchors = new Map() 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 [opened, setOpened] = createSignal(null) const open = () => props.open ?? store.open const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified") @@ -150,9 +183,6 @@ export const SessionReview = (props: SessionReviewProps) => { 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) => { @@ -167,88 +197,26 @@ export const SessionReview = (props: SessionReviewProps) => { return lines.slice(0, 2).join("\n") } - const renderAnnotation = (annotation: DiffLineAnnotation) => { - if (!props.onLineComment) return undefined - const meta = annotation.metadata - if (!meta) return undefined + createEffect(() => { + const focus = props.focusedComment + if (!focus) return - const wrapper = document.createElement("div") - wrapper.className = "relative" + setOpened(focus) - 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 comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id) + if (comment) setSelection({ file: comment.file, range: comment.selection }) - 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) + const current = open() + if (!current.includes(focus.file)) { + handleChange([...current, focus.file]) } - 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) + requestAnimationFrame(() => { + anchors.get(focus.file)?.scrollIntoView({ block: "center" }) }) - 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 - } + requestAnimationFrame(() => props.onFocusedCommentChange?.(null)) + }) return (
{ {(diff) => { + let wrapper: HTMLDivElement | undefined + let textarea: HTMLTextAreaElement | undefined + + const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file)) + const commentedLines = createMemo(() => comments().map((c) => c.selection)) + const beforeText = () => (typeof diff.before === "string" ? diff.before : "") const afterText = () => (typeof diff.after === "string" ? diff.after : "") @@ -321,27 +295,70 @@ export const SessionReview = (props: SessionReviewProps) => { return current.range }) - const commentingLines = createMemo(() => { + const draftRange = 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), - }, - }, - ] + const [draft, setDraft] = createSignal("") + const [positions, setPositions] = createSignal>({}) + const [draftTop, setDraftTop] = createSignal(undefined) + + const getRoot = () => { + const el = wrapper + if (!el) return + + const host = el.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) return + return host.shadowRoot ?? undefined + } + + const updateAnchors = () => { + const el = wrapper + if (!el) return + + const root = getRoot() + if (!root) return + + const next: Record = {} + for (const item of comments()) { + const marker = findMarker(root, item.selection) + if (!marker) continue + next[item.id] = markerTop(el, marker) + } + setPositions(next) + + const range = draftRange() + if (!range) { + setDraftTop(undefined) + return + } + + const marker = findMarker(root, range) + if (!marker) { + setDraftTop(undefined) + return + } + + setDraftTop(markerTop(el, marker)) + } + + const scheduleAnchors = () => { + requestAnimationFrame(updateAnchors) + } + + createEffect(() => { + comments() + scheduleAnchors() + }) + + createEffect(() => { + const range = draftRange() + if (!range) return + setDraft("") + scheduleAnchors() + requestAnimationFrame(() => textarea?.focus()) }) createEffect(() => { @@ -395,31 +412,15 @@ export const SessionReview = (props: SessionReviewProps) => { }) }) - const fileForCode = () => { - const contents = afterText() || beforeText() - return { - name: diff.file, - contents, - cacheKey: checksum(contents), - } - } - 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) => { @@ -434,6 +435,17 @@ export const SessionReview = (props: SessionReviewProps) => { setCommenting({ file: diff.file, range }) } + const openComment = (comment: SessionReviewComment) => { + setOpened({ file: comment.file, id: comment.id }) + setSelection({ file: comment.file, range: comment.selection }) + } + + const isCommentOpen = (comment: SessionReviewComment) => { + const current = opened() + if (!current) return false + return current.file === comment.file && current.id === comment.id + } + return ( @@ -526,32 +538,167 @@ export const SessionReview = (props: SessionReviewProps) => {
- -
- -
-
- { + wrapper = el + anchors.set(diff.file, el) + scheduleAnchors() }} - after={{ - name: diff.file!, - contents: afterText(), - }} - /> + > + { + props.onDiffRendered?.() + scheduleAnchors() + }} + enableLineSelection={props.onLineComment != null} + onLineSelected={handleLineSelected} + onLineSelectionEnd={handleLineSelectionEnd} + selectedLines={selectedLines()} + commentedLines={commentedLines()} + before={{ + name: diff.file!, + contents: beforeText(), + }} + after={{ + name: diff.file!, + contents: afterText(), + }} + /> + + + {(comment) => ( +
+ { + if (open) { + openComment(comment) + return + } + if (!isCommentOpen(comment)) return + setOpened(null) + }} + trigger={ + + setSelection({ file: comment.file, range: comment.selection }) + } + > + + + } + > +
+
+ {getFilename(comment.file)}:{selectionLabel(comment.selection)} +
+
{comment.comment}
+
+
+ } + > +
+
+ {getFilename(comment.file)}:{selectionLabel(comment.selection)} +
+
{comment.comment}
+ + {(preview) =>
{preview()}
} +
+
+
+
+ )} +
+ + + {(range) => ( + +
+ { + if (open) return + setCommenting(null) + }} + trigger={ + + } + > +
+
+ Commenting on {getFilename(diff.file)}:{selectionLabel(range())} +
+