| 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 |
| } |
|
|
| |
| |
| |
| 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>, |
| }, |
| ] |
| }, |
| [], |
| |
| { 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) |
| }, |
| }) |
| } |
|
|