feat(app): v2 review panel overhaul (#31882)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
parent
fbb95a6ee3
commit
7d2618637f
35 changed files with 3438 additions and 214 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
import { createEffect, createMemo, createResource, Match, on, Show, Switch, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, Match, on, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import {
|
||||
dataUrlFromMediaValue,
|
||||
|
|
@ -30,6 +31,13 @@ function mediaValue(cfg: FileMediaOptions, mode: "image" | "audio") {
|
|||
|
||||
export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX.Element }) {
|
||||
const i18n = useI18n()
|
||||
const [remote, setRemote] = createStore<{
|
||||
key?: string
|
||||
loading?: boolean
|
||||
error?: boolean
|
||||
src?: string
|
||||
mime?: string
|
||||
}>({})
|
||||
const cfg = () => props.media
|
||||
const kind = createMemo(() => {
|
||||
const media = cfg()
|
||||
|
|
@ -81,50 +89,66 @@ export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX
|
|||
}
|
||||
})
|
||||
|
||||
const [loaded] = createResource(request, async (input) => {
|
||||
return input.readFile(input.path).then(
|
||||
createEffect(() => {
|
||||
const input = request()
|
||||
if (!input) {
|
||||
setRemote({ key: undefined, loading: false, error: false, src: undefined, mime: undefined })
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
// Keep the previous media visible while re-reading the same file (e.g. a vcs
|
||||
// diff refresh); only a key change resets to the loading placeholder.
|
||||
if (untrack(() => remote.key) === input.key) setRemote({ loading: true, error: false })
|
||||
else setRemote({ key: input.key, loading: true, error: false, src: undefined, mime: undefined })
|
||||
void input.readFile(input.path).then(
|
||||
(result) => {
|
||||
if (!active) return
|
||||
const src = dataUrlFromMediaValue(result as any, input.kind)
|
||||
if (!src) {
|
||||
input.onError?.({ kind: input.kind })
|
||||
return { key: input.key, error: true as const }
|
||||
setRemote({ key: input.key, loading: false, error: true, src: undefined, mime: undefined })
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
setRemote({
|
||||
key: input.key,
|
||||
loading: false,
|
||||
error: false,
|
||||
src,
|
||||
mime: input.kind === "audio" ? normalizeMimeType(result?.mimeType) : undefined,
|
||||
}
|
||||
})
|
||||
},
|
||||
() => {
|
||||
if (!active) return
|
||||
input.onError?.({ kind: input.kind })
|
||||
return { key: input.key, error: true as const }
|
||||
setRemote({ key: input.key, loading: false, error: true, src: undefined, mime: undefined })
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const remote = createMemo(() => {
|
||||
const input = request()
|
||||
const value = loaded()
|
||||
if (!input || !value || value.key !== input.key) return
|
||||
return value
|
||||
onCleanup(() => {
|
||||
active = false
|
||||
})
|
||||
})
|
||||
|
||||
const src = createMemo(() => {
|
||||
const value = remote()
|
||||
return direct() ?? (value && "src" in value ? value.src : undefined)
|
||||
const input = request()
|
||||
if (!input || remote.key !== input.key || remote.error) return direct()
|
||||
return direct() ?? remote.src
|
||||
})
|
||||
const status = createMemo(() => {
|
||||
if (direct()) return "ready" as const
|
||||
if (!request()) return "idle" as const
|
||||
if (loaded.loading) return "loading" as const
|
||||
if (remote()?.error) return "error" as const
|
||||
const input = request()
|
||||
if (!input) return "idle" as const
|
||||
if (remote.key !== input.key || remote.loading) return "loading" as const
|
||||
if (remote.error) return "error" as const
|
||||
if (src()) return "ready" as const
|
||||
return "idle" as const
|
||||
})
|
||||
const audioMime = createMemo(() => {
|
||||
const value = remote()
|
||||
return value && "mime" in value ? value.mime : undefined
|
||||
const input = request()
|
||||
if (!input || remote.key !== input.key) return
|
||||
return remote.mime
|
||||
})
|
||||
|
||||
const svgSource = createMemo(() => {
|
||||
|
|
|
|||
|
|
@ -456,11 +456,18 @@ function useAnnotationRerender<A>(opts: {
|
|||
current: () => AnnotationTarget<A> | undefined
|
||||
annotations: () => A[]
|
||||
}) {
|
||||
const applied = new WeakSet<AnnotationTarget<A>>()
|
||||
createEffect(() => {
|
||||
opts.viewer.rendered()
|
||||
const active = opts.current()
|
||||
if (!active) return
|
||||
active.setLineAnnotations(opts.annotations())
|
||||
const annotations = opts.annotations()
|
||||
// renderViewer always draws with empty annotations, so skip the extra rerender
|
||||
// when this instance has nothing applied and nothing to apply.
|
||||
if (annotations.length === 0 && !applied.has(active)) return
|
||||
if (annotations.length === 0) applied.delete(active)
|
||||
else applied.add(active)
|
||||
active.setLineAnnotations(annotations)
|
||||
active.rerender()
|
||||
requestAnimationFrame(() => opts.viewer.find.refresh({ reset: true }))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ type HoverCommentLine = {
|
|||
side?: "additions" | "deletions"
|
||||
}
|
||||
|
||||
type LineCommentStateProps<T> = {
|
||||
export type LineCommentStateProps<T> = {
|
||||
opened: Accessor<T | null>
|
||||
setOpened: (id: T | null) => void
|
||||
selected: Accessor<SelectedLineRange | null>
|
||||
|
|
@ -45,7 +45,7 @@ type LineCommentStateProps<T> = {
|
|||
hoverSelected?: (range: SelectedLineRange) => void
|
||||
}
|
||||
|
||||
type LineCommentShape = {
|
||||
export type LineCommentShape = {
|
||||
id: string
|
||||
selection: SelectedLineRange
|
||||
comment: string
|
||||
|
|
@ -95,9 +95,14 @@ type DraftProps = {
|
|||
submitLabel?: string
|
||||
}
|
||||
|
||||
export function createLineCommentAnnotationRenderer<T>(props: {
|
||||
renderComment: (comment: T) => CommentProps
|
||||
renderDraft: (range: SelectedLineRange) => DraftProps
|
||||
// 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,
|
||||
|
|
@ -123,37 +128,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
|
|||
if (next.kind !== "comment") return props.renderComment(active.comment)
|
||||
return props.renderComment(next.comment)
|
||||
})
|
||||
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>
|
||||
)
|
||||
return props.commentElement(view)
|
||||
}
|
||||
|
||||
const view = createMemo(() => {
|
||||
|
|
@ -161,18 +136,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
|
|||
if (next.kind !== "draft") return props.renderDraft(active.range)
|
||||
return props.renderDraft(next.range)
|
||||
})
|
||||
return (
|
||||
<LineCommentEditor
|
||||
inline
|
||||
value={view().value}
|
||||
selection={view().selection}
|
||||
onInput={view().onInput}
|
||||
onCancel={view().onCancel}
|
||||
onSubmit={view().onSubmit}
|
||||
onPopoverFocusOut={view().onPopoverFocusOut}
|
||||
mention={view().mention}
|
||||
/>
|
||||
)
|
||||
return props.draftElement(view)
|
||||
}, host)
|
||||
|
||||
const node = { host, dispose, setMeta: setCurrent }
|
||||
|
|
@ -205,6 +169,55 @@ export function createLineCommentAnnotationRenderer<T>(props: {
|
|||
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: "",
|
||||
|
|
@ -314,7 +327,9 @@ export function createLineCommentController<T extends LineCommentShape>(
|
|||
): {
|
||||
note: ReturnType<typeof createLineCommentState<string>>
|
||||
annotations: Accessor<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>
|
||||
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
|
||||
renderAnnotation: ReturnType<
|
||||
typeof createManagedLineCommentAnnotationRenderer<T, CommentProps, DraftProps>
|
||||
>["renderAnnotation"]
|
||||
renderGutterUtility: ReturnType<typeof createLineCommentGutterRenderer>
|
||||
onLineSelected: (range: SelectedLineRange | null) => void
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
|
|
@ -324,7 +339,9 @@ export function createLineCommentController<T extends LineCommentShape>(
|
|||
): {
|
||||
note: ReturnType<typeof createLineCommentState<string>>
|
||||
annotations: Accessor<LineCommentAnnotation<T>[]>
|
||||
renderAnnotation: ReturnType<typeof createManagedLineCommentAnnotationRenderer<T>>["renderAnnotation"]
|
||||
renderAnnotation: ReturnType<
|
||||
typeof createManagedLineCommentAnnotationRenderer<T, CommentProps, DraftProps>
|
||||
>["renderAnnotation"]
|
||||
renderGutterUtility: ReturnType<typeof createLineCommentGutterRenderer>
|
||||
onLineSelected: (range: SelectedLineRange | null) => void
|
||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||
|
|
@ -353,8 +370,10 @@ export function createLineCommentController<T extends LineCommentShape>(
|
|||
draftKey: props.draftKey,
|
||||
})
|
||||
|
||||
const { renderAnnotation } = createManagedLineCommentAnnotationRenderer<T>({
|
||||
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 = () => {
|
||||
|
|
@ -471,77 +490,111 @@ export function createLineCommentAnnotations<T>(
|
|||
const line = (range: SelectedLineRange) => Math.max(range.start, range.end)
|
||||
|
||||
if ("getSide" in props) {
|
||||
return createMemo<DiffLineAnnotation<LineCommentAnnotationMeta<T>>[]>(() => {
|
||||
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)
|
||||
return {
|
||||
side: props.getSide(range),
|
||||
const entry: LineCommentAnnotation<T> = {
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "comment",
|
||||
key: `comment:${props.getCommentId(comment)}`,
|
||||
comment,
|
||||
} satisfies LineCommentAnnotationMeta<T>,
|
||||
},
|
||||
}
|
||||
|
||||
return entry
|
||||
})
|
||||
|
||||
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>,
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return createMemo<LineCommentAnnotation<T>[]>(() => {
|
||||
const list = props.comments().map((comment) => {
|
||||
const range = props.getCommentSelection(comment)
|
||||
const entry: LineCommentAnnotation<T> = {
|
||||
const draft: LineCommentAnnotation<T> = {
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "comment",
|
||||
key: `comment:${props.getCommentId(comment)}`,
|
||||
comment,
|
||||
kind: "draft",
|
||||
key: `draft:${props.draftKey()}`,
|
||||
range,
|
||||
},
|
||||
}
|
||||
|
||||
return entry
|
||||
})
|
||||
return [...list, draft]
|
||||
},
|
||||
[],
|
||||
{ equals: sameAnnotationLists },
|
||||
)
|
||||
}
|
||||
|
||||
const range = props.draftRange()
|
||||
if (!range) return list
|
||||
type AnnotationListItem = {
|
||||
lineNumber: number
|
||||
side?: unknown
|
||||
metadata: { kind: string; key: string; comment?: unknown; range?: unknown }
|
||||
}
|
||||
|
||||
const draft: LineCommentAnnotation<T> = {
|
||||
lineNumber: line(range),
|
||||
metadata: {
|
||||
kind: "draft",
|
||||
key: `draft:${props.draftKey()}`,
|
||||
range,
|
||||
},
|
||||
}
|
||||
|
||||
return [...list, draft]
|
||||
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>(props: {
|
||||
export function createManagedLineCommentAnnotationRenderer<T, C, D>(props: {
|
||||
annotations: Accessor<LineCommentAnnotation<T>[]>
|
||||
renderComment: (comment: T) => CommentProps
|
||||
renderDraft: (range: SelectedLineRange) => DraftProps
|
||||
renderComment: (comment: T) => C
|
||||
renderDraft: (range: SelectedLineRange) => D
|
||||
commentElement: (view: Accessor<C>) => JSX.Element
|
||||
draftElement: (view: Accessor<D>) => JSX.Element
|
||||
}) {
|
||||
const renderer = createLineCommentAnnotationRenderer<T>({
|
||||
const renderer = createLineCommentAnnotationRenderer<T, C, D>({
|
||||
renderComment: props.renderComment,
|
||||
renderDraft: props.renderDraft,
|
||||
commentElement: props.commentElement,
|
||||
draftElement: props.draftElement,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue