diff options
| author | Adam <[email protected]> | 2026-01-22 13:10:51 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-22 22:12:12 -0600 |
| commit | 0eb523631d6b321960ecbc3893a74d3df086a5d7 (patch) | |
| tree | 798c04d55fa36df0bf67e4826eaa32ede4dd0d6c | |
| parent | 99e15caaf6c736e0c8ebc702e264e4f7a0113e3c (diff) | |
| download | opencode-0eb523631d6b321960ecbc3893a74d3df086a5d7.tar.gz opencode-0eb523631d6b321960ecbc3893a74d3df086a5d7.zip | |
wip(app): line selection
| -rw-r--r-- | packages/app/src/components/prompt-input.tsx | 1 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 299 | ||||
| -rw-r--r-- | packages/ui/src/components/session-review.css | 19 | ||||
| -rw-r--r-- | packages/ui/src/components/session-review.tsx | 381 |
4 files changed, 431 insertions, 269 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 10af351cb..8dc64b428 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1568,6 +1568,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { class="h-5 w-5" onClick={(e) => { e.stopPropagation() + if (item.commentID) comments.remove(item.path, item.commentID) prompt.context.remove(item.key) }} aria-label={language.t("prompt.context.removeFile")} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index dea9c3d44..96de3f117 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -39,6 +39,7 @@ import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLayout } from "@/context/layout" import { Terminal } from "@/components/terminal" import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode" +import { getFilename } from "@opencode-ai/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import { DialogSelectModel } from "@/components/dialog-select-model" @@ -1866,6 +1867,258 @@ export default function Page() { return `L${sel.startLine}-${sel.endLine}` }) + let wrap: HTMLDivElement | undefined + let textarea: HTMLTextAreaElement | undefined + + const fileComments = createMemo(() => { + const p = path() + if (!p) return [] + return comments.list(p) + }) + + const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) + + const [openedComment, setOpenedComment] = createSignal<string | null>(null) + const [commenting, setCommenting] = createSignal<SelectedLineRange | null>(null) + const [draft, setDraft] = createSignal("") + const [positions, setPositions] = createSignal<Record<string, number>>({}) + const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined) + + const commentLabel = (range: SelectedLineRange) => { + const start = Math.min(range.start, range.end) + const end = Math.max(range.start, range.end) + if (start === end) return `line ${start}` + return `lines ${start}-${end}` + } + + const getRoot = () => { + const el = wrap + if (!el) return + + const host = el.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) return + + const root = host.shadowRoot + if (!root) return + + return root + } + + const findMarker = (root: ShadowRoot, range: SelectedLineRange) => { + const line = Math.max(range.start, range.end) + const node = root.querySelector(`[data-line="${line}"]`) + if (!(node instanceof HTMLElement)) return + return node + } + + const 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) + } + + const updateComments = () => { + const el = wrap + const root = getRoot() + if (!el || !root) { + setPositions({}) + setDraftTop(undefined) + return + } + + const next: Record<string, number> = {} + for (const comment of fileComments()) { + const marker = findMarker(root, comment.selection) + if (!marker) continue + next[comment.id] = markerTop(el, marker) + } + + setPositions(next) + + const range = commenting() + if (!range) { + setDraftTop(undefined) + return + } + + const marker = findMarker(root, range) + if (!marker) { + setDraftTop(undefined) + return + } + + setDraftTop(markerTop(el, marker)) + } + + const scheduleComments = () => { + requestAnimationFrame(updateComments) + } + + createEffect(() => { + fileComments() + scheduleComments() + }) + + createEffect(() => { + commenting() + scheduleComments() + }) + + createEffect(() => { + const range = commenting() + if (!range) return + setDraft("") + requestAnimationFrame(() => textarea?.focus()) + }) + + const renderCode = (source: string, wrapperClass: string) => ( + <div + ref={(el) => { + wrap = el + scheduleComments() + }} + class={`relative overflow-hidden ${wrapperClass}`} + > + <Dynamic + component={codeComponent} + file={{ + name: path() ?? "", + contents: source, + cacheKey: cacheKey(), + }} + enableLineSelection + selectedLines={selectedLines()} + commentedLines={commentedLines()} + onRendered={() => { + requestAnimationFrame(restoreScroll) + requestAnimationFrame(updateSelectionPopover) + requestAnimationFrame(scheduleComments) + }} + onLineSelected={(range: SelectedLineRange | null) => { + const p = path() + if (!p) return + file.setSelectedLines(p, range) + if (!range) setCommenting(null) + }} + onLineSelectionEnd={(range: SelectedLineRange | null) => { + if (!range) { + setCommenting(null) + return + } + + setOpenedComment(null) + setCommenting(range) + }} + overflow="scroll" + class="select-text" + /> + <For each={fileComments()}> + {(comment) => ( + <div + class="absolute right-6 z-30" + style={{ + top: `${positions()[comment.id] ?? 0}px`, + opacity: positions()[comment.id] === undefined ? 0 : 1, + "pointer-events": positions()[comment.id] === undefined ? "none" : "auto", + }} + > + <button + type="button" + class="size-5 rounded-md flex items-center justify-center bg-surface-warning-base border border-border-warning-base text-icon-warning-active shadow-xs hover:bg-surface-warning-weak hover:border-border-warning-hover focus:outline-none focus-visible:shadow-xs-border-focus" + onMouseEnter={() => { + const p = path() + if (!p) return + file.setSelectedLines(p, comment.selection) + }} + onClick={() => { + const p = path() + if (!p) return + setCommenting(null) + setOpenedComment((current) => (current === comment.id ? null : comment.id)) + file.setSelectedLines(p, comment.selection) + }} + > + <Icon name="speech-bubble" size="small" /> + </button> + <Show when={openedComment() === comment.id}> + <div class="absolute top-0 right-[calc(100%+12px)] z-40 min-w-[200px] max-w-[320px] rounded-md bg-surface-raised-stronger-non-alpha border border-border-base shadow-md p-3"> + <div class="flex flex-col gap-1.5"> + <div class="text-12-medium text-text-strong whitespace-nowrap"> + {getFilename(comment.file)}:{commentLabel(comment.selection)} + </div> + <div class="text-12-regular text-text-base whitespace-pre-wrap"> + {comment.comment} + </div> + </div> + </div> + </Show> + </div> + )} + </For> + <Show when={commenting()}> + {(range) => ( + <Show when={draftTop() !== undefined}> + <div class="absolute right-6 z-30" style={{ top: `${draftTop() ?? 0}px` }}> + <button + type="button" + class="size-5 rounded-md flex items-center justify-center bg-surface-warning-base border border-border-warning-base text-icon-warning-active shadow-xs hover:bg-surface-warning-weak hover:border-border-warning-hover focus:outline-none focus-visible:shadow-xs-border-focus" + onClick={() => textarea?.focus()} + > + <Icon name="speech-bubble" size="small" /> + </button> + <div class="absolute top-0 right-[calc(100%+12px)] z-40 min-w-[200px] max-w-[320px] rounded-md bg-surface-raised-stronger-non-alpha border border-border-base shadow-md p-3"> + <div class="flex flex-col gap-2"> + <div class="text-12-medium text-text-strong"> + Commenting on {getFilename(path() ?? "")}:{commentLabel(range())} + </div> + <textarea + ref={textarea} + class="w-[320px] max-w-[calc(100vw-48px)] resize-vertical p-2 rounded-sm bg-surface-base border border-border-base text-text-strong text-12-regular leading-5 focus:outline-none focus:shadow-xs-border-focus" + rows={3} + placeholder="Add a comment" + value={draft()} + onInput={(e) => setDraft(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key !== "Enter") return + if (e.shiftKey) return + e.preventDefault() + const value = draft().trim() + if (!value) return + const p = path() + if (!p) return + addCommentToContext({ file: p, selection: range(), comment: value }) + setCommenting(null) + }} + /> + <div class="flex justify-end gap-2"> + <Button size="small" variant="ghost" onClick={() => setCommenting(null)}> + Cancel + </Button> + <Button + size="small" + variant="secondary" + disabled={draft().trim().length === 0} + onClick={() => { + const value = draft().trim() + if (!value) return + const p = path() + if (!p) return + addCommentToContext({ file: p, selection: range(), comment: value }) + setCommenting(null) + }} + > + Comment + </Button> + </div> + </div> + </div> + </div> + </Show> + )} + </Show> + </div> + ) + const updateSelectionPopover = () => { const el = scroll if (!el) { @@ -2107,27 +2360,7 @@ export default function Page() { </Match> <Match when={state()?.loaded && isSvg()}> <div class="flex flex-col gap-4 px-6 py-4"> - <Dynamic - component={codeComponent} - file={{ - name: path() ?? "", - contents: svgContent() ?? "", - cacheKey: cacheKey(), - }} - enableLineSelection - selectedLines={selectedLines()} - onRendered={() => { - requestAnimationFrame(restoreScroll) - requestAnimationFrame(updateSelectionPopover) - }} - onLineSelected={(range: SelectedLineRange | null) => { - const p = path() - if (!p) return - file.setSelectedLines(p, range) - }} - overflow="scroll" - class="select-text" - /> + {renderCode(svgContent() ?? "", "")} <Show when={svgPreviewUrl()}> <div class="flex justify-center pb-40"> <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" /> @@ -2135,29 +2368,7 @@ export default function Page() { </Show> </div> </Match> - <Match when={state()?.loaded}> - <Dynamic - component={codeComponent} - file={{ - name: path() ?? "", - contents: contents(), - cacheKey: cacheKey(), - }} - enableLineSelection - selectedLines={selectedLines()} - onRendered={() => { - requestAnimationFrame(restoreScroll) - requestAnimationFrame(updateSelectionPopover) - }} - onLineSelected={(range: SelectedLineRange | null) => { - const p = path() - if (!p) return - file.setSelectedLines(p, range) - }} - overflow="scroll" - class="select-text pb-40" - /> - </Match> + <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match> <Match when={state()?.loading}> <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div> </Match> diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index 26ca73265..f34c8b446 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -75,13 +75,18 @@ overflow: hidden; } - [data-component="popover-content"] { - position: absolute !important; - } - - .session-review-comment-popover-content { - left: auto !important; - right: calc(100% + 12px) !important; + [data-slot="session-review-comment-popover-content"] { + position: absolute; + top: 0; + right: calc(100% + 12px); + z-index: 40; + min-width: 200px; + max-width: min(320px, calc(100vw - 48px)); + border-radius: var(--radius-md); + background-color: var(--surface-raised-stronger-non-alpha); + border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent); + box-shadow: var(--shadow-md); + padding: 12px; } [data-slot="session-review-trigger-content"] { diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 4096f341b..7c35c9226 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -1,6 +1,5 @@ import { Accordion } from "./accordion" import { Button } from "./button" -import { Popover } from "./popover" import { RadioGroup } from "./radio-group" import { DiffChanges } from "./diff-changes" import { FileIcon } from "./file-icon" @@ -151,6 +150,7 @@ function markerTop(wrapper: HTMLElement, marker: HTMLElement) { export const SessionReview = (props: SessionReviewProps) => { let scroll: HTMLDivElement | undefined + let focusToken = 0 const i18n = useI18n() const diffComponent = useDiffComponent() const anchors = new Map<string, HTMLElement>() @@ -201,6 +201,9 @@ export const SessionReview = (props: SessionReviewProps) => { const focus = props.focusedComment if (!focus) return + focusToken++ + const token = focusToken + setOpened(focus) const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id) @@ -211,31 +214,35 @@ export const SessionReview = (props: SessionReviewProps) => { handleChange([...current, focus.file]) } - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const root = scroll - if (!root) return - - const anchor = root.querySelector(`[data-comment-id="${focus.id}"]`) - if (anchor instanceof HTMLElement) { - const rootRect = root.getBoundingClientRect() - const anchorRect = anchor.getBoundingClientRect() - const offset = anchorRect.top - rootRect.top - const next = root.scrollTop + offset - rootRect.height / 2 + anchorRect.height / 2 - root.scrollTop = Math.max(0, next) - return - } - - const target = anchors.get(focus.file) - if (!target) return - - const rootRect = root.getBoundingClientRect() - const targetRect = target.getBoundingClientRect() - const offset = targetRect.top - rootRect.top - const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2 - root.scrollTop = Math.max(0, next) - }) - }) + const scrollTo = (attempt: number) => { + if (token !== focusToken) return + + const root = scroll + if (!root) return + + const anchor = root.querySelector(`[data-comment-id="${focus.id}"]`) + const ready = + anchor instanceof HTMLElement && anchor.style.pointerEvents !== "none" && anchor.style.opacity !== "0" + + const target = ready ? anchor : anchors.get(focus.file) + if (!target) { + if (attempt >= 24) return + requestAnimationFrame(() => scrollTo(attempt + 1)) + return + } + + const rootRect = root.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const offset = targetRect.top - rootRect.top + const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2 + root.scrollTop = Math.max(0, next) + + if (ready) return + if (attempt >= 24) return + requestAnimationFrame(() => scrollTo(attempt + 1)) + } + + requestAnimationFrame(() => scrollTo(0)) requestAnimationFrame(() => props.onFocusedCommentChange?.(null)) }) @@ -519,207 +526,145 @@ export const SessionReview = (props: SessionReviewProps) => { </Accordion.Trigger> </StickyAccordionHeader> <Accordion.Content data-slot="session-review-accordion-content"> - <Switch> - <Match when={isImage()}> - <div data-slot="session-review-image-container"> - <Show - when={imageSrc()} - fallback={ - <div data-slot="session-review-image-placeholder"> - <Switch> - <Match when={imageStatus() === "loading"}>Loading image...</Match> - <Match when={true}>Image preview unavailable</Match> - </Switch> - </div> - } - > - <img data-slot="session-review-image" src={imageSrc()!} alt={getFilename(diff.file)} /> - </Show> - </div> - </Match> - <Match when={isAudio()}> - <div data-slot="session-review-audio-container"> - <Show - when={audioSrc() && audioStatus() !== "error"} - fallback={ - <div data-slot="session-review-audio-placeholder"> - <Switch> - <Match when={audioStatus() === "loading"}>Loading audio...</Match> - <Match when={true}>Audio preview unavailable</Match> - </Switch> - </div> - } + <div + data-slot="session-review-diff-wrapper" + ref={(el) => { + wrapper = el + anchors.set(diff.file, el) + scheduleAnchors() + }} + > + <Dynamic + component={diffComponent} + preloadedDiff={diff.preloaded} + diffStyle={diffStyle()} + onRendered={() => { + props.onDiffRendered?.() + scheduleAnchors() + }} + enableLineSelection={props.onLineComment != null} + onLineSelected={handleLineSelected} + onLineSelectionEnd={handleLineSelectionEnd} + selectedLines={selectedLines()} + commentedLines={commentedLines()} + before={{ + name: diff.file!, + contents: typeof diff.before === "string" ? diff.before : "", + }} + after={{ + name: diff.file!, + contents: typeof diff.after === "string" ? diff.after : "", + }} + /> + + <For each={comments()}> + {(comment) => ( + <div + data-slot="session-review-comment-anchor" + data-comment-id={comment.id} + style={{ + top: `${positions()[comment.id] ?? 0}px`, + opacity: positions()[comment.id] === undefined ? 0 : 1, + "pointer-events": positions()[comment.id] === undefined ? "none" : "auto", + }} > - <audio - data-slot="session-review-audio" - controls - preload="metadata" - onError={() => { - setAudioStatus("error") + <button + type="button" + data-slot="session-review-comment-button" + onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })} + onClick={() => { + if (isCommentOpen(comment)) { + setOpened(null) + return + } + + openComment(comment) }} > - <source src={audioSrc()!} type={audioMime()} /> - </audio> - </Show> - </div> - </Match> - <Match when={true}> - <div - data-slot="session-review-diff-wrapper" - ref={(el) => { - wrapper = el - anchors.set(diff.file, el) - scheduleAnchors() - }} - > - <Dynamic - component={diffComponent} - preloadedDiff={diff.preloaded} - diffStyle={diffStyle()} - onRendered={() => { - 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(), - }} - /> - - <For each={comments()}> - {(comment) => ( - <div - data-slot="session-review-comment-anchor" - data-comment-id={comment.id} - style={{ - top: `${positions()[comment.id] ?? 0}px`, - opacity: positions()[comment.id] === undefined ? 0 : 1, - "pointer-events": positions()[comment.id] === undefined ? "none" : "auto", - }} - > - <Popover - portal={false} - open={isCommentOpen(comment)} - class="session-review-comment-popover-content" - onOpenChange={(open) => { - if (open) { - openComment(comment) - return - } - if (!isCommentOpen(comment)) return - setOpened(null) - }} - trigger={ - <button - type="button" - data-slot="session-review-comment-button" - onMouseEnter={() => - setSelection({ file: comment.file, range: comment.selection }) - } - > - <Icon name="speech-bubble" size="small" /> - </button> - } - > - <div data-slot="session-review-comment-popover"> - <div data-slot="session-review-comment-popover-label"> - {getFilename(comment.file)}:{selectionLabel(comment.selection)} - </div> - <div data-slot="session-review-comment-popover-text">{comment.comment}</div> - <Show when={selectionPreview(diff, comment.selection)}> - {(preview) => <pre data-slot="session-review-comment-preview">{preview()}</pre>} - </Show> + <Icon name="speech-bubble" size="small" /> + </button> + <Show when={isCommentOpen(comment)}> + <div data-slot="session-review-comment-popover-content"> + <div data-slot="session-review-comment-popover"> + <div data-slot="session-review-comment-popover-label"> + {getFilename(comment.file)}:{selectionLabel(comment.selection)} </div> - </Popover> + <div data-slot="session-review-comment-popover-text">{comment.comment}</div> + </div> </div> - )} - </For> - - <Show when={draftRange()}> - {(range) => ( - <Show when={draftTop() !== undefined}> - <div data-slot="session-review-comment-anchor" style={{ top: `${draftTop() ?? 0}px` }}> - <Popover - portal={false} - open={true} - class="session-review-comment-popover-content" - onOpenChange={(open) => { - if (open) return + </Show> + </div> + )} + </For> + + <Show when={draftRange()}> + {(range) => ( + <Show when={draftTop() !== undefined}> + <div data-slot="session-review-comment-anchor" style={{ top: `${draftTop() ?? 0}px` }}> + <button + type="button" + data-slot="session-review-comment-button" + onClick={() => textarea?.focus()} + > + <Icon name="speech-bubble" size="small" /> + </button> + <div data-slot="session-review-comment-popover-content"> + <div data-slot="session-review-comment-popover"> + <div data-slot="session-review-comment-popover-label"> + Commenting on {getFilename(diff.file)}:{selectionLabel(range())} + </div> + <textarea + ref={textarea} + data-slot="session-review-comment-textarea" + rows={3} + placeholder="Add a comment" + value={draft()} + onInput={(e) => setDraft(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key !== "Enter") return + if (e.shiftKey) return + e.preventDefault() + const value = draft().trim() + if (!value) return + props.onLineComment?.({ + file: diff.file, + selection: range(), + comment: value, + preview: selectionPreview(diff, range()), + }) setCommenting(null) }} - trigger={ - <button type="button" data-slot="session-review-comment-button"> - <Icon name="speech-bubble" size="small" /> - </button> - } - > - <div data-slot="session-review-comment-popover"> - <div data-slot="session-review-comment-popover-label"> - Commenting on {getFilename(diff.file)}:{selectionLabel(range())} - </div> - <textarea - ref={textarea} - data-slot="session-review-comment-textarea" - rows={3} - placeholder="Add a comment" - value={draft()} - onInput={(e) => setDraft(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key !== "Enter") return - if (e.shiftKey) return - e.preventDefault() - const value = draft().trim() - if (!value) return - props.onLineComment?.({ - file: diff.file, - selection: range(), - comment: value, - preview: selectionPreview(diff, range()), - }) - setCommenting(null) - }} - /> - <div data-slot="session-review-comment-actions"> - <Button size="small" variant="ghost" onClick={() => setCommenting(null)}> - Cancel - </Button> - <Button - size="small" - variant="secondary" - disabled={draft().trim().length === 0} - onClick={() => { - const value = draft().trim() - if (!value) return - props.onLineComment?.({ - file: diff.file, - selection: range(), - comment: value, - preview: selectionPreview(diff, range()), - }) - setCommenting(null) - }} - > - Comment - </Button> - </div> - </div> - </Popover> + /> + <div data-slot="session-review-comment-actions"> + <Button size="small" variant="ghost" onClick={() => setCommenting(null)}> + Cancel + </Button> + <Button + size="small" + variant="secondary" + disabled={draft().trim().length === 0} + onClick={() => { + const value = draft().trim() + if (!value) return + props.onLineComment?.({ + file: diff.file, + selection: range(), + comment: value, + preview: selectionPreview(diff, range()), + }) + setCommenting(null) + }} + > + Comment + </Button> + </div> </div> - </Show> - )} + </div> + </div> </Show> - </div> - </Match> - </Switch> + )} + </Show> + </div> </Accordion.Content> </Accordion.Item> ) |
