wip(app): line selection

This commit is contained in:
Adam
2026-01-22 13:10:51 -06:00
parent 99e15caaf6
commit 0eb523631d
4 changed files with 427 additions and 265 deletions

View File

@@ -1568,6 +1568,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="h-5 w-5" class="h-5 w-5"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key) prompt.context.remove(item.key)
}} }}
aria-label={language.t("prompt.context.removeFile")} aria-label={language.t("prompt.context.removeFile")}

View File

@@ -39,6 +39,7 @@ import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { Terminal } from "@/components/terminal" import { Terminal } from "@/components/terminal"
import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode" 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 { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file" import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectModel } from "@/components/dialog-select-model"
@@ -1866,6 +1867,258 @@ export default function Page() {
return `L${sel.startLine}-${sel.endLine}` 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 updateSelectionPopover = () => {
const el = scroll const el = scroll
if (!el) { if (!el) {
@@ -2107,27 +2360,7 @@ export default function Page() {
</Match> </Match>
<Match when={state()?.loaded && isSvg()}> <Match when={state()?.loaded && isSvg()}>
<div class="flex flex-col gap-4 px-6 py-4"> <div class="flex flex-col gap-4 px-6 py-4">
<Dynamic {renderCode(svgContent() ?? "", "")}
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"
/>
<Show when={svgPreviewUrl()}> <Show when={svgPreviewUrl()}>
<div class="flex justify-center pb-40"> <div class="flex justify-center pb-40">
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" /> <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
@@ -2135,29 +2368,7 @@ export default function Page() {
</Show> </Show>
</div> </div>
</Match> </Match>
<Match when={state()?.loaded}> <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
<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()?.loading}> <Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div> <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
</Match> </Match>

View File

@@ -75,13 +75,18 @@
overflow: hidden; overflow: hidden;
} }
[data-component="popover-content"] { [data-slot="session-review-comment-popover-content"] {
position: absolute !important; position: absolute;
} top: 0;
right: calc(100% + 12px);
.session-review-comment-popover-content { z-index: 40;
left: auto !important; min-width: 200px;
right: calc(100% + 12px) !important; 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"] { [data-slot="session-review-trigger-content"] {

View File

@@ -1,6 +1,5 @@
import { Accordion } from "./accordion" import { Accordion } from "./accordion"
import { Button } from "./button" import { Button } from "./button"
import { Popover } from "./popover"
import { RadioGroup } from "./radio-group" import { RadioGroup } from "./radio-group"
import { DiffChanges } from "./diff-changes" import { DiffChanges } from "./diff-changes"
import { FileIcon } from "./file-icon" import { FileIcon } from "./file-icon"
@@ -151,6 +150,7 @@ function markerTop(wrapper: HTMLElement, marker: HTMLElement) {
export const SessionReview = (props: SessionReviewProps) => { export const SessionReview = (props: SessionReviewProps) => {
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let focusToken = 0
const i18n = useI18n() const i18n = useI18n()
const diffComponent = useDiffComponent() const diffComponent = useDiffComponent()
const anchors = new Map<string, HTMLElement>() const anchors = new Map<string, HTMLElement>()
@@ -201,6 +201,9 @@ export const SessionReview = (props: SessionReviewProps) => {
const focus = props.focusedComment const focus = props.focusedComment
if (!focus) return if (!focus) return
focusToken++
const token = focusToken
setOpened(focus) setOpened(focus)
const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id) 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]) handleChange([...current, focus.file])
} }
requestAnimationFrame(() => { const scrollTo = (attempt: number) => {
requestAnimationFrame(() => { if (token !== focusToken) return
const root = scroll
if (!root) return
const anchor = root.querySelector(`[data-comment-id="${focus.id}"]`) const root = scroll
if (anchor instanceof HTMLElement) { if (!root) return
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) const anchor = root.querySelector(`[data-comment-id="${focus.id}"]`)
if (!target) return const ready =
anchor instanceof HTMLElement && anchor.style.pointerEvents !== "none" && anchor.style.opacity !== "0"
const rootRect = root.getBoundingClientRect() const target = ready ? anchor : anchors.get(focus.file)
const targetRect = target.getBoundingClientRect() if (!target) {
const offset = targetRect.top - rootRect.top if (attempt >= 24) return
const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2 requestAnimationFrame(() => scrollTo(attempt + 1))
root.scrollTop = Math.max(0, next) 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)) requestAnimationFrame(() => props.onFocusedCommentChange?.(null))
}) })
@@ -519,207 +526,145 @@ export const SessionReview = (props: SessionReviewProps) => {
</Accordion.Trigger> </Accordion.Trigger>
</StickyAccordionHeader> </StickyAccordionHeader>
<Accordion.Content data-slot="session-review-accordion-content"> <Accordion.Content data-slot="session-review-accordion-content">
<Switch> <div
<Match when={isImage()}> data-slot="session-review-diff-wrapper"
<div data-slot="session-review-image-container"> ref={(el) => {
<Show wrapper = el
when={imageSrc()} anchors.set(diff.file, el)
fallback={ scheduleAnchors()
<div data-slot="session-review-image-placeholder"> }}
<Switch> >
<Match when={imageStatus() === "loading"}>Loading image...</Match> <Dynamic
<Match when={true}>Image preview unavailable</Match> component={diffComponent}
</Switch> preloadedDiff={diff.preloaded}
</div> 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",
}}
> >
<img data-slot="session-review-image" src={imageSrc()!} alt={getFilename(diff.file)} /> <button
</Show> type="button"
</div> data-slot="session-review-comment-button"
</Match> onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
<Match when={isAudio()}> onClick={() => {
<div data-slot="session-review-audio-container"> if (isCommentOpen(comment)) {
<Show setOpened(null)
when={audioSrc() && audioStatus() !== "error"} return
fallback={ }
<div data-slot="session-review-audio-placeholder">
<Switch> openComment(comment)
<Match when={audioStatus() === "loading"}>Loading audio...</Match>
<Match when={true}>Audio preview unavailable</Match>
</Switch>
</div>
}
>
<audio
data-slot="session-review-audio"
controls
preload="metadata"
onError={() => {
setAudioStatus("error")
}} }}
> >
<source src={audioSrc()!} type={audioMime()} /> <Icon name="speech-bubble" size="small" />
</audio> </button>
</Show> <Show when={isCommentOpen(comment)}>
</div> <div data-slot="session-review-comment-popover-content">
</Match> <div data-slot="session-review-comment-popover">
<Match when={true}> <div data-slot="session-review-comment-popover-label">
<div {getFilename(comment.file)}:{selectionLabel(comment.selection)}
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>
</div> </div>
</Popover> <div data-slot="session-review-comment-popover-text">{comment.comment}</div>
</div>
</div> </div>
)} </Show>
</For> </div>
)}
</For>
<Show when={draftRange()}> <Show when={draftRange()}>
{(range) => ( {(range) => (
<Show when={draftTop() !== undefined}> <Show when={draftTop() !== undefined}>
<div data-slot="session-review-comment-anchor" style={{ top: `${draftTop() ?? 0}px` }}> <div data-slot="session-review-comment-anchor" style={{ top: `${draftTop() ?? 0}px` }}>
<Popover <button
portal={false} type="button"
open={true} data-slot="session-review-comment-button"
class="session-review-comment-popover-content" onClick={() => textarea?.focus()}
onOpenChange={(open) => { >
if (open) return <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) setCommenting(null)
}} }}
trigger={ />
<button type="button" data-slot="session-review-comment-button"> <div data-slot="session-review-comment-actions">
<Icon name="speech-bubble" size="small" /> <Button size="small" variant="ghost" onClick={() => setCommenting(null)}>
</button> Cancel
} </Button>
> <Button
<div data-slot="session-review-comment-popover"> size="small"
<div data-slot="session-review-comment-popover-label"> variant="secondary"
Commenting on {getFilename(diff.file)}:{selectionLabel(range())} disabled={draft().trim().length === 0}
</div> onClick={() => {
<textarea const value = draft().trim()
ref={textarea} if (!value) return
data-slot="session-review-comment-textarea" props.onLineComment?.({
rows={3} file: diff.file,
placeholder="Add a comment" selection: range(),
value={draft()} comment: value,
onInput={(e) => setDraft(e.currentTarget.value)} preview: selectionPreview(diff, range()),
onKeyDown={(e) => { })
if (e.key !== "Enter") return setCommenting(null)
if (e.shiftKey) return }}
e.preventDefault() >
const value = draft().trim() Comment
if (!value) return </Button>
props.onLineComment?.({ </div>
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> </div>
</Show> </div>
)} </div>
</Show> </Show>
</div> )}
</Match> </Show>
</Switch> </div>
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
) )