wip(app): line selection
This commit is contained in:
@@ -19,6 +19,7 @@ import { SettingsProvider } from "@/context/settings"
|
|||||||
import { TerminalProvider } from "@/context/terminal"
|
import { TerminalProvider } from "@/context/terminal"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { NotificationProvider } from "@/context/notification"
|
import { NotificationProvider } from "@/context/notification"
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { CommandProvider } from "@/context/command"
|
import { CommandProvider } from "@/context/command"
|
||||||
@@ -128,13 +129,15 @@ export function AppInterface(props: { defaultUrl?: string }) {
|
|||||||
component={(p) => (
|
component={(p) => (
|
||||||
<Show when={p.params.id ?? "new"}>
|
<Show when={p.params.id ?? "new"}>
|
||||||
<TerminalProvider>
|
<TerminalProvider>
|
||||||
<FileProvider>
|
<FileProvider>
|
||||||
<PromptProvider>
|
<PromptProvider>
|
||||||
<Suspense fallback={<Loading />}>
|
<CommentsProvider>
|
||||||
<Session />
|
<Suspense fallback={<Loading />}>
|
||||||
</Suspense>
|
<Session />
|
||||||
</PromptProvider>
|
</Suspense>
|
||||||
</FileProvider>
|
</CommentsProvider>
|
||||||
|
</PromptProvider>
|
||||||
|
</FileProvider>
|
||||||
</TerminalProvider>
|
</TerminalProvider>
|
||||||
</Show>
|
</Show>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { useLayout } from "@/context/layout"
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useComments } from "@/context/comments"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
@@ -115,6 +116,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const files = useFile()
|
const files = useFile()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
const comments = useComments()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
@@ -158,6 +160,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
|
const view = createMemo(() => layout.view(sessionKey()))
|
||||||
const activeFile = createMemo(() => {
|
const activeFile = createMemo(() => {
|
||||||
const tab = tabs().active()
|
const tab = tabs().active()
|
||||||
if (!tab) return
|
if (!tab) return
|
||||||
@@ -1555,7 +1558,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
{(item) => {
|
{(item) => {
|
||||||
const preview = createMemo(() => selectionPreview(item.path, item.selection, item.preview))
|
const preview = createMemo(() => selectionPreview(item.path, item.selection, item.preview))
|
||||||
return (
|
return (
|
||||||
<div class="shrink-0 flex flex-col gap-1 rounded-md bg-surface-base border border-border-base px-2 py-1 max-w-[320px]">
|
<div
|
||||||
|
classList={{
|
||||||
|
"shrink-0 flex flex-col gap-1 rounded-md bg-surface-base border border-border-base px-2 py-1 max-w-[320px]": true,
|
||||||
|
"cursor-pointer hover:bg-surface-raised-base-hover": !!item.commentID,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!item.commentID) return
|
||||||
|
comments.setFocus({ file: item.path, id: item.commentID })
|
||||||
|
view().reviewPanel.open()
|
||||||
|
tabs().open("review")
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||||
<div class="flex items-center text-11-regular min-w-0">
|
<div class="flex items-center text-11-regular min-w-0">
|
||||||
@@ -1576,7 +1590,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
icon="close"
|
icon="close"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="h-5 w-5"
|
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")}
|
aria-label={language.t("prompt.context.removeFile")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
140
packages/app/src/context/comments.tsx
Normal file
140
packages/app/src/context/comments.tsx
Normal file
@@ -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<typeof createCommentSession>
|
||||||
|
|
||||||
|
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<string, LineComment[]>
|
||||||
|
}>({
|
||||||
|
comments: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const [focus, setFocus] = createSignal<CommentFocus | null>(null)
|
||||||
|
|
||||||
|
const list = (file: string) => store.comments[file] ?? []
|
||||||
|
|
||||||
|
const add = (input: Omit<LineComment, "id" | "time">) => {
|
||||||
|
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<string, CommentCacheEntry>()
|
||||||
|
|
||||||
|
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<LineComment, "id" | "time">) => 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(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -43,6 +43,7 @@ export type FileContextItem = {
|
|||||||
path: string
|
path: string
|
||||||
selection?: FileSelection
|
selection?: FileSelection
|
||||||
comment?: string
|
comment?: string
|
||||||
|
commentID?: string
|
||||||
preview?: string
|
preview?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +140,11 @@ function createPromptSession(dir: string, id: string | undefined) {
|
|||||||
const start = item.selection?.startLine
|
const start = item.selection?.startLine
|
||||||
const end = item.selection?.endLine
|
const end = item.selection?.endLine
|
||||||
const key = `${item.type}:${item.path}:${start}:${end}`
|
const key = `${item.type}:${item.path}:${start}:${end}`
|
||||||
|
|
||||||
|
if (item.commentID) {
|
||||||
|
return `${key}:c=${item.commentID}`
|
||||||
|
}
|
||||||
|
|
||||||
const comment = item.comment?.trim()
|
const comment = item.comment?.trim()
|
||||||
if (!comment) return key
|
if (!comment) return key
|
||||||
const digest = checksum(comment) ?? comment
|
const digest = checksum(comment) ?? comment
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
|
|||||||
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
|
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
|
import { useComments, type LineComment } from "@/context/comments"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
@@ -82,6 +83,9 @@ interface SessionReviewTabProps {
|
|||||||
onDiffStyleChange?: (style: DiffStyle) => void
|
onDiffStyleChange?: (style: DiffStyle) => void
|
||||||
onViewFile?: (file: string) => void
|
onViewFile?: (file: string) => void
|
||||||
onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: 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?: {
|
classes?: {
|
||||||
root?: string
|
root?: string
|
||||||
header?: string
|
header?: string
|
||||||
@@ -168,6 +172,9 @@ function SessionReviewTab(props: SessionReviewTabProps) {
|
|||||||
onViewFile={props.onViewFile}
|
onViewFile={props.onViewFile}
|
||||||
readFile={readFile}
|
readFile={readFile}
|
||||||
onLineComment={props.onLineComment}
|
onLineComment={props.onLineComment}
|
||||||
|
comments={props.comments}
|
||||||
|
focusedComment={props.focusedComment}
|
||||||
|
onFocusedCommentChange={props.onFocusedCommentChange}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -187,6 +194,7 @@ export default function Page() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
|
const comments = useComments()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
|
const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
@@ -513,11 +521,17 @@ export default function Page() {
|
|||||||
}) => {
|
}) => {
|
||||||
const selection = selectionFromLines(input.selection)
|
const selection = selectionFromLines(input.selection)
|
||||||
const preview = input.preview ?? selectionPreview(input.file, 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({
|
prompt.context.add({
|
||||||
type: "file",
|
type: "file",
|
||||||
path: input.file,
|
path: input.file,
|
||||||
selection,
|
selection,
|
||||||
comment: input.comment,
|
comment: input.comment,
|
||||||
|
commentID: saved.id,
|
||||||
preview,
|
preview,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1433,6 +1447,9 @@ export default function Page() {
|
|||||||
view={view}
|
view={view}
|
||||||
diffStyle="unified"
|
diffStyle="unified"
|
||||||
onLineComment={addCommentToContext}
|
onLineComment={addCommentToContext}
|
||||||
|
comments={comments.all()}
|
||||||
|
focusedComment={comments.focus()}
|
||||||
|
onFocusedCommentChange={comments.setFocus}
|
||||||
onViewFile={(path) => {
|
onViewFile={(path) => {
|
||||||
const value = file.tab(path)
|
const value = file.tab(path)
|
||||||
tabs().open(value)
|
tabs().open(value)
|
||||||
@@ -1749,6 +1766,9 @@ export default function Page() {
|
|||||||
diffStyle={layout.review.diffStyle()}
|
diffStyle={layout.review.diffStyle()}
|
||||||
onDiffStyleChange={layout.review.setDiffStyle}
|
onDiffStyleChange={layout.review.setDiffStyle}
|
||||||
onLineComment={addCommentToContext}
|
onLineComment={addCommentToContext}
|
||||||
|
comments={comments.all()}
|
||||||
|
focusedComment={comments.focus()}
|
||||||
|
onFocusedCommentChange={comments.setFocus}
|
||||||
onViewFile={(path) => {
|
onViewFile={(path) => {
|
||||||
const value = file.tab(path)
|
const value = file.tab(path)
|
||||||
tabs().open(value)
|
tabs().open(value)
|
||||||
|
|||||||
@@ -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 { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||||
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
|
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
|
||||||
import { Dynamic, isServer } from "solid-js/web"
|
import { Dynamic, isServer } from "solid-js/web"
|
||||||
@@ -19,12 +19,50 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
|||||||
"classList",
|
"classList",
|
||||||
"annotations",
|
"annotations",
|
||||||
"selectedLines",
|
"selectedLines",
|
||||||
|
"commentedLines",
|
||||||
])
|
])
|
||||||
const workerPool = useWorkerPool(props.diffStyle)
|
const workerPool = useWorkerPool(props.diffStyle)
|
||||||
|
|
||||||
let fileDiffInstance: FileDiff<T> | undefined
|
let fileDiffInstance: FileDiff<T> | undefined
|
||||||
const cleanupFunctions: Array<() => void> = []
|
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(() => {
|
onMount(() => {
|
||||||
if (isServer || !props.preloadedDiff) return
|
if (isServer || !props.preloadedDiff) return
|
||||||
fileDiffInstance = new FileDiff<T>(
|
fileDiffInstance = new FileDiff<T>(
|
||||||
@@ -55,6 +93,11 @@ export function Diff<T>(props: SSRDiffProps<T>) {
|
|||||||
fileDiffInstance?.setSelectedLines(local.selectedLines ?? null)
|
fileDiffInstance?.setSelectedLines(local.selectedLines ?? null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const ranges = local.commentedLines ?? []
|
||||||
|
requestAnimationFrame(() => applyCommentedLines(ranges))
|
||||||
|
})
|
||||||
|
|
||||||
// Hydrate annotation slots with interactive SolidJS components
|
// Hydrate annotation slots with interactive SolidJS components
|
||||||
// if (props.annotations.length > 0 && props.renderAnnotation != null) {
|
// if (props.annotations.length > 0 && props.renderAnnotation != null) {
|
||||||
// for (const annotation of props.annotations) {
|
// for (const annotation of props.annotations) {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
|||||||
"classList",
|
"classList",
|
||||||
"annotations",
|
"annotations",
|
||||||
"selectedLines",
|
"selectedLines",
|
||||||
|
"commentedLines",
|
||||||
"onRendered",
|
"onRendered",
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ export function Diff<T>(props: DiffProps<T>) {
|
|||||||
|
|
||||||
let instance: FileDiff<T> | undefined
|
let instance: FileDiff<T> | undefined
|
||||||
const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined)
|
const [current, setCurrent] = createSignal<FileDiff<T> | undefined>(undefined)
|
||||||
|
const [rendered, setRendered] = createSignal(0)
|
||||||
|
|
||||||
const getRoot = () => {
|
const getRoot = () => {
|
||||||
const host = container.querySelector("diffs-container")
|
const host = container.querySelector("diffs-container")
|
||||||
@@ -172,6 +174,39 @@ export function Diff<T>(props: DiffProps<T>) {
|
|||||||
observer.observe(container, { childList: true, subtree: true })
|
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 setSelectedLines = (range: SelectedLineRange | null) => {
|
||||||
const active = current()
|
const active = current()
|
||||||
if (!active) return
|
if (!active) return
|
||||||
@@ -379,9 +414,16 @@ export function Diff<T>(props: DiffProps<T>) {
|
|||||||
containerWrapper: container,
|
containerWrapper: container,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setRendered((value) => value + 1)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
rendered()
|
||||||
|
const ranges = local.commentedLines ?? []
|
||||||
|
requestAnimationFrame(() => applyCommentedLines(ranges))
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const selected = local.selectedLines ?? null
|
const selected = local.selectedLines ?? null
|
||||||
setSelectedLines(selected)
|
setSelectedLines(selected)
|
||||||
|
|||||||
@@ -195,4 +195,103 @@
|
|||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
color: var(--text-weak);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,49 @@
|
|||||||
import { Accordion } from "./accordion"
|
import { Accordion } from "./accordion"
|
||||||
import { Button } from "./button"
|
import { Button } from "./button"
|
||||||
|
import { HoverCard } from "./hover-card"
|
||||||
|
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"
|
||||||
import { Icon } from "./icon"
|
import { Icon } from "./icon"
|
||||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||||
import { useCodeComponent } from "../context/code"
|
|
||||||
import { useDiffComponent } from "../context/diff"
|
import { useDiffComponent } from "../context/diff"
|
||||||
import { useI18n } from "../context/i18n"
|
import { useI18n } from "../context/i18n"
|
||||||
import { checksum } from "@opencode-ai/util/encode"
|
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
|
import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
|
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
|
||||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
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"
|
import { Dynamic } from "solid-js/web"
|
||||||
|
|
||||||
export type SessionReviewDiffStyle = "unified" | "split"
|
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 {
|
export interface SessionReviewProps {
|
||||||
split?: boolean
|
split?: boolean
|
||||||
diffStyle?: SessionReviewDiffStyle
|
diffStyle?: SessionReviewDiffStyle
|
||||||
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
|
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
|
||||||
onDiffRendered?: () => void
|
onDiffRendered?: () => void
|
||||||
onLineComment?: (comment: SessionReviewLineComment) => void
|
onLineComment?: (comment: SessionReviewLineComment) => void
|
||||||
|
comments?: SessionReviewComment[]
|
||||||
|
focusedComment?: SessionReviewFocus | null
|
||||||
|
onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void
|
||||||
open?: string[]
|
open?: string[]
|
||||||
onOpenChange?: (open: string[]) => void
|
onOpenChange?: (open: string[]) => void
|
||||||
scrollRef?: (el: HTMLDivElement) => void
|
scrollRef?: (el: HTMLDivElement) => void
|
||||||
@@ -105,29 +124,43 @@ type SessionReviewSelection = {
|
|||||||
range: SelectedLineRange
|
range: SelectedLineRange
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionReviewLineComment = {
|
function findSide(element: HTMLElement): "additions" | "deletions" {
|
||||||
file: string
|
const code = element.closest("[data-code]")
|
||||||
selection: SelectedLineRange
|
if (!(code instanceof HTMLElement)) return "additions"
|
||||||
comment: string
|
if (code.hasAttribute("data-deletions")) return "deletions"
|
||||||
preview?: string
|
return "additions"
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommentAnnotationMeta = {
|
function findMarker(root: ShadowRoot, range: SelectedLineRange) {
|
||||||
file: string
|
const line = Math.max(range.start, range.end)
|
||||||
selection: SelectedLineRange
|
const side = range.endSide ?? range.side
|
||||||
label: string
|
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||||
preview?: string
|
(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) => {
|
export const SessionReview = (props: SessionReviewProps) => {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const diffComponent = useDiffComponent()
|
const diffComponent = useDiffComponent()
|
||||||
const codeComponent = useCodeComponent()
|
const anchors = new Map<string, HTMLElement>()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
|
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
|
||||||
})
|
})
|
||||||
|
|
||||||
const [selection, setSelection] = createSignal<SessionReviewSelection | null>(null)
|
const [selection, setSelection] = createSignal<SessionReviewSelection | null>(null)
|
||||||
const [commenting, setCommenting] = createSignal<SessionReviewSelection | null>(null)
|
const [commenting, setCommenting] = createSignal<SessionReviewSelection | null>(null)
|
||||||
|
const [opened, setOpened] = createSignal<SessionReviewFocus | null>(null)
|
||||||
|
|
||||||
const open = () => props.open ?? store.open
|
const open = () => props.open ?? store.open
|
||||||
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
||||||
@@ -150,9 +183,6 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
return `lines ${start}-${end}`
|
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 selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
|
||||||
|
|
||||||
const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => {
|
const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => {
|
||||||
@@ -167,88 +197,26 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
return lines.slice(0, 2).join("\n")
|
return lines.slice(0, 2).join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderAnnotation = (annotation: DiffLineAnnotation<CommentAnnotationMeta>) => {
|
createEffect(() => {
|
||||||
if (!props.onLineComment) return undefined
|
const focus = props.focusedComment
|
||||||
const meta = annotation.metadata
|
if (!focus) return
|
||||||
if (!meta) return undefined
|
|
||||||
|
|
||||||
const wrapper = document.createElement("div")
|
setOpened(focus)
|
||||||
wrapper.className = "relative"
|
|
||||||
|
|
||||||
const card = document.createElement("div")
|
const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id)
|
||||||
card.className =
|
if (comment) setSelection({ file: comment.file, range: comment.selection })
|
||||||
"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 textarea = document.createElement("textarea")
|
const current = open()
|
||||||
textarea.rows = 3
|
if (!current.includes(focus.file)) {
|
||||||
textarea.placeholder = "Add a comment"
|
handleChange([...current, focus.file])
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateState()
|
requestAnimationFrame(() => {
|
||||||
textarea.addEventListener("input", updateState)
|
anchors.get(focus.file)?.scrollIntoView({ block: "center" })
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
actions.appendChild(cancel)
|
requestAnimationFrame(() => props.onFocusedCommentChange?.(null))
|
||||||
actions.appendChild(submit)
|
})
|
||||||
footer.appendChild(label)
|
|
||||||
footer.appendChild(actions)
|
|
||||||
card.appendChild(textarea)
|
|
||||||
card.appendChild(footer)
|
|
||||||
wrapper.appendChild(card)
|
|
||||||
|
|
||||||
requestAnimationFrame(() => textarea.focus())
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -298,6 +266,12 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
<Accordion multiple value={open()} onChange={handleChange}>
|
<Accordion multiple value={open()} onChange={handleChange}>
|
||||||
<For each={props.diffs}>
|
<For each={props.diffs}>
|
||||||
{(diff) => {
|
{(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 beforeText = () => (typeof diff.before === "string" ? diff.before : "")
|
||||||
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
|
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
|
||||||
|
|
||||||
@@ -321,27 +295,70 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
return current.range
|
return current.range
|
||||||
})
|
})
|
||||||
|
|
||||||
const commentingLines = createMemo(() => {
|
const draftRange = createMemo(() => {
|
||||||
const current = commenting()
|
const current = commenting()
|
||||||
if (!current || current.file !== diff.file) return null
|
if (!current || current.file !== diff.file) return null
|
||||||
return current.range
|
return current.range
|
||||||
})
|
})
|
||||||
|
|
||||||
const annotations = createMemo<DiffLineAnnotation<CommentAnnotationMeta>[]>(() => {
|
const [draft, setDraft] = createSignal("")
|
||||||
const range = commentingLines()
|
const [positions, setPositions] = createSignal<Record<string, number>>({})
|
||||||
if (!range) return []
|
const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
||||||
return [
|
|
||||||
{
|
const getRoot = () => {
|
||||||
lineNumber: Math.max(range.start, range.end),
|
const el = wrapper
|
||||||
side: selectionSide(range),
|
if (!el) return
|
||||||
metadata: {
|
|
||||||
file: diff.file,
|
const host = el.querySelector("diffs-container")
|
||||||
selection: range,
|
if (!(host instanceof HTMLElement)) return
|
||||||
label: selectionLabel(range),
|
return host.shadowRoot ?? undefined
|
||||||
preview: selectionPreview(diff, range),
|
}
|
||||||
},
|
|
||||||
},
|
const updateAnchors = () => {
|
||||||
]
|
const el = wrapper
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const root = getRoot()
|
||||||
|
if (!root) return
|
||||||
|
|
||||||
|
const next: Record<string, number> = {}
|
||||||
|
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(() => {
|
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) => {
|
const handleLineSelected = (range: SelectedLineRange | null) => {
|
||||||
if (!props.onLineComment) return
|
if (!props.onLineComment) return
|
||||||
|
|
||||||
if (!range) {
|
if (!range) {
|
||||||
setSelection(null)
|
setSelection(null)
|
||||||
setCommenting(null)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelection({ file: diff.file, range })
|
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) => {
|
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
|
||||||
@@ -434,6 +435,17 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
setCommenting({ file: diff.file, range })
|
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 (
|
return (
|
||||||
<Accordion.Item value={diff.file} data-slot="session-review-accordion-item">
|
<Accordion.Item value={diff.file} data-slot="session-review-accordion-item">
|
||||||
<StickyAccordionHeader>
|
<StickyAccordionHeader>
|
||||||
@@ -526,32 +538,167 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={isAdded() || isDeleted()}>
|
|
||||||
<div data-slot="session-review-file-container">
|
|
||||||
<Dynamic component={codeComponent} file={fileForCode()} overflow="scroll" />
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<Dynamic
|
<div
|
||||||
component={diffComponent}
|
data-slot="session-review-diff-wrapper"
|
||||||
preloadedDiff={diff.preloaded}
|
ref={(el) => {
|
||||||
diffStyle={diffStyle()}
|
wrapper = el
|
||||||
onRendered={props.onDiffRendered}
|
anchors.set(diff.file, el)
|
||||||
enableLineSelection={props.onLineComment != null}
|
scheduleAnchors()
|
||||||
onLineSelected={handleLineSelected}
|
|
||||||
onLineSelectionEnd={handleLineSelectionEnd}
|
|
||||||
selectedLines={selectedLines()}
|
|
||||||
annotations={annotations()}
|
|
||||||
renderAnnotation={renderAnnotation}
|
|
||||||
before={{
|
|
||||||
name: diff.file!,
|
|
||||||
contents: beforeText(),
|
|
||||||
}}
|
}}
|
||||||
after={{
|
>
|
||||||
name: diff.file!,
|
<Dynamic
|
||||||
contents: afterText(),
|
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"
|
||||||
|
style={{
|
||||||
|
top: `${positions()[comment.id] ?? 0}px`,
|
||||||
|
opacity: positions()[comment.id] === undefined ? 0 : 1,
|
||||||
|
"pointer-events": positions()[comment.id] === undefined ? "none" : "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popover
|
||||||
|
open={isCommentOpen(comment)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
openComment(comment)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isCommentOpen(comment)) return
|
||||||
|
setOpened(null)
|
||||||
|
}}
|
||||||
|
trigger={
|
||||||
|
<HoverCard
|
||||||
|
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-hover">
|
||||||
|
<div data-slot="session-review-comment-hover-label">
|
||||||
|
{getFilename(comment.file)}:{selectionLabel(comment.selection)}
|
||||||
|
</div>
|
||||||
|
<div data-slot="session-review-comment-hover-text">{comment.comment}</div>
|
||||||
|
</div>
|
||||||
|
</HoverCard>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
|
||||||
|
<Show when={draftRange()}>
|
||||||
|
{(range) => (
|
||||||
|
<Show when={draftTop() !== undefined}>
|
||||||
|
<div data-slot="session-review-comment-anchor" style={{ top: `${draftTop() ?? 0}px` }}>
|
||||||
|
<Popover
|
||||||
|
open={true}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) return
|
||||||
|
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>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
|||||||
after: FileContents
|
after: FileContents
|
||||||
annotations?: DiffLineAnnotation<T>[]
|
annotations?: DiffLineAnnotation<T>[]
|
||||||
selectedLines?: SelectedLineRange | null
|
selectedLines?: SelectedLineRange | null
|
||||||
|
commentedLines?: SelectedLineRange[]
|
||||||
onRendered?: () => void
|
onRendered?: () => void
|
||||||
class?: string
|
class?: string
|
||||||
classList?: ComponentProps<"div">["classList"]
|
classList?: ComponentProps<"div">["classList"]
|
||||||
@@ -42,6 +43,15 @@ const unsafeCSS = `
|
|||||||
background-color: var(--diffs-bg-selection-text);
|
background-color: var(--diffs-bg-selection-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-diffs] [data-comment-selected] {
|
||||||
|
background-color: var(--diffs-bg-selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-diffs] [data-comment-selected] [data-column-number] {
|
||||||
|
background-color: var(--diffs-bg-selection-number);
|
||||||
|
color: var(--diffs-selection-number-fg);
|
||||||
|
}
|
||||||
|
|
||||||
[data-diffs-header],
|
[data-diffs-header],
|
||||||
[data-diffs] {
|
[data-diffs] {
|
||||||
[data-separator-wrapper] {
|
[data-separator-wrapper] {
|
||||||
|
|||||||
Reference in New Issue
Block a user