mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 20:52:01 +03:00
fix(session-ui): keep line comment drafts and edits in one state machine (#61816)
This commit is contained in:
committed by
GitHub
Unverified
parent
24cddd1659
commit
d22f9b8eb3
@@ -1,10 +1,9 @@
|
||||
import { createMemo, createSignal, Match, on, onCleanup, onMount, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { FileSearchHandle } from "@opencode/session-ui/file"
|
||||
import { useFileComponent } from "@opencode/ui/context/file"
|
||||
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
|
||||
import { previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
|
||||
import { createLineCommentControllerV2 } from "@opencode/session-ui/v2/line-comment-annotations-v2"
|
||||
import { sampledChecksum } from "@opencode/util/encode"
|
||||
import { LineCommentOverflowIcon } from "@opencode/ui/line-comment"
|
||||
@@ -59,8 +58,6 @@ type ScrollPos = { x: number; y: number }
|
||||
|
||||
type ScrollSyncState = { scroll?: HTMLDivElement; scrollFrame?: number; restoreFrame?: number; pending?: ScrollPos }
|
||||
|
||||
type NoteState = { openedComment: string | null; commenting: LineRange | null; selected: LineRange | null }
|
||||
|
||||
type FindState = { find: FileSearchHandle | null }
|
||||
|
||||
function createScrollSync(input: { get: () => ScrollPos | undefined; set: (pos: ScrollPos) => void }) {
|
||||
@@ -308,17 +305,6 @@ export function SessionFileView(props: { session: MountedSession; screen: Sessio
|
||||
|
||||
const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
|
||||
|
||||
const [note, setNote] = createStore<NoteState>({ openedComment: null, commenting: null, selected: null })
|
||||
|
||||
const syncSelected = (range: LineRange | null) => {
|
||||
const p = path()
|
||||
|
||||
if (!p) return
|
||||
file.selection.set(p, range ? cloneSelectedLineRange(range) : null)
|
||||
}
|
||||
|
||||
const activeSelection = () => note.selected ?? selectedLines()
|
||||
|
||||
const commentsUi = createLineCommentControllerV2({
|
||||
comments: fileComments,
|
||||
label: ctx.t("ui.lineComment.submit"),
|
||||
@@ -327,15 +313,11 @@ export function SessionFileView(props: { session: MountedSession; screen: Sessio
|
||||
items: (query) => file.search(query, { kind: "any" }),
|
||||
},
|
||||
getSide: selectionSide,
|
||||
state: {
|
||||
opened: () => note.openedComment,
|
||||
setOpened: (id) => setNote("openedComment", id),
|
||||
selected: () => note.selected,
|
||||
setSelected: (range) => setNote("selected", range),
|
||||
commenting: () => note.commenting,
|
||||
setCommenting: (range) => setNote("commenting", range),
|
||||
syncSelected,
|
||||
hoverSelected: syncSelected,
|
||||
onSyncSelected: (range) => {
|
||||
const p = path()
|
||||
|
||||
if (!p) return
|
||||
file.selection.set(p, range)
|
||||
},
|
||||
onSubmit: ({ comment, selection }) => {
|
||||
const p = path()
|
||||
@@ -388,7 +370,7 @@ export function SessionFileView(props: { session: MountedSession; screen: Sessio
|
||||
// A new path, e.g. after the workspace directory changes, drops the comment draft and selection of the old one.
|
||||
const moved = createMemo(on(path, () => ({}), { defer: true }))
|
||||
|
||||
createKeyed(moved, () => commentsUi.note.reset())
|
||||
createKeyed(moved, () => commentsUi.reset())
|
||||
|
||||
// A comment focused elsewhere, e.g. from its composer chip, opens here once this file shows.
|
||||
createKeyed(
|
||||
@@ -403,7 +385,7 @@ export function SessionFileView(props: { session: MountedSession; screen: Sessio
|
||||
return target && { target }
|
||||
},
|
||||
(focus) => {
|
||||
commentsUi.note.openComment(focus.target.id, focus.target.selection, { cancelDraft: true })
|
||||
commentsUi.focusComment(focus.target.id, focus.target.selection)
|
||||
requestAnimationFrame(() => comment.focus.set(null))
|
||||
},
|
||||
)
|
||||
@@ -419,7 +401,7 @@ export function SessionFileView(props: { session: MountedSession; screen: Sessio
|
||||
(target) =>
|
||||
onCleanup(
|
||||
shared.reveal.register(target.session, target.path, () => {
|
||||
setNote("selected", null)
|
||||
commentsUi.clearPassive()
|
||||
scrollSync.queueRestore()
|
||||
}),
|
||||
),
|
||||
@@ -460,7 +442,7 @@ export function SessionFileView(props: { session: MountedSession; screen: Sessio
|
||||
}}
|
||||
enableLineSelection
|
||||
enableGutterUtility
|
||||
selectedLines={activeSelection()}
|
||||
selectedLines={commentsUi.selected() ?? selectedLines()}
|
||||
commentedLines={commentedLines()}
|
||||
onRendered={() => {
|
||||
scrollSync.queueRestore()
|
||||
@@ -472,18 +454,8 @@ export function SessionFileView(props: { session: MountedSession; screen: Sessio
|
||||
commentsUi.onLineSelected(range)
|
||||
}}
|
||||
onLineSelectionEnd={(range: LineRange | null) => {
|
||||
if (!range) {
|
||||
commentsUi.note.select(null)
|
||||
commentsUi.note.cancelDraft()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
commentsUi.onLineSelectionEnd(range)
|
||||
}}
|
||||
onLineNumberSelectionEnd={(range: LineRange | null) => {
|
||||
commentsUi.onLineNumberSelectionEnd(range)
|
||||
}}
|
||||
search={search}
|
||||
class="select-text"
|
||||
// Media and previews have their own viewers below; the code view only ever shows text.
|
||||
|
||||
@@ -48,3 +48,40 @@ story("shows a comment button when a diff line is hovered", async ({ mount }) =>
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||
})
|
||||
|
||||
story("keeps typed comment text while other lines and comments are used", async ({ mount, page }) => {
|
||||
const root = await mount("components-session-review--interactive-comments")
|
||||
const review = root.locator('[data-component="session-review"]')
|
||||
const textbox = review.getByRole("textbox")
|
||||
const firstLine = review.getByText("export const first = 1", { exact: true })
|
||||
|
||||
await review.locator('[data-line-type="change-addition"] [data-diff-span]').click()
|
||||
await textbox.fill("first comment")
|
||||
await textbox.press("Enter")
|
||||
await expect(review.getByText("first comment", { exact: true })).toBeVisible()
|
||||
|
||||
// The gutter button on another line used to replace the open edit with an empty draft.
|
||||
await review.getByRole("button", { name: "More options" }).click()
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click()
|
||||
await expect(textbox).toHaveValue("first comment")
|
||||
await textbox.fill("edited comment")
|
||||
// A click on another line keeps the edit, and the highlight returns to the edited line.
|
||||
await firstLine.click()
|
||||
await expect(textbox).toHaveValue("edited comment")
|
||||
await expect(review.locator('[data-column-number="1"]')).not.toHaveAttribute("data-selected-line")
|
||||
await firstLine.hover()
|
||||
await review.getByRole("button", { name: "Comment", exact: true, includeHidden: true }).dispatchEvent("click")
|
||||
await expect(textbox).toHaveCount(1)
|
||||
await expect(textbox).toHaveValue("edited comment")
|
||||
await textbox.press("Enter")
|
||||
await expect(review.getByText("edited comment", { exact: true })).toBeVisible()
|
||||
|
||||
// Deleting a comment used to discard the draft on another line.
|
||||
await firstLine.click()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||
await textbox.fill("draft text")
|
||||
await review.getByRole("button", { name: "More options" }).click()
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click()
|
||||
await expect(review.getByText("edited comment", { exact: true })).toHaveCount(0)
|
||||
await expect(textbox).toHaveValue("draft text")
|
||||
})
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
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 { createEffect, createMemo, createSignal, onCleanup, Show, untrack, type Accessor, type JSX } from "solid-js"
|
||||
import { render as renderSolid } from "solid-js/web"
|
||||
import { useI18n } from "@opencode/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"
|
||||
import {
|
||||
IDLE_LINE_COMMENT_MODE,
|
||||
transitionLineComment,
|
||||
viewCommentItem,
|
||||
viewLineComment,
|
||||
type LineCommentEffect,
|
||||
type LineCommentEvent,
|
||||
type LineCommentMode,
|
||||
} from "./line-comment-machine"
|
||||
|
||||
export type LineCommentAnnotationMeta<T> =
|
||||
| { kind: "comment"; key: string; comment: T }
|
||||
@@ -34,15 +42,10 @@ type HoverCommentLine = {
|
||||
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
|
||||
/** A line-comment mode owned by the caller, such as one file's entry in a multi-file review. Omit it to keep the mode inside the controller. */
|
||||
export type LineCommentMachine = {
|
||||
mode: Accessor<LineCommentMode>
|
||||
dispatch: (event: LineCommentEvent) => readonly LineCommentEffect[]
|
||||
}
|
||||
|
||||
export type SavedLineComment = {
|
||||
@@ -51,21 +54,22 @@ export type SavedLineComment = {
|
||||
comment: string
|
||||
}
|
||||
|
||||
type LineCommentControllerProps<T extends SavedLineComment> = {
|
||||
type LineCommentDriverProps<T extends SavedLineComment> = {
|
||||
machine?: LineCommentMachine
|
||||
onSyncSelected?: (range: SelectedLineRange | null) => void
|
||||
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
|
||||
onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
|
||||
onDelete?: (comment: T) => void
|
||||
}
|
||||
|
||||
type LineCommentControllerProps<T extends SavedLineComment> = LineCommentDriverProps<T> & {
|
||||
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?: (event: FocusEvent) => void
|
||||
getHoverSelectedRange?: Accessor<SelectedLineRange | null>
|
||||
cancelDraftOnCommentToggle?: boolean
|
||||
clearSelectionOnSelectionEndNull?: boolean
|
||||
}
|
||||
|
||||
type LineCommentControllerWithSideProps<T extends SavedLineComment> = LineCommentControllerProps<T> & {
|
||||
@@ -197,19 +201,21 @@ function lineCommentElement(view: Accessor<CommentProps>) {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
{(editor) => (
|
||||
<LineCommentEditor
|
||||
inline
|
||||
id={view().id}
|
||||
value={editor().value}
|
||||
selection={editor().selection}
|
||||
onInput={editor().onInput}
|
||||
onCancel={editor().onCancel}
|
||||
onSubmit={editor().onSubmit}
|
||||
onPopoverFocusOut={editor().onPopoverFocusOut}
|
||||
cancelLabel={editor().cancelLabel}
|
||||
submitLabel={editor().submitLabel}
|
||||
mention={editor().mention}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -229,147 +235,75 @@ function lineCommentDraftElement(view: Accessor<DraftProps>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function createLineCommentState<T>(props: LineCommentStateProps<T>) {
|
||||
const [state, setState] = createStore<{ draft: string; editing: T | null }>({
|
||||
draft: "",
|
||||
editing: null,
|
||||
})
|
||||
// Runs a line-comment machine and turns its effects into the controller's callbacks.
|
||||
export function createLineCommentDriver<T extends SavedLineComment>(props: LineCommentDriverProps<T>) {
|
||||
const machine = props.machine ?? createLocalLineCommentMachine()
|
||||
|
||||
const draft = () => state.draft
|
||||
const setDraft = (value: string) => setState("draft", value)
|
||||
const editing = () => state.editing
|
||||
// The updater form stores `value` as is, even when it is itself a function.
|
||||
const setEditing = (value: T | null) => setState("editing", () => value)
|
||||
const send = (event: LineCommentEvent, comment?: T) => {
|
||||
for (const effect of machine.dispatch(event)) {
|
||||
if (effect.type === "sync-selected")
|
||||
props.onSyncSelected?.(effect.range ? cloneSelectedLineRange(effect.range) : null)
|
||||
|
||||
const toRange = (range: SelectedLineRange | null) => (range ? cloneSelectedLineRange(range) : null)
|
||||
if (effect.type === "submit-draft") props.onSubmit({ comment: effect.comment, selection: effect.selection })
|
||||
|
||||
const setSelected = (range: SelectedLineRange | null) => {
|
||||
const next = toRange(range)
|
||||
props.setSelected(next)
|
||||
props.syncSelected?.(toRange(next))
|
||||
if (effect.type === "submit-edit")
|
||||
props.onUpdate?.({ id: effect.id, comment: effect.comment, selection: effect.selection })
|
||||
|
||||
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
|
||||
if (effect.type === "delete-comment" && comment) props.onDelete?.(comment)
|
||||
}
|
||||
|
||||
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,
|
||||
mode: machine.mode,
|
||||
send,
|
||||
view: () => viewLineComment(machine.mode()),
|
||||
focusComment: (id: string, range: SelectedLineRange) => send({ type: "comment.focused", id, range }),
|
||||
clearPassive: () => send({ type: "passive.cleared" }),
|
||||
reset: () => send({ type: "reset" }),
|
||||
}
|
||||
}
|
||||
|
||||
function createLocalLineCommentMachine(): LineCommentMachine {
|
||||
const [mode, setMode] = createSignal(IDLE_LINE_COMMENT_MODE)
|
||||
|
||||
return {
|
||||
mode,
|
||||
dispatch: (event) => {
|
||||
const step = transitionLineComment(untrack(mode), event)
|
||||
setMode(step.state)
|
||||
|
||||
return step.effects
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type LineCommentController<T extends SavedLineComment, A> = {
|
||||
mode: Accessor<LineCommentMode>
|
||||
selected: Accessor<SelectedLineRange | null>
|
||||
focusComment: (id: string, range: SelectedLineRange) => void
|
||||
clearPassive: VoidFunction
|
||||
reset: VoidFunction
|
||||
annotations: Accessor<A[]>
|
||||
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 SavedLineComment>(
|
||||
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
|
||||
}
|
||||
): LineCommentController<T, DiffLineAnnotation<LineCommentAnnotationMeta<T>>>
|
||||
export function createLineCommentController<T extends SavedLineComment>(
|
||||
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
|
||||
}
|
||||
): LineCommentController<T, LineCommentAnnotation<T>>
|
||||
export function createLineCommentController<T extends SavedLineComment>(
|
||||
props: LineCommentControllerProps<T> | LineCommentControllerWithSideProps<T>,
|
||||
) {
|
||||
const i18n = useI18n()
|
||||
const note = createLineCommentState<string>(props.state)
|
||||
const driver = createLineCommentDriver(props)
|
||||
const draftRange = () => driver.view().draftRange
|
||||
|
||||
const annotations =
|
||||
"getSide" in props
|
||||
@@ -377,7 +311,7 @@ export function createLineCommentController<T extends SavedLineComment>(
|
||||
comments: props.comments,
|
||||
getCommentId: (comment) => comment.id,
|
||||
getCommentSelection: (comment) => comment.selection,
|
||||
draftRange: note.commenting,
|
||||
draftRange,
|
||||
draftKey: props.draftKey,
|
||||
getSide: props.getSide,
|
||||
})
|
||||
@@ -385,7 +319,7 @@ export function createLineCommentController<T extends SavedLineComment>(
|
||||
comments: props.comments,
|
||||
getCommentId: (comment) => comment.id,
|
||||
getCommentSelection: (comment) => comment.selection,
|
||||
draftRange: note.commenting,
|
||||
draftRange,
|
||||
draftKey: props.draftKey,
|
||||
})
|
||||
|
||||
@@ -394,17 +328,17 @@ export function createLineCommentController<T extends SavedLineComment>(
|
||||
commentElement: lineCommentElement,
|
||||
draftElement: lineCommentDraftElement,
|
||||
renderComment: (comment) => {
|
||||
const edit = () => note.openEditor(comment.id, comment.selection, comment.comment)
|
||||
const item = () => viewCommentItem(driver.mode(), comment.id)
|
||||
|
||||
const remove = () => {
|
||||
note.reset()
|
||||
props.onDelete?.(comment)
|
||||
}
|
||||
const edit = () =>
|
||||
driver.send({ type: "edit.opened", id: comment.id, range: comment.selection, text: comment.comment })
|
||||
|
||||
const remove = () => driver.send({ type: "comment.deleted", id: comment.id }, comment)
|
||||
|
||||
return {
|
||||
id: comment.id,
|
||||
get open() {
|
||||
return note.isOpen(comment.id) || note.isEditing(comment.id)
|
||||
return item().open
|
||||
},
|
||||
comment: comment.comment,
|
||||
selection: formatSelectedLineLabel(comment.selection, i18n.t),
|
||||
@@ -412,92 +346,52 @@ export function createLineCommentController<T extends SavedLineComment>(
|
||||
return props.renderCommentActions?.(comment, { edit, remove })
|
||||
},
|
||||
get editor() {
|
||||
return note.isEditing(comment.id)
|
||||
return item().editing
|
||||
? {
|
||||
get value() {
|
||||
return note.draft()
|
||||
return item().editText
|
||||
},
|
||||
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()
|
||||
},
|
||||
onInput: (text: string) => driver.send({ type: "edit.changed", text }),
|
||||
onCancel: () => driver.send({ type: "edit.cancelled" }),
|
||||
onSubmit: (text: string) => driver.send({ type: "edit.submitted", text }),
|
||||
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 })
|
||||
},
|
||||
onMouseEnter: () => driver.send({ type: "comment.hovered", range: comment.selection }),
|
||||
onClick: () => driver.send({ type: "comment.toggled", id: comment.id, range: comment.selection }),
|
||||
}
|
||||
},
|
||||
renderDraft: (range) => ({
|
||||
get value() {
|
||||
return note.draft()
|
||||
return driver.view().draftText
|
||||
},
|
||||
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()
|
||||
},
|
||||
onInput: (text) => driver.send({ type: "draft.changed", text }),
|
||||
onCancel: () => driver.send({ type: "draft.cancelled" }),
|
||||
onSubmit: (text) => driver.send({ type: "draft.submitted", text }),
|
||||
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,
|
||||
mode: driver.mode,
|
||||
selected: () => driver.view().selected,
|
||||
focusComment: driver.focusComment,
|
||||
clearPassive: driver.clearPassive,
|
||||
reset: driver.reset,
|
||||
annotations,
|
||||
renderAnnotation,
|
||||
renderGutterUtility,
|
||||
onLineSelected,
|
||||
onLineSelectionEnd,
|
||||
renderGutterUtility: createLineCommentGutterRenderer({
|
||||
label: props.label,
|
||||
getSelectedRange: () => driver.view().gutterSelected,
|
||||
onOpenDraft: (range) => driver.send({ type: "draft.opened", range }),
|
||||
}),
|
||||
onLineSelected: (range: SelectedLineRange | null) => driver.send({ type: "line.selected", range }),
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => driver.send({ type: "line.selection-ended", range }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SelectedLineRange } from "@pierre/diffs"
|
||||
import {
|
||||
EMPTY_REVIEW_COMMENT_MODES,
|
||||
IDLE_LINE_COMMENT_MODE,
|
||||
reviewFileCommentMode,
|
||||
transitionLineComment,
|
||||
transitionReviewCommentModes,
|
||||
viewCommentItem,
|
||||
viewLineComment,
|
||||
type LineCommentEvent,
|
||||
type LineCommentMode,
|
||||
type LineCommentTransition,
|
||||
type ReviewCommentModes,
|
||||
} from "./line-comment-machine"
|
||||
|
||||
const line = (start: number, end = start): SelectedLineRange => ({ start, end, side: "additions" })
|
||||
|
||||
function replay(events: LineCommentEvent[], state: LineCommentMode = IDLE_LINE_COMMENT_MODE) {
|
||||
return events.reduce((current, event) => transitionLineComment(current, event).state, state)
|
||||
}
|
||||
|
||||
function replayFiles(steps: [string, LineCommentEvent][], modes: ReviewCommentModes = EMPTY_REVIEW_COMMENT_MODES) {
|
||||
return steps.reduce((current, [file, event]) => transitionReviewCommentModes(current, file, event).modes, modes)
|
||||
}
|
||||
|
||||
describe("transitionLineComment", () => {
|
||||
test("the gutter button, a line click or a cleared selection keeps the text of an open edit", () => {
|
||||
const editing = replay([
|
||||
{ type: "edit.opened", id: "c1", range: line(2), text: "original" },
|
||||
{ type: "edit.changed", text: "updated" },
|
||||
])
|
||||
|
||||
expect(transitionLineComment(editing, { type: "draft.opened", range: line(5) })).toEqual({
|
||||
state: editing,
|
||||
effects: [],
|
||||
})
|
||||
|
||||
// The viewer already highlighted the clicked lines, so the edit hands its own range back to it.
|
||||
const clicks: LineCommentEvent[] = [
|
||||
{ type: "line.selected", range: line(8) },
|
||||
{ type: "line.selection-ended", range: line(8) },
|
||||
{ type: "line.selected", range: null },
|
||||
{ type: "line.selection-ended", range: null },
|
||||
]
|
||||
|
||||
for (const event of clicks) {
|
||||
const step = transitionLineComment(editing, event)
|
||||
|
||||
expect(step).toEqual({ state: editing, effects: [{ type: "sync-selected", range: line(2) }] })
|
||||
expect(viewLineComment(step.state).selected).not.toBe(viewLineComment(editing).selected)
|
||||
}
|
||||
|
||||
expect(editing).toEqual({ status: "editing", id: "c1", range: line(2), text: "updated" })
|
||||
})
|
||||
|
||||
test("the viewer echoing an opened comment's range keeps the comment open", () => {
|
||||
// A focused comment used to close at once: its highlight came back as a selection and moved it to `selecting`.
|
||||
const viewing = transitionLineComment(
|
||||
{ status: "selecting", range: line(9) },
|
||||
{ type: "comment.focused", id: "c1", range: line(2) },
|
||||
).state
|
||||
|
||||
expect(transitionLineComment(viewing, { type: "line.selected", range: line(2) }).state).toBe(viewing)
|
||||
expect(transitionLineComment(viewing, { type: "line.selected", range: line(3) }).state).toEqual({
|
||||
status: "selecting",
|
||||
range: line(3),
|
||||
})
|
||||
})
|
||||
|
||||
test("the viewer echoing a restored range back keeps the same state", () => {
|
||||
const editing: LineCommentMode = { status: "editing", id: "c1", range: line(2), text: "x" }
|
||||
const restored = transitionLineComment(editing, { type: "line.selected", range: line(8) }).state
|
||||
|
||||
expect(transitionLineComment(restored, { type: "line.selected", range: line(2) }).state).toBe(restored)
|
||||
expect(transitionLineComment(restored, { type: "line.selection-ended", range: line(2) }).state).toBe(restored)
|
||||
})
|
||||
|
||||
test("a repeated selection end for the same lines keeps the typed draft, and a new range moves it", () => {
|
||||
const drafting = replay([
|
||||
{ type: "line.selection-ended", range: line(1, 3) },
|
||||
{ type: "draft.changed", text: "keep me" },
|
||||
])
|
||||
|
||||
// The line-number drag used to open the draft again one frame later, which emptied it.
|
||||
expect(transitionLineComment(drafting, { type: "draft.opened", range: line(1, 3) }).state).toBe(drafting)
|
||||
expect(transitionLineComment(drafting, { type: "line.selection-ended", range: line(1, 3) }).state).toBe(drafting)
|
||||
|
||||
expect(transitionLineComment(drafting, { type: "line.selection-ended", range: line(1, 5) })).toEqual({
|
||||
state: { status: "drafting", range: line(1, 5), text: "keep me" },
|
||||
effects: [{ type: "sync-selected", range: line(1, 5) }],
|
||||
})
|
||||
})
|
||||
|
||||
test("a typed draft survives a cleared selection, and an empty one closes", () => {
|
||||
const typed = replay([
|
||||
{ type: "draft.opened", range: line(4) },
|
||||
{ type: "draft.changed", text: "wip" },
|
||||
])
|
||||
|
||||
for (const event of [
|
||||
{ type: "line.selected", range: null },
|
||||
{ type: "line.selection-ended", range: null },
|
||||
] as const)
|
||||
expect(transitionLineComment(typed, event)).toEqual({
|
||||
state: typed,
|
||||
effects: [{ type: "sync-selected", range: line(4) }],
|
||||
})
|
||||
|
||||
const empty = replay([{ type: "draft.opened", range: line(4) }])
|
||||
|
||||
expect(transitionLineComment(empty, { type: "line.selection-ended", range: null })).toEqual({
|
||||
state: IDLE_LINE_COMMENT_MODE,
|
||||
effects: [{ type: "sync-selected", range: null }],
|
||||
})
|
||||
})
|
||||
|
||||
test("deleting one comment keeps a draft, another comment's edit, and closes only the deleted comment", () => {
|
||||
const drafting: LineCommentMode = { status: "drafting", range: line(20), text: "wip" }
|
||||
const editingOther: LineCommentMode = { status: "editing", id: "c2", range: line(10), text: "wip" }
|
||||
|
||||
for (const state of [drafting, editingOther]) {
|
||||
const step = transitionLineComment(state, { type: "comment.deleted", id: "c1" })
|
||||
|
||||
expect(step.state).toBe(state)
|
||||
expect(step.effects).toEqual([{ type: "delete-comment", id: "c1" }])
|
||||
}
|
||||
|
||||
const own: LineCommentMode[] = [
|
||||
{ status: "editing", id: "c1", range: line(5), text: "wip" },
|
||||
{ status: "viewing", id: "c1", range: line(5) },
|
||||
]
|
||||
|
||||
for (const state of own)
|
||||
expect(transitionLineComment(state, { type: "comment.deleted", id: "c1" })).toEqual({
|
||||
state: IDLE_LINE_COMMENT_MODE,
|
||||
effects: [
|
||||
{ type: "sync-selected", range: null },
|
||||
{ type: "delete-comment", id: "c1" },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("clicking a comment never leaves a draft and an open comment at the same time", () => {
|
||||
const empty = transitionLineComment(
|
||||
{ status: "drafting", range: line(5), text: "" },
|
||||
{ type: "comment.toggled", id: "c1", range: line(2) },
|
||||
).state
|
||||
|
||||
expect(empty).toEqual({ status: "viewing", id: "c1", range: line(2) })
|
||||
expect(viewLineComment(empty).draftRange).toBeNull()
|
||||
|
||||
const typed: LineCommentMode = { status: "drafting", range: line(5), text: "unsent" }
|
||||
const kept = transitionLineComment(typed, { type: "comment.toggled", id: "c1", range: line(2) }).state
|
||||
|
||||
expect(kept).toBe(typed)
|
||||
expect(viewCommentItem(kept, "c1").open).toBe(false)
|
||||
})
|
||||
|
||||
test("toggling the open comment closes it, and another comment replaces it", () => {
|
||||
const viewing = replay([{ type: "comment.toggled", id: "c1", range: line(2) }])
|
||||
|
||||
expect(viewCommentItem(viewing, "c1")).toEqual({ open: true, editing: false, editText: "" })
|
||||
expect(transitionLineComment(viewing, { type: "comment.toggled", id: "c2", range: line(7) }).state).toEqual({
|
||||
status: "viewing",
|
||||
id: "c2",
|
||||
range: line(7),
|
||||
})
|
||||
expect(transitionLineComment(viewing, { type: "comment.toggled", id: "c1", range: line(2) })).toEqual({
|
||||
state: IDLE_LINE_COMMENT_MODE,
|
||||
effects: [{ type: "sync-selected", range: null }],
|
||||
})
|
||||
})
|
||||
|
||||
test("a focus request opens the comment unless typed text would be lost", () => {
|
||||
const step = transitionLineComment(
|
||||
{ status: "selecting", range: line(9) },
|
||||
{ type: "comment.focused", id: "c1", range: line(2) },
|
||||
)
|
||||
|
||||
expect(step).toEqual({
|
||||
state: { status: "viewing", id: "c1", range: line(2) },
|
||||
effects: [{ type: "sync-selected", range: line(2) }],
|
||||
})
|
||||
expect(transitionLineComment(step.state, { type: "comment.focused", id: "c1", range: line(2) }).state).toBe(
|
||||
step.state,
|
||||
)
|
||||
|
||||
const typed: LineCommentMode = { status: "drafting", range: line(5), text: "unsent" }
|
||||
const editing: LineCommentMode = { status: "editing", id: "c2", range: line(3), text: "x" }
|
||||
|
||||
expect(transitionLineComment(typed, { type: "comment.focused", id: "c1", range: line(2) }).state).toBe(typed)
|
||||
expect(transitionLineComment(editing, { type: "comment.focused", id: "c1", range: line(2) }).state).toBe(editing)
|
||||
})
|
||||
|
||||
test("hovering a comment previews its lines only while nothing is open", () => {
|
||||
expect(transitionLineComment(IDLE_LINE_COMMENT_MODE, { type: "comment.hovered", range: line(3) })).toEqual({
|
||||
state: { status: "selecting", range: line(3) },
|
||||
effects: [{ type: "sync-selected", range: line(3) }],
|
||||
})
|
||||
|
||||
const held: LineCommentMode[] = [
|
||||
{ status: "selecting", range: line(3) },
|
||||
{ status: "viewing", id: "c1", range: line(2) },
|
||||
{ status: "drafting", range: line(5), text: "" },
|
||||
{ status: "editing", id: "c1", range: line(2), text: "x" },
|
||||
]
|
||||
|
||||
for (const state of held)
|
||||
expect(transitionLineComment(state, { type: "comment.hovered", range: line(3) }).state).toBe(state)
|
||||
})
|
||||
|
||||
test("submitting trims the text, rejects blank text and clears the selection first", () => {
|
||||
const drafting: LineCommentMode = { status: "drafting", range: line(1, 2), text: " note " }
|
||||
|
||||
expect(transitionLineComment(drafting, { type: "draft.submitted", text: " " }).state).toBe(drafting)
|
||||
expect(transitionLineComment(drafting, { type: "draft.submitted", text: " note " })).toEqual({
|
||||
state: IDLE_LINE_COMMENT_MODE,
|
||||
effects: [
|
||||
{ type: "sync-selected", range: null },
|
||||
{ type: "submit-draft", comment: "note", selection: line(1, 2) },
|
||||
],
|
||||
})
|
||||
|
||||
const editing: LineCommentMode = { status: "editing", id: "c1", range: line(4), text: "x" }
|
||||
|
||||
expect(transitionLineComment(editing, { type: "edit.submitted", text: "\n" }).state).toBe(editing)
|
||||
expect(transitionLineComment(editing, { type: "edit.submitted", text: " fixed " })).toEqual({
|
||||
state: IDLE_LINE_COMMENT_MODE,
|
||||
effects: [
|
||||
{ type: "sync-selected", range: null },
|
||||
{ type: "submit-edit", id: "c1", comment: "fixed", selection: line(4) },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("cancel, passive clear and reset exit to idle", () => {
|
||||
const drafting: LineCommentMode = { status: "drafting", range: line(1), text: "x" }
|
||||
const editing: LineCommentMode = { status: "editing", id: "c1", range: line(4), text: "x" }
|
||||
const selecting: LineCommentMode = { status: "selecting", range: line(2) }
|
||||
const viewing: LineCommentMode = { status: "viewing", id: "c1", range: line(4) }
|
||||
|
||||
const cleared: LineCommentTransition = {
|
||||
state: IDLE_LINE_COMMENT_MODE,
|
||||
effects: [{ type: "sync-selected", range: null }],
|
||||
}
|
||||
|
||||
expect(transitionLineComment(drafting, { type: "draft.cancelled" })).toEqual(cleared)
|
||||
expect(transitionLineComment(editing, { type: "edit.cancelled" })).toEqual(cleared)
|
||||
expect(transitionLineComment(drafting, { type: "edit.cancelled" }).state).toBe(drafting)
|
||||
expect(transitionLineComment(editing, { type: "draft.cancelled" }).state).toBe(editing)
|
||||
|
||||
for (const state of [selecting, viewing])
|
||||
expect(transitionLineComment(state, { type: "passive.cleared" })).toEqual({
|
||||
state: IDLE_LINE_COMMENT_MODE,
|
||||
effects: [],
|
||||
})
|
||||
|
||||
for (const state of [drafting, editing])
|
||||
expect(transitionLineComment(state, { type: "passive.cleared" }).state).toBe(state)
|
||||
|
||||
for (const state of [drafting, editing, selecting, viewing])
|
||||
expect(transitionLineComment(state, { type: "reset" })).toEqual({ state: IDLE_LINE_COMMENT_MODE, effects: [] })
|
||||
})
|
||||
|
||||
test("events that do not apply return the same state", () => {
|
||||
const states: LineCommentMode[] = [
|
||||
IDLE_LINE_COMMENT_MODE,
|
||||
{ status: "selecting", range: line(2) },
|
||||
{ status: "viewing", id: "c1", range: line(4) },
|
||||
]
|
||||
|
||||
const events: LineCommentEvent[] = [
|
||||
{ type: "draft.changed", text: "x" },
|
||||
{ type: "draft.cancelled" },
|
||||
{ type: "draft.submitted", text: "x" },
|
||||
{ type: "edit.changed", text: "x" },
|
||||
{ type: "edit.cancelled" },
|
||||
{ type: "edit.submitted", text: "x" },
|
||||
]
|
||||
|
||||
for (const state of states)
|
||||
for (const event of events) expect(transitionLineComment(state, event).state).toBe(state)
|
||||
|
||||
expect(transitionLineComment(IDLE_LINE_COMMENT_MODE, { type: "reset" }).state).toBe(IDLE_LINE_COMMENT_MODE)
|
||||
expect(transitionLineComment(IDLE_LINE_COMMENT_MODE, { type: "line.selected", range: null }).state).toBe(
|
||||
IDLE_LINE_COMMENT_MODE,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("transitionReviewCommentModes", () => {
|
||||
test("hovering in one file keeps another file's draft and its selected lines", () => {
|
||||
const modes = replayFiles([
|
||||
["b.ts", { type: "draft.opened", range: line(3) }],
|
||||
["b.ts", { type: "draft.changed", text: "wip" }],
|
||||
["a.ts", { type: "comment.hovered", range: line(1) }],
|
||||
])
|
||||
|
||||
expect(viewLineComment(reviewFileCommentMode(modes, "b.ts"))).toMatchObject({
|
||||
selected: line(3),
|
||||
draftRange: line(3),
|
||||
draftText: "wip",
|
||||
})
|
||||
expect(reviewFileCommentMode(modes, "a.ts")).toEqual({ status: "selecting", range: line(1) })
|
||||
})
|
||||
|
||||
test("a selection in one file clears passive selections in the others", () => {
|
||||
const modes = replayFiles([
|
||||
["a.ts", { type: "line.selected", range: line(1) }],
|
||||
["c.ts", { type: "comment.toggled", id: "c1", range: line(5) }],
|
||||
])
|
||||
|
||||
expect(Object.keys(modes)).toEqual(["c.ts"])
|
||||
})
|
||||
|
||||
test("an edit keeps its file active while the user selects elsewhere", () => {
|
||||
const modes = replayFiles([
|
||||
["d.ts", { type: "edit.opened", id: "d1", range: line(4), text: "edit" }],
|
||||
["a.ts", { type: "line.selected", range: line(2) }],
|
||||
["a.ts", { type: "line.selected", range: null }],
|
||||
])
|
||||
|
||||
expect(reviewFileCommentMode(modes, "d.ts").status).toBe("editing")
|
||||
expect(reviewFileCommentMode(modes, "a.ts").status).toBe("idle")
|
||||
})
|
||||
|
||||
test("ignored events keep the same map, and the last idle file returns the empty map", () => {
|
||||
const modes = replayFiles([["a.ts", { type: "line.selected", range: line(1) }]])
|
||||
|
||||
expect(transitionReviewCommentModes(modes, "a.ts", { type: "line.selected", range: line(1) }).modes).toBe(modes)
|
||||
expect(transitionReviewCommentModes(modes, "a.ts", { type: "reset" }).modes).toBe(EMPTY_REVIEW_COMMENT_MODES)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,254 @@
|
||||
import type { SelectedLineRange } from "@pierre/diffs"
|
||||
import { cloneSelectedLineRange } from "../pierre/selection-bridge"
|
||||
|
||||
/** What one file's line comments are doing. A new comment's draft and a saved comment's edit live in different variants, so one never overwrites the other. */
|
||||
export type LineCommentMode =
|
||||
| { readonly status: "idle" }
|
||||
| { readonly status: "selecting"; readonly range: SelectedLineRange }
|
||||
| { readonly status: "drafting"; readonly range: SelectedLineRange; readonly text: string }
|
||||
| { readonly status: "viewing"; readonly id: string; readonly range: SelectedLineRange }
|
||||
| { readonly status: "editing"; readonly id: string; readonly range: SelectedLineRange; readonly text: string }
|
||||
|
||||
export type LineCommentEvent =
|
||||
| { readonly type: "line.selected"; readonly range: SelectedLineRange | null }
|
||||
| { readonly type: "line.selection-ended"; readonly range: SelectedLineRange | null }
|
||||
| { readonly type: "draft.opened"; readonly range: SelectedLineRange }
|
||||
| { readonly type: "draft.changed"; readonly text: string }
|
||||
| { readonly type: "draft.cancelled" }
|
||||
| { readonly type: "draft.submitted"; readonly text: string }
|
||||
| { readonly type: "comment.hovered"; readonly range: SelectedLineRange }
|
||||
| { readonly type: "comment.toggled"; readonly id: string; readonly range: SelectedLineRange }
|
||||
| { readonly type: "comment.focused"; readonly id: string; readonly range: SelectedLineRange }
|
||||
| { readonly type: "comment.deleted"; readonly id: string }
|
||||
| { readonly type: "edit.opened"; readonly id: string; readonly range: SelectedLineRange; readonly text: string }
|
||||
| { readonly type: "edit.changed"; readonly text: string }
|
||||
| { readonly type: "edit.cancelled" }
|
||||
| { readonly type: "edit.submitted"; readonly text: string }
|
||||
| { readonly type: "passive.cleared" }
|
||||
| { readonly type: "reset" }
|
||||
|
||||
export type LineCommentEffect =
|
||||
| { readonly type: "sync-selected"; readonly range: SelectedLineRange | null }
|
||||
| { readonly type: "submit-draft"; readonly comment: string; readonly selection: SelectedLineRange }
|
||||
| {
|
||||
readonly type: "submit-edit"
|
||||
readonly id: string
|
||||
readonly comment: string
|
||||
readonly selection: SelectedLineRange
|
||||
}
|
||||
| { readonly type: "delete-comment"; readonly id: string }
|
||||
|
||||
export type LineCommentTransition = {
|
||||
readonly state: LineCommentMode
|
||||
readonly effects: readonly LineCommentEffect[]
|
||||
}
|
||||
|
||||
/** The modes of every file in a multi-file review. Idle files have no entry. */
|
||||
export type ReviewCommentModes = Readonly<Record<string, ActiveLineCommentMode>>
|
||||
|
||||
type ActiveLineCommentMode = Exclude<LineCommentMode, { status: "idle" }>
|
||||
|
||||
export const IDLE_LINE_COMMENT_MODE: LineCommentMode = Object.freeze({ status: "idle" })
|
||||
|
||||
export const EMPTY_REVIEW_COMMENT_MODES: ReviewCommentModes = Object.freeze({})
|
||||
|
||||
const NO_EFFECTS: readonly LineCommentEffect[] = Object.freeze([])
|
||||
|
||||
export function transitionLineComment(state: LineCommentMode, event: LineCommentEvent): LineCommentTransition {
|
||||
switch (event.type) {
|
||||
case "line.selected":
|
||||
if (!event.range) return clear(state)
|
||||
|
||||
// The viewer reports every highlight it applies, including the range this mode just handed it.
|
||||
if (state.status !== "idle" && sameSelectedRange(state.range, event.range)) return stay(state)
|
||||
|
||||
if (state.status === "editing") return restore(state, event.range)
|
||||
|
||||
if (state.status === "drafting")
|
||||
return enter({ status: "drafting", range: cloneSelectedLineRange(event.range), text: state.text })
|
||||
|
||||
return enter({ status: "selecting", range: cloneSelectedLineRange(event.range) })
|
||||
case "line.selection-ended":
|
||||
if (!event.range) return clear(state)
|
||||
|
||||
if (state.status === "editing") return restore(state, event.range)
|
||||
|
||||
return openDraft(state, event.range)
|
||||
case "draft.opened":
|
||||
return openDraft(state, event.range)
|
||||
case "draft.changed":
|
||||
if (state.status !== "drafting" || state.text === event.text) return stay(state)
|
||||
|
||||
return { state: { ...state, text: event.text }, effects: NO_EFFECTS }
|
||||
case "draft.cancelled":
|
||||
if (state.status !== "drafting") return stay(state)
|
||||
|
||||
return enter(IDLE_LINE_COMMENT_MODE)
|
||||
case "draft.submitted": {
|
||||
const comment = event.text.trim()
|
||||
|
||||
if (state.status !== "drafting" || !comment) return stay(state)
|
||||
|
||||
return enter(IDLE_LINE_COMMENT_MODE, {
|
||||
type: "submit-draft",
|
||||
comment,
|
||||
selection: cloneSelectedLineRange(state.range),
|
||||
})
|
||||
}
|
||||
|
||||
case "comment.hovered":
|
||||
if (state.status !== "idle" && state.status !== "selecting") return stay(state)
|
||||
|
||||
if (state.status === "selecting" && sameSelectedRange(state.range, event.range)) return stay(state)
|
||||
|
||||
return enter({ status: "selecting", range: cloneSelectedLineRange(event.range) })
|
||||
case "comment.toggled":
|
||||
if (holdsText(state)) return stay(state)
|
||||
|
||||
if (state.status === "viewing" && state.id === event.id) return enter(IDLE_LINE_COMMENT_MODE)
|
||||
|
||||
return enter({ status: "viewing", id: event.id, range: cloneSelectedLineRange(event.range) })
|
||||
case "comment.focused":
|
||||
if (holdsText(state)) return stay(state)
|
||||
|
||||
if (state.status === "viewing" && state.id === event.id && sameSelectedRange(state.range, event.range))
|
||||
return stay(state)
|
||||
|
||||
return enter({ status: "viewing", id: event.id, range: cloneSelectedLineRange(event.range) })
|
||||
case "comment.deleted": {
|
||||
const effect: LineCommentEffect = { type: "delete-comment", id: event.id }
|
||||
|
||||
if ((state.status === "viewing" || state.status === "editing") && state.id === event.id)
|
||||
return enter(IDLE_LINE_COMMENT_MODE, effect)
|
||||
|
||||
return { state, effects: [effect] }
|
||||
}
|
||||
|
||||
case "edit.opened":
|
||||
if (holdsText(state)) return stay(state)
|
||||
|
||||
return enter({ status: "editing", id: event.id, range: cloneSelectedLineRange(event.range), text: event.text })
|
||||
case "edit.changed":
|
||||
if (state.status !== "editing" || state.text === event.text) return stay(state)
|
||||
|
||||
return { state: { ...state, text: event.text }, effects: NO_EFFECTS }
|
||||
case "edit.cancelled":
|
||||
if (state.status !== "editing") return stay(state)
|
||||
|
||||
return enter(IDLE_LINE_COMMENT_MODE)
|
||||
case "edit.submitted": {
|
||||
const comment = event.text.trim()
|
||||
|
||||
if (state.status !== "editing" || !comment) return stay(state)
|
||||
|
||||
return enter(IDLE_LINE_COMMENT_MODE, {
|
||||
type: "submit-edit",
|
||||
id: state.id,
|
||||
comment,
|
||||
selection: cloneSelectedLineRange(state.range),
|
||||
})
|
||||
}
|
||||
|
||||
case "passive.cleared":
|
||||
if (state.status !== "selecting" && state.status !== "viewing") return stay(state)
|
||||
|
||||
return { state: IDLE_LINE_COMMENT_MODE, effects: NO_EFFECTS }
|
||||
case "reset":
|
||||
if (state.status === "idle") return stay(state)
|
||||
|
||||
return { state: IDLE_LINE_COMMENT_MODE, effects: NO_EFFECTS }
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies `event` to `file`. A file that becomes active clears passive selections in the other files and keeps their drafts and edits. */
|
||||
export function transitionReviewCommentModes(modes: ReviewCommentModes, file: string, event: LineCommentEvent) {
|
||||
const current = reviewFileCommentMode(modes, file)
|
||||
const step = transitionLineComment(current, event)
|
||||
|
||||
if (step.state === current) return { modes, effects: step.effects }
|
||||
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(modes).filter(
|
||||
([other, mode]) =>
|
||||
other !== file && (step.state.status === "idle" || mode.status === "drafting" || mode.status === "editing"),
|
||||
),
|
||||
)
|
||||
|
||||
if (step.state.status !== "idle") next[file] = step.state
|
||||
|
||||
return {
|
||||
modes: Object.keys(next).length === 0 ? EMPTY_REVIEW_COMMENT_MODES : next,
|
||||
effects: step.effects,
|
||||
}
|
||||
}
|
||||
|
||||
export function viewLineComment(state: LineCommentMode) {
|
||||
return {
|
||||
selected: state.status === "idle" ? null : state.range,
|
||||
draftRange: state.status === "drafting" ? state.range : null,
|
||||
draftText: state.status === "drafting" ? state.text : "",
|
||||
gutterSelected: state.status === "selecting" || state.status === "drafting" ? state.range : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function viewCommentItem(state: LineCommentMode, id: string) {
|
||||
const editing = state.status === "editing" && state.id === id
|
||||
|
||||
return {
|
||||
open: editing || (state.status === "viewing" && state.id === id),
|
||||
editing,
|
||||
editText: editing ? state.text : "",
|
||||
}
|
||||
}
|
||||
|
||||
export function reviewFileCommentMode(modes: ReviewCommentModes, file: string): LineCommentMode {
|
||||
return modes[file] ?? IDLE_LINE_COMMENT_MODE
|
||||
}
|
||||
|
||||
function sameSelectedRange(a: SelectedLineRange, b: SelectedLineRange) {
|
||||
return a.start === b.start && a.end === b.end && a.side === b.side && (a.endSide ?? a.side) === (b.endSide ?? b.side)
|
||||
}
|
||||
|
||||
function openDraft(state: LineCommentMode, range: SelectedLineRange): LineCommentTransition {
|
||||
if (state.status === "editing") return stay(state)
|
||||
|
||||
if (state.status !== "drafting") return enter({ status: "drafting", range: cloneSelectedLineRange(range), text: "" })
|
||||
|
||||
if (sameSelectedRange(state.range, range)) return stay(state)
|
||||
|
||||
return enter({ status: "drafting", range: cloneSelectedLineRange(range), text: state.text })
|
||||
}
|
||||
|
||||
function clear(state: LineCommentMode): LineCommentTransition {
|
||||
if (state.status === "idle") return stay(state)
|
||||
|
||||
if (holdsText(state)) return restore(state, null)
|
||||
|
||||
return enter(IDLE_LINE_COMMENT_MODE)
|
||||
}
|
||||
|
||||
// The viewer highlights a clicked range before the machine sees it. When typed text keeps its range, a fresh copy of
|
||||
// that range makes the `selectedLines` prop change, so the viewer highlights the held lines again.
|
||||
function restore(state: ActiveLineCommentMode, range: SelectedLineRange | null): LineCommentTransition {
|
||||
if (range && sameSelectedRange(state.range, range)) return stay(state)
|
||||
|
||||
return enter({ ...state, range: cloneSelectedLineRange(state.range) })
|
||||
}
|
||||
|
||||
// Typed draft text and an open edit are never discarded by a selection, click or focus elsewhere in the file.
|
||||
function holdsText(state: LineCommentMode) {
|
||||
if (state.status === "editing") return true
|
||||
|
||||
return state.status === "drafting" && state.text.trim().length > 0
|
||||
}
|
||||
|
||||
function enter(state: LineCommentMode, ...effects: LineCommentEffect[]): LineCommentTransition {
|
||||
return {
|
||||
state,
|
||||
effects: [{ type: "sync-selected", range: state.status === "idle" ? null : state.range }, ...effects],
|
||||
}
|
||||
}
|
||||
|
||||
function stay(state: LineCommentMode): LineCommentTransition {
|
||||
return { state, effects: NO_EFFECTS }
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export const UnifiedDark = {
|
||||
}
|
||||
|
||||
function InteractiveCommentsStory() {
|
||||
const [state, setState] = createStore({ comments: [] as SessionReviewComment[] })
|
||||
const [state, setState] = createStore<{ comments: SessionReviewComment[]; next: number }>({ comments: [], next: 1 })
|
||||
const file = "src/review.ts"
|
||||
|
||||
const diffs = [
|
||||
@@ -71,9 +71,22 @@ function InteractiveCommentsStory() {
|
||||
diffs={diffs}
|
||||
open={[file]}
|
||||
comments={state.comments}
|
||||
onLineComment={(comment) =>
|
||||
setState("comments", (comments) => [...comments, { id: `comment-${comments.length + 1}`, ...comment }])
|
||||
onLineComment={(comment) => {
|
||||
setState("comments", (comments) => [...comments, { id: `comment-${state.next}`, ...comment }])
|
||||
setState("next", (next) => next + 1)
|
||||
}}
|
||||
onLineCommentUpdate={(update) =>
|
||||
setState("comments", (comment) => comment.id === update.id, "comment", update.comment)
|
||||
}
|
||||
onLineCommentDelete={(target) =>
|
||||
setState("comments", (comments) => comments.filter((comment) => comment.id !== target.id))
|
||||
}
|
||||
lineCommentActions={{
|
||||
moreLabel: "More options",
|
||||
editLabel: "Edit",
|
||||
deleteLabel: "Delete",
|
||||
saveLabel: "Save",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CurrentSessionProviders>
|
||||
|
||||
@@ -12,7 +12,18 @@ import { useFileComponent } from "@opencode/ui/context/file"
|
||||
import { useI18n } from "@opencode/ui/context/i18n"
|
||||
import { getDirectory, getFilename } from "@opencode/util/path"
|
||||
import { checksum } from "@opencode/util/encode"
|
||||
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
onCleanup,
|
||||
Show,
|
||||
Switch,
|
||||
untrack,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Predicate } from "effect"
|
||||
import type { FileDiffInfo } from "@opencode/client/promise"
|
||||
@@ -21,8 +32,14 @@ import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { mediaKindFromPath } from "../pierre/media"
|
||||
import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge"
|
||||
import { previewSelectedLines } from "../pierre/selection-bridge"
|
||||
import { createLineCommentController } from "./line-comment-annotations"
|
||||
import {
|
||||
EMPTY_REVIEW_COMMENT_MODES,
|
||||
reviewFileCommentMode,
|
||||
transitionReviewCommentModes,
|
||||
type LineCommentEvent,
|
||||
} from "./line-comment-machine"
|
||||
import type { LineCommentEditorProps } from "./line-comment"
|
||||
import { normalize, text, type ViewDiff } from "./session-diff"
|
||||
|
||||
@@ -139,11 +156,6 @@ function diffId(file: string): string | undefined {
|
||||
return `session-review-diff-${sum}`
|
||||
}
|
||||
|
||||
type SessionReviewSelection = {
|
||||
file: string
|
||||
range: SelectedLineRange
|
||||
}
|
||||
|
||||
export const SessionReview = (props: SessionReviewProps) => {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let focusToken = 0
|
||||
@@ -157,21 +169,20 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
open: string[]
|
||||
visible: Record<string, boolean | undefined>
|
||||
force: Record<string, boolean | undefined>
|
||||
selection: SessionReviewSelection | null
|
||||
commenting: SessionReviewSelection | null
|
||||
opened: SessionReviewFocus | null
|
||||
}>({
|
||||
open: [],
|
||||
visible: {},
|
||||
force: {},
|
||||
selection: null,
|
||||
commenting: null,
|
||||
opened: null,
|
||||
})
|
||||
|
||||
const selection = () => store.selection
|
||||
const commenting = () => store.commenting
|
||||
const opened = () => store.opened
|
||||
const [commentModes, setCommentModes] = createSignal(EMPTY_REVIEW_COMMENT_MODES)
|
||||
|
||||
const dispatchComment = (file: string, event: LineCommentEvent) => {
|
||||
const step = transitionReviewCommentModes(untrack(commentModes), file, event)
|
||||
setCommentModes(step.modes)
|
||||
|
||||
return step.effects
|
||||
}
|
||||
|
||||
const open = () => props.open ?? store.open
|
||||
|
||||
@@ -240,9 +251,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const pinned = (file: string) =>
|
||||
props.focusedComment?.file === file ||
|
||||
props.focusedFile === file ||
|
||||
selection()?.file === file ||
|
||||
commenting()?.file === file ||
|
||||
opened()?.file === file
|
||||
reviewFileCommentMode(commentModes(), file).status !== "idle"
|
||||
|
||||
const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
|
||||
queue()
|
||||
@@ -300,11 +309,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
focusToken++
|
||||
const token = focusToken
|
||||
|
||||
setStore("opened", focus)
|
||||
|
||||
const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id)
|
||||
|
||||
if (comment) setStore("selection", { file: comment.file, range: cloneSelectedLineRange(comment.selection) })
|
||||
if (comment) dispatchComment(focus.file, { type: "comment.focused", id: comment.id, range: comment.selection })
|
||||
|
||||
const current = open()
|
||||
|
||||
@@ -451,43 +458,16 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const isDeleted = () =>
|
||||
diff().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
||||
|
||||
const selectedLines = createMemo(() => {
|
||||
const current = selection()
|
||||
|
||||
if (!current || current.file !== file) return null
|
||||
|
||||
return current.range
|
||||
})
|
||||
|
||||
const draftRange = createMemo(() => {
|
||||
const current = commenting()
|
||||
|
||||
if (!current || current.file !== file) return null
|
||||
|
||||
return current.range
|
||||
})
|
||||
|
||||
const commentsUi = createLineCommentController<SessionReviewComment>({
|
||||
comments,
|
||||
label: i18n.t("ui.lineComment.submit"),
|
||||
draftKey: () => file,
|
||||
mention: props.lineCommentMention,
|
||||
state: {
|
||||
opened: () => {
|
||||
const current = opened()
|
||||
|
||||
if (!current || current.file !== file) return null
|
||||
|
||||
return current.id
|
||||
},
|
||||
setOpened: (id) => setStore("opened", id ? { file, id } : null),
|
||||
selected: selectedLines,
|
||||
setSelected: (range) => setStore("selection", range ? { file, range } : null),
|
||||
commenting: draftRange,
|
||||
setCommenting: (range) => setStore("commenting", range ? { file, range } : null),
|
||||
machine: {
|
||||
mode: createMemo(() => reviewFileCommentMode(commentModes(), file)),
|
||||
dispatch: (event) => dispatchComment(file, event),
|
||||
},
|
||||
getSide: selectionSide,
|
||||
clearSelectionOnSelectionEndNull: false,
|
||||
onSubmit: ({ comment, selection }) => {
|
||||
props.onLineComment?.({
|
||||
file,
|
||||
@@ -693,7 +673,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
renderGutterUtility={
|
||||
props.onLineComment ? commentsUi.renderGutterUtility : undefined
|
||||
}
|
||||
selectedLines={selectedLines()}
|
||||
selectedLines={commentsUi.selected()}
|
||||
commentedLines={commentedLines()}
|
||||
media={{
|
||||
mode: "auto",
|
||||
|
||||
@@ -2,21 +2,22 @@ import { type SelectedLineRange } from "@pierre/diffs"
|
||||
import { Show, type Accessor, type JSX } from "solid-js"
|
||||
import {
|
||||
createLineCommentAnnotations,
|
||||
createLineCommentDriver,
|
||||
createLineCommentGutterRenderer,
|
||||
createLineCommentState,
|
||||
createManagedLineCommentAnnotationRenderer,
|
||||
type LineCommentMachine,
|
||||
type SavedLineComment,
|
||||
type LineCommentStateProps,
|
||||
} from "../../components/line-comment-annotations"
|
||||
import { viewCommentItem } from "../../components/line-comment-machine"
|
||||
import { useI18n } from "@opencode/ui/context/i18n"
|
||||
import { cloneSelectedLineRange } from "../../pierre/selection-bridge"
|
||||
import { LineCommentEditor, LineComment, type LineCommentEditorMention } from "@opencode/ui/line-comment"
|
||||
|
||||
type LineCommentControllerV2Props<T extends SavedLineComment> = {
|
||||
comments: Accessor<T[]>
|
||||
draftKey: Accessor<string>
|
||||
label: string
|
||||
state: LineCommentStateProps<string>
|
||||
machine?: LineCommentMachine
|
||||
onSyncSelected?: (range: SelectedLineRange | null) => void
|
||||
getSide: (range: SelectedLineRange) => "additions" | "deletions"
|
||||
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
|
||||
onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
|
||||
@@ -62,17 +63,19 @@ function lineCommentElementV2(view: Accessor<CommentProps>) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div data-prevent-autofocus="" data-comment-id={view().id} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<LineCommentEditor
|
||||
value={view().editor!.value}
|
||||
onInput={view().editor!.onInput}
|
||||
onCancel={view().editor!.onCancel}
|
||||
onSubmit={view().editor!.onSubmit}
|
||||
cancelLabel={view().editor!.cancelLabel}
|
||||
submitLabel={view().editor!.submitLabel}
|
||||
mention={view().editor!.mention}
|
||||
/>
|
||||
</div>
|
||||
{(editor) => (
|
||||
<div data-prevent-autofocus="" data-comment-id={view().id} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<LineCommentEditor
|
||||
value={editor().value}
|
||||
onInput={editor().onInput}
|
||||
onCancel={editor().onCancel}
|
||||
onSubmit={editor().onSubmit}
|
||||
cancelLabel={editor().cancelLabel}
|
||||
submitLabel={editor().submitLabel}
|
||||
mention={editor().mention}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -95,13 +98,13 @@ function lineCommentDraftElementV2(view: Accessor<DraftProps>) {
|
||||
|
||||
export function createLineCommentControllerV2<T extends SavedLineComment>(props: LineCommentControllerV2Props<T>) {
|
||||
const i18n = useI18n()
|
||||
const note = createLineCommentState<string>(props.state)
|
||||
const driver = createLineCommentDriver(props)
|
||||
|
||||
const annotations = createLineCommentAnnotations({
|
||||
comments: props.comments,
|
||||
getCommentId: (comment) => comment.id,
|
||||
getCommentSelection: (comment) => comment.selection,
|
||||
draftRange: note.commenting,
|
||||
draftRange: () => driver.view().draftRange,
|
||||
draftKey: props.draftKey,
|
||||
getSide: props.getSide,
|
||||
})
|
||||
@@ -111,12 +114,12 @@ export function createLineCommentControllerV2<T extends SavedLineComment>(props:
|
||||
commentElement: lineCommentElementV2,
|
||||
draftElement: lineCommentDraftElementV2,
|
||||
renderComment: (comment) => {
|
||||
const edit = () => note.openEditor(comment.id, comment.selection, comment.comment)
|
||||
const item = () => viewCommentItem(driver.mode(), comment.id)
|
||||
|
||||
const remove = () => {
|
||||
note.reset()
|
||||
props.onDelete?.(comment)
|
||||
}
|
||||
const edit = () =>
|
||||
driver.send({ type: "edit.opened", id: comment.id, range: comment.selection, text: comment.comment })
|
||||
|
||||
const remove = () => driver.send({ type: "comment.deleted", id: comment.id }, comment)
|
||||
|
||||
return {
|
||||
id: comment.id,
|
||||
@@ -127,100 +130,54 @@ export function createLineCommentControllerV2<T extends SavedLineComment>(props:
|
||||
return props.renderCommentActions?.(comment, { edit, remove })
|
||||
},
|
||||
get editor() {
|
||||
return note.isEditing(comment.id)
|
||||
return item().editing
|
||||
? {
|
||||
get value() {
|
||||
return note.draft()
|
||||
},
|
||||
onInput: note.setDraft,
|
||||
onCancel: note.cancelDraft,
|
||||
onSubmit: (value: string) => {
|
||||
props.onUpdate?.({
|
||||
id: comment.id,
|
||||
comment: value,
|
||||
selection: cloneSelectedLineRange(comment.selection),
|
||||
})
|
||||
note.cancelDraft()
|
||||
return item().editText
|
||||
},
|
||||
onInput: (text: string) => driver.send({ type: "edit.changed", text }),
|
||||
onCancel: () => driver.send({ type: "edit.cancelled" }),
|
||||
onSubmit: (text: string) => driver.send({ type: "edit.submitted", text }),
|
||||
cancelLabel: i18n.t("ui.lineComment.cancel"),
|
||||
submitLabel: props.editSubmitLabel,
|
||||
mention: props.mention,
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
onMouseEnter: () => {
|
||||
if (note.commenting()) return
|
||||
note.hoverComment(comment.selection)
|
||||
},
|
||||
onMouseEnter: () => driver.send({ type: "comment.hovered", range: comment.selection }),
|
||||
onClick: () => {
|
||||
if (props.renderCommentActions || note.isEditing(comment.id)) return
|
||||
note.toggleComment(comment.id, comment.selection)
|
||||
if (props.renderCommentActions) return
|
||||
driver.send({ type: "comment.toggled", id: comment.id, range: comment.selection })
|
||||
},
|
||||
}
|
||||
},
|
||||
renderDraft: (range) => ({
|
||||
renderDraft: () => ({
|
||||
get value() {
|
||||
return note.draft()
|
||||
},
|
||||
onInput: note.setDraft,
|
||||
onCancel: () => {
|
||||
note.cancelDraft()
|
||||
note.select(null)
|
||||
},
|
||||
onSubmit: (comment) => {
|
||||
props.onSubmit({ comment, selection: cloneSelectedLineRange(range) })
|
||||
note.cancelDraft()
|
||||
note.select(null)
|
||||
return driver.view().draftText
|
||||
},
|
||||
onInput: (text) => driver.send({ type: "draft.changed", text }),
|
||||
onCancel: () => driver.send({ type: "draft.cancelled" }),
|
||||
onSubmit: (text) => driver.send({ type: "draft.submitted", text }),
|
||||
cancelLabel: i18n.t("ui.lineComment.cancel"),
|
||||
submitLabel: i18n.t("ui.lineComment.submit"),
|
||||
mention: props.mention,
|
||||
}),
|
||||
})
|
||||
|
||||
const renderGutterUtility = createLineCommentGutterRenderer({
|
||||
label: props.label,
|
||||
getSelectedRange: () => {
|
||||
if (note.opened()) return null
|
||||
|
||||
return 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) {
|
||||
note.cancelDraft()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
note.openDraft(range)
|
||||
}
|
||||
|
||||
const onLineNumberSelectionEnd = (range: SelectedLineRange | null) => {
|
||||
if (!range) return
|
||||
note.openDraft(range)
|
||||
}
|
||||
|
||||
return {
|
||||
note,
|
||||
mode: driver.mode,
|
||||
selected: () => driver.view().selected,
|
||||
focusComment: driver.focusComment,
|
||||
clearPassive: driver.clearPassive,
|
||||
reset: driver.reset,
|
||||
annotations,
|
||||
renderAnnotation,
|
||||
renderGutterUtility,
|
||||
onLineSelected,
|
||||
onLineSelectionEnd,
|
||||
onLineNumberSelectionEnd,
|
||||
renderGutterUtility: createLineCommentGutterRenderer({
|
||||
label: props.label,
|
||||
getSelectedRange: () => driver.view().gutterSelected,
|
||||
onOpenDraft: (range) => driver.send({ type: "draft.opened", range }),
|
||||
}),
|
||||
onLineSelected: (range: SelectedLineRange | null) => driver.send({ type: "line.selected", range }),
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => driver.send({ type: "line.selection-ended", range }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { useFileComponent } from "@opencode/ui/context/file"
|
||||
import { useI18n } from "@opencode/ui/context/i18n"
|
||||
import { mediaKindFromPath } from "../../pierre/media"
|
||||
import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge"
|
||||
import { previewSelectedLines } from "../../pierre/selection-bridge"
|
||||
import type { FileDiffInfo } from "@opencode/client/promise"
|
||||
import type { PresentationFileContent, PresentationFileDiff } from "../../file-presentation"
|
||||
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { normalize, text, type ViewDiff } from "../../components/session-diff"
|
||||
import type {
|
||||
@@ -107,12 +106,6 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
let focusToken = 0
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
selection: null as SelectedLineRange | null,
|
||||
commenting: null as SelectedLineRange | null,
|
||||
opened: null as string | null,
|
||||
})
|
||||
|
||||
const view = createMemo(() => ({
|
||||
...normalize(props.diff),
|
||||
preloaded: "preloaded" in props.diff ? props.diff.preloaded : undefined,
|
||||
@@ -128,14 +121,6 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop
|
||||
comments,
|
||||
label: i18n.t("ui.lineComment.submit"),
|
||||
draftKey: () => props.file,
|
||||
state: {
|
||||
opened: () => store.opened,
|
||||
setOpened: (id) => setStore("opened", id),
|
||||
selected: () => store.selection,
|
||||
setSelected: (range) => setStore("selection", range),
|
||||
commenting: () => store.commenting,
|
||||
setCommenting: (range) => setStore("commenting", range),
|
||||
},
|
||||
getSide: selectionSide,
|
||||
onSubmit: ({ comment, selection }) => {
|
||||
props.onLineComment?.({
|
||||
@@ -193,11 +178,9 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
setStore("opened", focus.id)
|
||||
|
||||
const comment = (props.comments ?? []).find((item) => item.file === focus.file && item.id === focus.id)
|
||||
|
||||
if (comment) setStore("selection", cloneSelectedLineRange(comment.selection))
|
||||
if (comment) commentsUi.focusComment(comment.id, comment.selection)
|
||||
|
||||
// The diff renders asynchronously, so poll for the comment anchor before
|
||||
// scrolling; clear the focus once handled so revisiting the file does not
|
||||
@@ -252,11 +235,10 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop
|
||||
if (!lineCommentsEnabled()) return
|
||||
commentsUi.onLineSelectionEnd(range)
|
||||
}}
|
||||
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
|
||||
annotations={commentsUi.annotations()}
|
||||
renderAnnotation={commentsUi.renderAnnotation}
|
||||
renderGutterUtility={lineCommentsEnabled() ? commentsUi.renderGutterUtility : undefined}
|
||||
selectedLines={store.selection}
|
||||
selectedLines={commentsUi.selected()}
|
||||
commentedLines={commentedLines()}
|
||||
media={{
|
||||
mode: "auto",
|
||||
|
||||
Reference in New Issue
Block a user