/
Alex_Ural
/
opencode
Обзор
Документация
Войти
/
Alex_Ural
/
opencode
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
dev
packages/session-ui/src/components/line-comment-annotations.tsx
637 строк
19 KB
Aarav Sareen
feat(app): v2 review panel overhaul (#31882)
02 июл 2026, 10:41
Не верифицирован
02 июл 2026, 10:41
7d26186
Код
Авторство
О чём код?
import { type DiffLineAnnotation, type SelectedLineRange } from "@pierre/diffs" import { createEffect, createMemo, createSignal, onCleanup, Show, type Accessor, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { render as renderSolid } from "solid-js/web" import { useI18n } from "@opencode-ai/ui/context/i18n" import { createHoverCommentUtility } from "../pierre/comment-hover" import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge" import { LineComment, LineCommentEditor, type LineCommentEditorProps } from "./line-comment" export type LineCommentAnnotationMeta<T> = | { kind: "comment"; key: string; comment: T } | { kind: "draft"; key: string; range: SelectedLineRange } export type LineCommentAnnotation<T> = { lineNumber: number side?: "additions" | "deletions" metadata: LineCommentAnnotationMeta<T> } type LineCommentAnnotationsProps<T> = { comments: Accessor<T[]> getCommentId: (comment: T) => string getCommentSelection: (comment: T) => SelectedLineRange draftRange: Accessor<SelectedLineRange | null> draftKey: Accessor<string> } type LineCommentAnnotationsWithSideProps<T> = LineCommentAnnotationsProps<T> & { getSide: (range: SelectedLineRange) => "additions" | "deletions" } type HoverCommentLine = { lineNumber: number side?: "additions" | "deletions" } export type LineCommentStateProps<T> = { opened: Accessor<T | null> setOpened: (id: T | null) => void selected: Accessor<SelectedLineRange | null> setSelected: (range: SelectedLineRange | null) => void commenting: Accessor<SelectedLineRange | null> setCommenting: (range: SelectedLineRange | null) => void syncSelected?: (range: SelectedLineRange | null) => void hoverSelected?: (range: SelectedLineRange) => void } export type LineCommentShape = { id: string selection: SelectedLineRange comment: string } type LineCommentControllerProps<T extends LineCommentShape> = { comments: Accessor<T[]> draftKey: Accessor<string> label: string mention?: LineCommentEditorProps["mention"] state: LineCommentStateProps<string> onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void onDelete?: (comment: T) => void renderCommentActions?: (comment: T, controls: { edit: VoidFunction; remove: VoidFunction }) => JSX.Element editSubmitLabel?: string onDraftPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent> getHoverSelectedRange?: Accessor<SelectedLineRange | null> cancelDraftOnCommentToggle?: boolean clearSelectionOnSelectionEndNull?: boolean } type LineCommentControllerWithSideProps<T extends LineCommentShape> = LineCommentControllerProps<T> & { getSide: (range: SelectedLineRange) => "additions" | "deletions" } type CommentProps = { id?: string open: boolean comment: JSX.Element selection: JSX.Element actions?: JSX.Element editor?: DraftProps onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> onMouseEnter?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> } type DraftProps = { value: string selection: JSX.Element mention?: LineCommentEditorProps["mention"] onInput: (value: string) => void onCancel: VoidFunction onSubmit: (value: string) => void onPopoverFocusOut?: JSX.EventHandlerUnion<HTMLDivElement, FocusEvent> cancelLabel?: string submitLabel?: string } // Generic host machinery shared by the v1 and v2 annotation renderers: each // annotation key gets a detached DOM host with its own Solid root, updated in // place through a signal so Pierre can reparent the host without re-rendering. export function createLineCommentAnnotationRenderer<T, C, D>(props: { renderComment: (comment: T) => C renderDraft: (range: SelectedLineRange) => D commentElement: (view: Accessor<C>) => JSX.Element draftElement: (view: Accessor<D>) => JSX.Element }) { const nodes = new Map< string, { host: HTMLDivElement dispose: VoidFunction setMeta: (meta: LineCommentAnnotationMeta<T>) => void } >() const mount = (meta: LineCommentAnnotationMeta<T>) => { if (typeof document === "undefined") return const host = document.createElement("div") host.setAttribute("data-prevent-autofocus", "") const [current, setCurrent] = createSignal(meta) const dispose = renderSolid(() => { const active = current() if (active.kind === "comment") { const view = createMemo(() => { const next = current() if (next.kind !== "comment") return props.renderComment(active.comment) return props.renderComment(next.comment) }) return props.commentElement(view) } const view = createMemo(() => { const next = current() if (next.kind !== "draft") return props.renderDraft(active.range) return props.renderDraft(next.range) }) return props.draftElement(view) }, host) const node = { host, dispose, setMeta: setCurrent } nodes.set(meta.key, node) return node } const render = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotation: A) => { const meta = annotation.metadata const node = nodes.get(meta.key) ?? mount(meta) if (!node) return node.setMeta(meta) return node.host } const reconcile = <A extends { metadata: LineCommentAnnotationMeta<T> }>(annotations: A[]) => { const next = new Set(annotations.map((annotation) => annotation.metadata.key)) for (const [key, node] of nodes) { if (next.has(key)) continue node.dispose() nodes.delete(key) } } const cleanup = () => { for (const [, node] of nodes) node.dispose() nodes.clear() } return { render, reconcile, cleanup } } function lineCommentElement(view: Accessor<CommentProps>) { return ( <Show when={view().editor} fallback={ <LineComment inline id={view().id} open={view().open} comment={view().comment} selection={view().selection} actions={view().actions} onClick={view().onClick} onMouseEnter={view().onMouseEnter} /> } > <LineCommentEditor inline id={view().id} value={view().editor!.value} selection={view().editor!.selection} onInput={view().editor!.onInput} onCancel={view().editor!.onCancel} onSubmit={view().editor!.onSubmit} onPopoverFocusOut={view().editor!.onPopoverFocusOut} cancelLabel={view().editor!.cancelLabel} submitLabel={view().editor!.submitLabel} mention={view().editor!.mention} /> </Show> ) } function lineCommentDraftElement(view: Accessor<DraftProps>) { return ( <LineCommentEditor inline value={view().value} selection={view().selection} onInput={view().onInput} onCancel={view().onCancel} onSubmit={view().onSubmit} onPopoverFocusOut={view().onPopoverFocusOut} mention={view().mention} /> ) } export function createLineCommentState<T>(props: LineCommentStateProps<T>) { const [state, setState] = createStore({ draft: "", editing: null as T | null, }) const draft = () => state.draft const setDraft = (value: string) => setState("draft", value) const editing = () => state.editing const setEditing = (value: T | null) => setState("editing", typeof value === "function" ? () => value : value) const toRange = (range: SelectedLineRange | null) => (range ? cloneSelectedLineRange(range) : null) const setSelected = (range: SelectedLineRange | null) => { const next = toRange(range) props.setSelected(next) props.syncSelected?.(toRange(next)) return next } const setCommenting = (range: SelectedLineRange | null) => { const next = toRange(range) props.setCommenting(next) return next } const closeComment = () => { props.setOpened(null) } const cancelDraft = () => { setDraft("") setEditing(null) setCommenting(null) } const reset = () => { setDraft("") setEditing(null) props.setOpened(null) props.setSelected(null) props.setCommenting(null) } const openComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => { if (options?.cancelDraft) cancelDraft() props.setOpened(id) setSelected(range) } const toggleComment = (id: T, range: SelectedLineRange, options?: { cancelDraft?: boolean }) => { if (options?.cancelDraft) cancelDraft() const next = props.opened() === id ? null : id props.setOpened(next) setSelected(range) } const openDraft = (range: SelectedLineRange) => { const next = toRange(range) setDraft("") setEditing(null) closeComment() setSelected(next) setCommenting(next) } const openEditor = (id: T, range: SelectedLineRange, value: string) => { closeComment() setSelected(range) props.setCommenting(null) setEditing(id) setDraft(value) } const hoverComment = (range: SelectedLineRange) => { const next = toRange(range) if (!next) return if (props.hoverSelected) { props.hoverSelected(next) return } setSelected(next) } return { draft, setDraft, editing, opened: props.opened, selected: props.selected, commenting: props.commenting, isOpen: (id: T) => props.opened() === id, isEditing: (id: T) => editing() === id, closeComment, openComment, toggleComment, openDraft, openEditor, hoverComment, cancelDraft, select: setSelected, reset, } } export function createLineCommentController<T extends LineCommentShape>( props: LineCommentControllerWithSideProps<T>, ): { note: ReturnType<typeof createLineCommentState<string>> annotations: Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]> renderAnnotation: ReturnType< typeof createManagedLineCommentAnnotationRenderer<T, CommentProps, DraftProps> >["renderAnnotation"] renderGutterUtility: ReturnType<typeof createLineCommentGutterRenderer> onLineSelected: (range: SelectedLineRange | null) => void onLineSelectionEnd: (range: SelectedLineRange | null) => void } export function createLineCommentController<T extends LineCommentShape>( props: LineCommentControllerProps<T>, ): { note: ReturnType<typeof createLineCommentState<string>> annotations: Accessor<LineCommentAnnotation<T>[]> renderAnnotation: ReturnType< typeof createManagedLineCommentAnnotationRenderer<T, CommentProps, DraftProps> >["renderAnnotation"] renderGutterUtility: ReturnType<typeof createLineCommentGutterRenderer> onLineSelected: (range: SelectedLineRange | null) => void onLineSelectionEnd: (range: SelectedLineRange | null) => void } export function createLineCommentController<T extends LineCommentShape>( props: LineCommentControllerProps<T> | LineCommentControllerWithSideProps<T>, ) { const i18n = useI18n() const note = createLineCommentState<string>(props.state) const annotations = "getSide" in props ? createLineCommentAnnotations({ comments: props.comments, getCommentId: (comment) => comment.id, getCommentSelection: (comment) => comment.selection, draftRange: note.commenting, draftKey: props.draftKey, getSide: props.getSide, }) : createLineCommentAnnotations({ comments: props.comments, getCommentId: (comment) => comment.id, getCommentSelection: (comment) => comment.selection, draftRange: note.commenting, draftKey: props.draftKey, }) const { renderAnnotation } = createManagedLineCommentAnnotationRenderer<T, CommentProps, DraftProps>({ annotations, commentElement: lineCommentElement, draftElement: lineCommentDraftElement, renderComment: (comment) => { const edit = () => note.openEditor(comment.id, comment.selection, comment.comment) const remove = () => { note.reset() props.onDelete?.(comment) } return { id: comment.id, get open() { return note.isOpen(comment.id) || note.isEditing(comment.id) }, comment: comment.comment, selection: formatSelectedLineLabel(comment.selection, i18n.t), get actions() { return props.renderCommentActions?.(comment, { edit, remove }) }, get editor() { return note.isEditing(comment.id) ? { get value() { return note.draft() }, selection: formatSelectedLineLabel(comment.selection, i18n.t), mention: props.mention, onInput: note.setDraft, onCancel: note.cancelDraft, onSubmit: (value: string) => { props.onUpdate?.({ id: comment.id, comment: value, selection: cloneSelectedLineRange(comment.selection), }) note.cancelDraft() }, submitLabel: props.editSubmitLabel, } : undefined }, onMouseEnter: () => note.hoverComment(comment.selection), onClick: () => { if (note.isEditing(comment.id)) return note.toggleComment(comment.id, comment.selection, { cancelDraft: props.cancelDraftOnCommentToggle }) }, } }, renderDraft: (range) => ({ get value() { return note.draft() }, selection: formatSelectedLineLabel(range, i18n.t), mention: props.mention, onInput: note.setDraft, onCancel: () => { note.cancelDraft() note.select(null) }, onSubmit: (comment) => { props.onSubmit({ comment, selection: cloneSelectedLineRange(range) }) note.cancelDraft() }, onPopoverFocusOut: props.onDraftPopoverFocusOut, }), }) const renderGutterUtility = createLineCommentGutterRenderer({ label: props.label, getSelectedRange: () => { if (note.opened()) return null return props.getHoverSelectedRange?.() ?? note.selected() }, onOpenDraft: note.openDraft, }) const onLineSelected = (range: SelectedLineRange | null) => { if (!range) { note.select(null) note.cancelDraft() return } note.select(range) } const onLineSelectionEnd = (range: SelectedLineRange | null) => { if (!range) { if (props.clearSelectionOnSelectionEndNull) note.select(null) note.cancelDraft() return } note.openDraft(range) } return { note, annotations, renderAnnotation, renderGutterUtility, onLineSelected, onLineSelectionEnd, } } export function createLineCommentAnnotations<T>( props: LineCommentAnnotationsWithSideProps<T>, ): Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]> export function createLineCommentAnnotations<T>( props: LineCommentAnnotationsProps<T>, ): Accessor<LineCommentAnnotation<T>[]> export function createLineCommentAnnotations<T>( props: LineCommentAnnotationsProps<T> | LineCommentAnnotationsWithSideProps<T>, ) { const line = (range: SelectedLineRange) => Math.max(range.start, range.end) if ("getSide" in props) { return createMemo<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>( () => { const list = props.comments().map((comment) => { const range = props.getCommentSelection(comment) return { side: props.getSide(range), lineNumber: line(range), metadata: { kind: "comment", key: `comment:${props.getCommentId(comment)}`, comment, } satisfies LineCommentAnnotationMeta<T>, } }) const range = props.draftRange() if (!range) return list return [ ...list, { side: props.getSide(range), lineNumber: line(range), metadata: { kind: "draft", key: `draft:${props.draftKey()}`, range, } satisfies LineCommentAnnotationMeta<T>, }, ] }, [], // Stable identity for unchanged annotations avoids no-op diff rerenders downstream. { equals: sameAnnotationLists }, ) } return createMemo<LineCommentAnnotation<T>[]>( () => { const list = props.comments().map((comment) => { const range = props.getCommentSelection(comment) const entry: LineCommentAnnotation<T> = { lineNumber: line(range), metadata: { kind: "comment", key: `comment:${props.getCommentId(comment)}`, comment, }, } return entry }) const range = props.draftRange() if (!range) return list const draft: LineCommentAnnotation<T> = { lineNumber: line(range), metadata: { kind: "draft", key: `draft:${props.draftKey()}`, range, }, } return [...list, draft] }, [], { equals: sameAnnotationLists }, ) } type AnnotationListItem = { lineNumber: number side?: unknown metadata: { kind: string; key: string; comment?: unknown; range?: unknown } } function sameAnnotationLists(previous: AnnotationListItem[], next: AnnotationListItem[]) { if (previous.length !== next.length) return false return previous.every((item, index) => { const other = next[index]! return ( item.lineNumber === other.lineNumber && item.side === other.side && item.metadata.kind === other.metadata.kind && item.metadata.key === other.metadata.key && item.metadata.comment === other.metadata.comment && item.metadata.range === other.metadata.range ) }) } export function createManagedLineCommentAnnotationRenderer<T, C, D>(props: { annotations: Accessor<LineCommentAnnotation<T>[]> renderComment: (comment: T) => C renderDraft: (range: SelectedLineRange) => D commentElement: (view: Accessor<C>) => JSX.Element draftElement: (view: Accessor<D>) => JSX.Element }) { const renderer = createLineCommentAnnotationRenderer<T, C, D>({ renderComment: props.renderComment, renderDraft: props.renderDraft, commentElement: props.commentElement, draftElement: props.draftElement, }) createEffect(() => { renderer.reconcile(props.annotations()) }) onCleanup(() => { renderer.cleanup() }) return { renderAnnotation: renderer.render, } } export function createLineCommentGutterRenderer(props: { label: string getSelectedRange: Accessor<SelectedLineRange | null> onOpenDraft: (range: SelectedLineRange) => void }) { return (getHoveredLine: () => HoverCommentLine | undefined) => createHoverCommentUtility({ label: props.label, getHoveredLine, onSelect: (hovered) => { const current = props.getSelectedRange() if (current && lineInSelectedRange(current, hovered.lineNumber, hovered.side)) { props.onOpenDraft(cloneSelectedLineRange(current)) return } const range: SelectedLineRange = { start: hovered.lineNumber, end: hovered.lineNumber, } if (hovered.side) range.side = hovered.side props.onOpenDraft(range) }, }) }