wip(app): line selection
This commit is contained in:
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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 scrollTo = (attempt: number) => {
|
||||
if (token !== focusToken) 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 root = scroll
|
||||
if (!root) return
|
||||
|
||||
const target = anchors.get(focus.file)
|
||||
if (!target) return
|
||||
const anchor = root.querySelector(`[data-comment-id="${focus.id}"]`)
|
||||
const ready =
|
||||
anchor instanceof HTMLElement && anchor.style.pointerEvents !== "none" && anchor.style.opacity !== "0"
|
||||
|
||||
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 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>
|
||||
}
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</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 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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user