|
|
@@ -1,23 +1,30 @@
|
|
|
import { Accordion } from "./accordion"
|
|
|
import { Button } from "./button"
|
|
|
+import { DropdownMenu } from "./dropdown-menu"
|
|
|
import { RadioGroup } from "./radio-group"
|
|
|
import { DiffChanges } from "./diff-changes"
|
|
|
import { FileIcon } from "./file-icon"
|
|
|
import { Icon } from "./icon"
|
|
|
-import { LineComment, LineCommentEditor } from "./line-comment"
|
|
|
+import { IconButton } from "./icon-button"
|
|
|
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
|
|
import { Tooltip } from "./tooltip"
|
|
|
import { ScrollView } from "./scroll-view"
|
|
|
-import { useDiffComponent } from "../context/diff"
|
|
|
+import { FileSearchBar } from "./file-search"
|
|
|
+import type { FileSearchHandle } from "./file"
|
|
|
+import { buildSessionSearchHits, stepSessionSearchIndex, type SessionSearchHit } from "./session-review-search"
|
|
|
+import { useFileComponent } from "../context/file"
|
|
|
import { useI18n } from "../context/i18n"
|
|
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
|
|
import { checksum } from "@opencode-ai/util/encode"
|
|
|
-import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
|
|
|
+import { createEffect, createMemo, createSignal, For, Match, onCleanup, Show, Switch, type JSX } from "solid-js"
|
|
|
import { createStore } from "solid-js/store"
|
|
|
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
|
|
|
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 { createLineCommentController } from "./line-comment-annotations"
|
|
|
|
|
|
const MAX_DIFF_CHANGED_LINES = 500
|
|
|
|
|
|
@@ -37,6 +44,22 @@ export type SessionReviewLineComment = {
|
|
|
preview?: string
|
|
|
}
|
|
|
|
|
|
+export type SessionReviewCommentUpdate = SessionReviewLineComment & {
|
|
|
+ id: string
|
|
|
+}
|
|
|
+
|
|
|
+export type SessionReviewCommentDelete = {
|
|
|
+ id: string
|
|
|
+ file: string
|
|
|
+}
|
|
|
+
|
|
|
+export type SessionReviewCommentActions = {
|
|
|
+ moreLabel: string
|
|
|
+ editLabel: string
|
|
|
+ deleteLabel: string
|
|
|
+ saveLabel: string
|
|
|
+}
|
|
|
+
|
|
|
export type SessionReviewFocus = { file: string; id: string }
|
|
|
|
|
|
export interface SessionReviewProps {
|
|
|
@@ -47,6 +70,9 @@ export interface SessionReviewProps {
|
|
|
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
|
|
|
onDiffRendered?: () => void
|
|
|
onLineComment?: (comment: SessionReviewLineComment) => void
|
|
|
+ onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
|
|
|
+ onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
|
|
|
+ lineCommentActions?: SessionReviewCommentActions
|
|
|
comments?: SessionReviewComment[]
|
|
|
focusedComment?: SessionReviewFocus | null
|
|
|
onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void
|
|
|
@@ -64,66 +90,35 @@ export interface SessionReviewProps {
|
|
|
readFile?: (path: string) => Promise<FileContent | undefined>
|
|
|
}
|
|
|
|
|
|
-const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
|
|
|
-const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"])
|
|
|
-
|
|
|
-function normalizeMimeType(type: string | undefined): string | undefined {
|
|
|
- if (!type) return
|
|
|
-
|
|
|
- const mime = type.split(";", 1)[0]?.trim().toLowerCase()
|
|
|
- if (!mime) return
|
|
|
-
|
|
|
- if (mime === "audio/x-aac") return "audio/aac"
|
|
|
- if (mime === "audio/x-m4a") return "audio/mp4"
|
|
|
-
|
|
|
- return mime
|
|
|
-}
|
|
|
-
|
|
|
-function getExtension(file: string): string {
|
|
|
- const idx = file.lastIndexOf(".")
|
|
|
- if (idx === -1) return ""
|
|
|
- return file.slice(idx + 1).toLowerCase()
|
|
|
-}
|
|
|
-
|
|
|
-function isImageFile(file: string): boolean {
|
|
|
- return imageExtensions.has(getExtension(file))
|
|
|
-}
|
|
|
-
|
|
|
-function isAudioFile(file: string): boolean {
|
|
|
- return audioExtensions.has(getExtension(file))
|
|
|
-}
|
|
|
-
|
|
|
-function dataUrl(content: FileContent | undefined): string | undefined {
|
|
|
- if (!content) return
|
|
|
- if (content.encoding !== "base64") return
|
|
|
- const mime = normalizeMimeType(content.mimeType)
|
|
|
- if (!mime) return
|
|
|
- if (!mime.startsWith("image/") && !mime.startsWith("audio/")) return
|
|
|
- return `data:${mime};base64,${content.content}`
|
|
|
-}
|
|
|
-
|
|
|
-function dataUrlFromValue(value: unknown): string | undefined {
|
|
|
- if (typeof value === "string") {
|
|
|
- if (value.startsWith("data:image/")) return value
|
|
|
- if (value.startsWith("data:audio/x-aac;")) return value.replace("data:audio/x-aac;", "data:audio/aac;")
|
|
|
- if (value.startsWith("data:audio/x-m4a;")) return value.replace("data:audio/x-m4a;", "data:audio/mp4;")
|
|
|
- if (value.startsWith("data:audio/")) return value
|
|
|
- return
|
|
|
- }
|
|
|
- if (!value || typeof value !== "object") return
|
|
|
-
|
|
|
- const content = (value as { content?: unknown }).content
|
|
|
- const encoding = (value as { encoding?: unknown }).encoding
|
|
|
- const mimeType = (value as { mimeType?: unknown }).mimeType
|
|
|
-
|
|
|
- if (typeof content !== "string") return
|
|
|
- if (encoding !== "base64") return
|
|
|
- if (typeof mimeType !== "string") return
|
|
|
- const mime = normalizeMimeType(mimeType)
|
|
|
- if (!mime) return
|
|
|
- if (!mime.startsWith("image/") && !mime.startsWith("audio/")) return
|
|
|
-
|
|
|
- return `data:${mime};base64,${content}`
|
|
|
+function ReviewCommentMenu(props: {
|
|
|
+ labels: SessionReviewCommentActions
|
|
|
+ onEdit: VoidFunction
|
|
|
+ onDelete: VoidFunction
|
|
|
+}) {
|
|
|
+ return (
|
|
|
+ <div onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
|
|
|
+ <DropdownMenu gutter={4} placement="bottom-end">
|
|
|
+ <DropdownMenu.Trigger
|
|
|
+ as={IconButton}
|
|
|
+ icon="dot-grid"
|
|
|
+ variant="ghost"
|
|
|
+ size="small"
|
|
|
+ class="size-6 rounded-md"
|
|
|
+ aria-label={props.labels.moreLabel}
|
|
|
+ />
|
|
|
+ <DropdownMenu.Portal>
|
|
|
+ <DropdownMenu.Content>
|
|
|
+ <DropdownMenu.Item onSelect={props.onEdit}>
|
|
|
+ <DropdownMenu.ItemLabel>{props.labels.editLabel}</DropdownMenu.ItemLabel>
|
|
|
+ </DropdownMenu.Item>
|
|
|
+ <DropdownMenu.Item onSelect={props.onDelete}>
|
|
|
+ <DropdownMenu.ItemLabel>{props.labels.deleteLabel}</DropdownMenu.ItemLabel>
|
|
|
+ </DropdownMenu.Item>
|
|
|
+ </DropdownMenu.Content>
|
|
|
+ </DropdownMenu.Portal>
|
|
|
+ </DropdownMenu>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
}
|
|
|
|
|
|
function diffId(file: string): string | undefined {
|
|
|
@@ -137,62 +132,37 @@ type SessionReviewSelection = {
|
|
|
range: SelectedLineRange
|
|
|
}
|
|
|
|
|
|
-function findSide(element: HTMLElement): "additions" | "deletions" | undefined {
|
|
|
- const typed = element.closest("[data-line-type]")
|
|
|
- if (typed instanceof HTMLElement) {
|
|
|
- const type = typed.dataset.lineType
|
|
|
- if (type === "change-deletion") return "deletions"
|
|
|
- if (type === "change-addition" || type === "change-additions") return "additions"
|
|
|
- }
|
|
|
-
|
|
|
- const code = element.closest("[data-code]")
|
|
|
- if (!(code instanceof HTMLElement)) return
|
|
|
- return code.hasAttribute("data-deletions") ? "deletions" : "additions"
|
|
|
-}
|
|
|
-
|
|
|
-function findMarker(root: ShadowRoot, range: SelectedLineRange) {
|
|
|
- const marker = (line: number, side?: "additions" | "deletions") => {
|
|
|
- const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
|
|
- (node): node is HTMLElement => node instanceof HTMLElement,
|
|
|
- )
|
|
|
- if (nodes.length === 0) return
|
|
|
- if (!side) return nodes[0]
|
|
|
- const match = nodes.find((node) => findSide(node) === side)
|
|
|
- return match ?? nodes[0]
|
|
|
- }
|
|
|
-
|
|
|
- const a = marker(range.start, range.side)
|
|
|
- const b = marker(range.end, range.endSide ?? range.side)
|
|
|
- if (!a) return b
|
|
|
- if (!b) return a
|
|
|
- return a.getBoundingClientRect().top > b.getBoundingClientRect().top ? a : b
|
|
|
-}
|
|
|
-
|
|
|
-function markerTop(wrapper: HTMLElement, marker: HTMLElement) {
|
|
|
- const wrapperRect = wrapper.getBoundingClientRect()
|
|
|
- const rect = marker.getBoundingClientRect()
|
|
|
- return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
|
|
|
-}
|
|
|
-
|
|
|
export const SessionReview = (props: SessionReviewProps) => {
|
|
|
let scroll: HTMLDivElement | undefined
|
|
|
+ let searchInput: HTMLInputElement | undefined
|
|
|
let focusToken = 0
|
|
|
+ let revealToken = 0
|
|
|
+ let highlightedFile: string | undefined
|
|
|
const i18n = useI18n()
|
|
|
- const diffComponent = useDiffComponent()
|
|
|
+ const fileComponent = useFileComponent()
|
|
|
const anchors = new Map<string, HTMLElement>()
|
|
|
- const [store, setStore] = createStore({
|
|
|
+ const searchHandles = new Map<string, FileSearchHandle>()
|
|
|
+ const readyFiles = new Set<string>()
|
|
|
+ const [store, setStore] = createStore<{ open: string[]; force: Record<string, boolean> }>({
|
|
|
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
|
|
|
+ force: {},
|
|
|
})
|
|
|
|
|
|
const [selection, setSelection] = createSignal<SessionReviewSelection | null>(null)
|
|
|
const [commenting, setCommenting] = createSignal<SessionReviewSelection | null>(null)
|
|
|
const [opened, setOpened] = createSignal<SessionReviewFocus | null>(null)
|
|
|
+ const [searchOpen, setSearchOpen] = createSignal(false)
|
|
|
+ const [searchQuery, setSearchQuery] = createSignal("")
|
|
|
+ const [searchActive, setSearchActive] = createSignal(0)
|
|
|
+ const [searchPos, setSearchPos] = createSignal({ top: 8, right: 8 })
|
|
|
|
|
|
const open = () => props.open ?? store.open
|
|
|
const files = createMemo(() => props.diffs.map((d) => d.file))
|
|
|
const diffs = createMemo(() => new Map(props.diffs.map((d) => [d.file, d] as const)))
|
|
|
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
|
|
const hasDiffs = () => files().length > 0
|
|
|
+ const searchValue = createMemo(() => searchQuery().trim())
|
|
|
+ const searchExpanded = createMemo(() => searchValue().length > 0)
|
|
|
|
|
|
const handleChange = (open: string[]) => {
|
|
|
props.onOpenChange?.(open)
|
|
|
@@ -205,13 +175,259 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
handleChange(next)
|
|
|
}
|
|
|
|
|
|
- const selectionLabel = (range: SelectedLineRange) => {
|
|
|
- const start = Math.min(range.start, range.end)
|
|
|
- const end = Math.max(range.start, range.end)
|
|
|
- if (start === end) return `line ${start}`
|
|
|
- return `lines ${start}-${end}`
|
|
|
+ const clearViewerSearch = () => {
|
|
|
+ for (const handle of searchHandles.values()) handle.clear()
|
|
|
+ highlightedFile = undefined
|
|
|
+ }
|
|
|
+
|
|
|
+ const focusSearch = () => {
|
|
|
+ if (!hasDiffs()) return
|
|
|
+ setSearchOpen(true)
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ searchInput?.focus()
|
|
|
+ searchInput?.select()
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const closeSearch = () => {
|
|
|
+ revealToken++
|
|
|
+ setSearchOpen(false)
|
|
|
+ setSearchQuery("")
|
|
|
+ setSearchActive(0)
|
|
|
+ clearViewerSearch()
|
|
|
}
|
|
|
|
|
|
+ const positionSearchBar = () => {
|
|
|
+ if (typeof window === "undefined") return
|
|
|
+ if (!scroll) return
|
|
|
+
|
|
|
+ const rect = scroll.getBoundingClientRect()
|
|
|
+ const title = parseFloat(getComputedStyle(scroll).getPropertyValue("--session-title-height"))
|
|
|
+ const header = Number.isNaN(title) ? 0 : title
|
|
|
+ setSearchPos({
|
|
|
+ top: Math.round(rect.top) + header - 4,
|
|
|
+ right: Math.round(window.innerWidth - rect.right) + 8,
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const searchHits = createMemo(() =>
|
|
|
+ buildSessionSearchHits({
|
|
|
+ query: searchQuery(),
|
|
|
+ files: props.diffs.flatMap((diff) => {
|
|
|
+ if (mediaKindFromPath(diff.file)) return []
|
|
|
+
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ file: diff.file,
|
|
|
+ before: typeof diff.before === "string" ? diff.before : undefined,
|
|
|
+ after: typeof diff.after === "string" ? diff.after : undefined,
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ }),
|
|
|
+ }),
|
|
|
+ )
|
|
|
+
|
|
|
+ const waitForViewer = (file: string, token: number) =>
|
|
|
+ new Promise<FileSearchHandle | undefined>((resolve) => {
|
|
|
+ let attempt = 0
|
|
|
+
|
|
|
+ const tick = () => {
|
|
|
+ if (token !== revealToken) {
|
|
|
+ resolve(undefined)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const handle = searchHandles.get(file)
|
|
|
+ if (handle && readyFiles.has(file)) {
|
|
|
+ resolve(handle)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (attempt >= 180) {
|
|
|
+ resolve(undefined)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ attempt++
|
|
|
+ requestAnimationFrame(tick)
|
|
|
+ }
|
|
|
+
|
|
|
+ tick()
|
|
|
+ })
|
|
|
+
|
|
|
+ const waitForFrames = (count: number, token: number) =>
|
|
|
+ new Promise<boolean>((resolve) => {
|
|
|
+ const tick = (left: number) => {
|
|
|
+ if (token !== revealToken) {
|
|
|
+ resolve(false)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (left <= 0) {
|
|
|
+ resolve(true)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ requestAnimationFrame(() => tick(left - 1))
|
|
|
+ }
|
|
|
+
|
|
|
+ tick(count)
|
|
|
+ })
|
|
|
+
|
|
|
+ const revealSearchHit = async (token: number, hit: SessionSearchHit, query: string) => {
|
|
|
+ const diff = diffs().get(hit.file)
|
|
|
+ if (!diff) return
|
|
|
+
|
|
|
+ if (!open().includes(hit.file)) {
|
|
|
+ handleChange([...open(), hit.file])
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!mediaKindFromPath(hit.file) && diff.additions + diff.deletions > MAX_DIFF_CHANGED_LINES) {
|
|
|
+ setStore("force", hit.file, true)
|
|
|
+ }
|
|
|
+
|
|
|
+ const handle = await waitForViewer(hit.file, token)
|
|
|
+ if (!handle || token !== revealToken) return
|
|
|
+ if (searchValue() !== query) return
|
|
|
+ if (!(await waitForFrames(2, token))) return
|
|
|
+
|
|
|
+ if (highlightedFile && highlightedFile !== hit.file) {
|
|
|
+ searchHandles.get(highlightedFile)?.clear()
|
|
|
+ highlightedFile = undefined
|
|
|
+ }
|
|
|
+
|
|
|
+ anchors.get(hit.file)?.scrollIntoView({ block: "nearest" })
|
|
|
+
|
|
|
+ let done = false
|
|
|
+ for (let i = 0; i < 4; i++) {
|
|
|
+ if (token !== revealToken) return
|
|
|
+ if (searchValue() !== query) return
|
|
|
+
|
|
|
+ handle.setQuery(query)
|
|
|
+ if (handle.reveal(hit)) {
|
|
|
+ done = true
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ const expanded = handle.expand(hit)
|
|
|
+ handle.refresh()
|
|
|
+ if (!(await waitForFrames(expanded ? 2 : 1, token))) return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!done) return
|
|
|
+
|
|
|
+ if (!(await waitForFrames(1, token))) return
|
|
|
+ handle.reveal(hit)
|
|
|
+
|
|
|
+ highlightedFile = hit.file
|
|
|
+ }
|
|
|
+
|
|
|
+ const navigateSearch = (dir: 1 | -1) => {
|
|
|
+ const total = searchHits().length
|
|
|
+ if (total <= 0) return
|
|
|
+ setSearchActive((value) => stepSessionSearchIndex(total, value, dir))
|
|
|
+ }
|
|
|
+
|
|
|
+ const inReview = (node: unknown, path?: unknown[]) => {
|
|
|
+ if (node === searchInput) return true
|
|
|
+ if (path?.some((item) => item === scroll || item === searchInput)) return true
|
|
|
+ if (path?.some((item) => item instanceof HTMLElement && item.dataset.component === "session-review")) {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ if (!(node instanceof Node)) return false
|
|
|
+ if (searchInput?.contains(node)) return true
|
|
|
+ if (node instanceof HTMLElement && node.closest("[data-component='session-review']")) return true
|
|
|
+ if (!scroll) return false
|
|
|
+ return scroll.contains(node)
|
|
|
+ }
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ if (typeof window === "undefined") return
|
|
|
+
|
|
|
+ const onKeyDown = (event: KeyboardEvent) => {
|
|
|
+ if (event.defaultPrevented) return
|
|
|
+
|
|
|
+ const mod = event.metaKey || event.ctrlKey
|
|
|
+ if (!mod) return
|
|
|
+
|
|
|
+ const key = event.key.toLowerCase()
|
|
|
+ if (key !== "f" && key !== "g") return
|
|
|
+
|
|
|
+ if (key === "f") {
|
|
|
+ if (!hasDiffs()) return
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ focusSearch()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const path = typeof event.composedPath === "function" ? event.composedPath() : undefined
|
|
|
+ if (!inReview(event.target, path) && !inReview(document.activeElement, path)) return
|
|
|
+ if (!searchOpen()) return
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ navigateSearch(event.shiftKey ? -1 : 1)
|
|
|
+ }
|
|
|
+
|
|
|
+ window.addEventListener("keydown", onKeyDown, { capture: true })
|
|
|
+ onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true }))
|
|
|
+ })
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ diffStyle()
|
|
|
+ searchExpanded()
|
|
|
+ readyFiles.clear()
|
|
|
+ })
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ if (!searchOpen()) return
|
|
|
+ if (!scroll) return
|
|
|
+
|
|
|
+ const root = scroll
|
|
|
+
|
|
|
+ requestAnimationFrame(positionSearchBar)
|
|
|
+ window.addEventListener("resize", positionSearchBar, { passive: true })
|
|
|
+ const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(positionSearchBar)
|
|
|
+ observer?.observe(root)
|
|
|
+
|
|
|
+ onCleanup(() => {
|
|
|
+ window.removeEventListener("resize", positionSearchBar)
|
|
|
+ observer?.disconnect()
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ const total = searchHits().length
|
|
|
+ if (total === 0) {
|
|
|
+ if (searchActive() !== 0) setSearchActive(0)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (searchActive() >= total) setSearchActive(total - 1)
|
|
|
+ })
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ diffStyle()
|
|
|
+ const query = searchValue()
|
|
|
+ const hits = searchHits()
|
|
|
+ const token = ++revealToken
|
|
|
+ if (!query || hits.length === 0) {
|
|
|
+ clearViewerSearch()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const hit = hits[Math.min(searchActive(), hits.length - 1)]
|
|
|
+ if (!hit) return
|
|
|
+ void revealSearchHit(token, hit, query)
|
|
|
+ })
|
|
|
+
|
|
|
+ onCleanup(() => {
|
|
|
+ revealToken++
|
|
|
+ clearViewerSearch()
|
|
|
+ readyFiles.clear()
|
|
|
+ searchHandles.clear()
|
|
|
+ })
|
|
|
+
|
|
|
const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
|
|
|
|
|
|
const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => {
|
|
|
@@ -219,11 +435,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
const contents = side === "deletions" ? diff.before : diff.after
|
|
|
if (typeof contents !== "string" || contents.length === 0) return undefined
|
|
|
|
|
|
- const start = Math.max(1, Math.min(range.start, range.end))
|
|
|
- const end = Math.max(range.start, range.end)
|
|
|
- const lines = contents.split("\n").slice(start - 1, end)
|
|
|
- if (lines.length === 0) return undefined
|
|
|
- return lines.slice(0, 2).join("\n")
|
|
|
+ return previewSelectedLines(contents, range)
|
|
|
}
|
|
|
|
|
|
createEffect(() => {
|
|
|
@@ -236,7 +448,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
setOpened(focus)
|
|
|
|
|
|
const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id)
|
|
|
- if (comment) setSelection({ file: comment.file, range: comment.selection })
|
|
|
+ if (comment) setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) })
|
|
|
|
|
|
const current = open()
|
|
|
if (!current.includes(focus.file)) {
|
|
|
@@ -249,11 +461,11 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
const root = scroll
|
|
|
if (!root) return
|
|
|
|
|
|
- const anchor = root.querySelector(`[data-comment-id="${focus.id}"]`)
|
|
|
- const ready =
|
|
|
- anchor instanceof HTMLElement && anchor.style.pointerEvents !== "none" && anchor.style.opacity !== "0"
|
|
|
+ const wrapper = anchors.get(focus.file)
|
|
|
+ const anchor = wrapper?.querySelector(`[data-comment-id="${focus.id}"]`)
|
|
|
+ const ready = anchor instanceof HTMLElement
|
|
|
|
|
|
- const target = ready ? anchor : anchors.get(focus.file)
|
|
|
+ const target = ready ? anchor : wrapper
|
|
|
if (!target) {
|
|
|
if (attempt >= 120) return
|
|
|
requestAnimationFrame(() => scrollTo(attempt + 1))
|
|
|
@@ -276,6 +488,58 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
requestAnimationFrame(() => props.onFocusedCommentChange?.(null))
|
|
|
})
|
|
|
|
|
|
+ const handleReviewKeyDown = (event: KeyboardEvent) => {
|
|
|
+ if (event.defaultPrevented) return
|
|
|
+
|
|
|
+ const mod = event.metaKey || event.ctrlKey
|
|
|
+ const key = event.key.toLowerCase()
|
|
|
+ const target = event.target
|
|
|
+ if (mod && key === "f") {
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ focusSearch()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (mod && key === "g") {
|
|
|
+ if (!searchOpen()) return
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ navigateSearch(event.shiftKey ? -1 : 1)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleSearchInputKeyDown = (event: KeyboardEvent) => {
|
|
|
+ const mod = event.metaKey || event.ctrlKey
|
|
|
+ const key = event.key.toLowerCase()
|
|
|
+
|
|
|
+ if (mod && key === "g") {
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ navigateSearch(event.shiftKey ? -1 : 1)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (mod && key === "f") {
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ focusSearch()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.key === "Escape") {
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ closeSearch()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (event.key !== "Enter") return
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ navigateSearch(event.shiftKey ? -1 : 1)
|
|
|
+ }
|
|
|
+
|
|
|
return (
|
|
|
<ScrollView
|
|
|
data-component="session-review"
|
|
|
@@ -284,6 +548,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
props.scrollRef?.(el)
|
|
|
}}
|
|
|
onScroll={props.onScroll as any}
|
|
|
+ onKeyDown={handleReviewKeyDown}
|
|
|
classList={{
|
|
|
...(props.classList ?? {}),
|
|
|
[props.classes?.root ?? ""]: !!props.classes?.root,
|
|
|
@@ -321,6 +586,25 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
{props.actions}
|
|
|
</div>
|
|
|
</div>
|
|
|
+ <Show when={searchOpen()}>
|
|
|
+ <FileSearchBar
|
|
|
+ pos={searchPos}
|
|
|
+ query={searchQuery}
|
|
|
+ index={() => (searchHits().length ? Math.min(searchActive(), searchHits().length - 1) : 0)}
|
|
|
+ count={() => searchHits().length}
|
|
|
+ setInput={(el) => {
|
|
|
+ searchInput = el
|
|
|
+ }}
|
|
|
+ onInput={(value) => {
|
|
|
+ setSearchQuery(value)
|
|
|
+ setSearchActive(0)
|
|
|
+ }}
|
|
|
+ onKeyDown={(event) => handleSearchInputKeyDown(event)}
|
|
|
+ onClose={closeSearch}
|
|
|
+ onPrev={() => navigateSearch(-1)}
|
|
|
+ onNext={() => navigateSearch(1)}
|
|
|
+ />
|
|
|
+ </Show>
|
|
|
<div data-slot="session-review-container" class={props.classes?.container}>
|
|
|
<Show when={hasDiffs()} fallback={props.empty}>
|
|
|
<Accordion multiple value={open()} onChange={handleChange}>
|
|
|
@@ -332,7 +616,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
const item = () => diff()!
|
|
|
|
|
|
const expanded = createMemo(() => open().includes(file))
|
|
|
- const [force, setForce] = createSignal(false)
|
|
|
+ const force = () => !!store.force[file]
|
|
|
|
|
|
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file))
|
|
|
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
|
|
@@ -340,28 +624,18 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
const beforeText = () => (typeof item().before === "string" ? item().before : "")
|
|
|
const afterText = () => (typeof item().after === "string" ? item().after : "")
|
|
|
const changedLines = () => item().additions + item().deletions
|
|
|
+ const mediaKind = createMemo(() => mediaKindFromPath(file))
|
|
|
|
|
|
const tooLarge = createMemo(() => {
|
|
|
if (!expanded()) return false
|
|
|
if (force()) return false
|
|
|
- if (isImageFile(file)) return false
|
|
|
+ if (mediaKind()) return false
|
|
|
return changedLines() > MAX_DIFF_CHANGED_LINES
|
|
|
})
|
|
|
|
|
|
const isAdded = () => item().status === "added" || (beforeText().length === 0 && afterText().length > 0)
|
|
|
const isDeleted = () =>
|
|
|
item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
|
|
|
- const isImage = () => isImageFile(file)
|
|
|
- const isAudio = () => isAudioFile(file)
|
|
|
-
|
|
|
- const diffImageSrc = createMemo(() => dataUrlFromValue(item().after) ?? dataUrlFromValue(item().before))
|
|
|
- const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc())
|
|
|
- const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
|
|
-
|
|
|
- const diffAudioSrc = createMemo(() => dataUrlFromValue(item().after) ?? dataUrlFromValue(item().before))
|
|
|
- const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc())
|
|
|
- const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
|
|
|
- const [audioMime, setAudioMime] = createSignal<string | undefined>(undefined)
|
|
|
|
|
|
const selectedLines = createMemo(() => {
|
|
|
const current = selection()
|
|
|
@@ -375,164 +649,74 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
return current.range
|
|
|
})
|
|
|
|
|
|
- const [draft, setDraft] = createSignal("")
|
|
|
- const [positions, setPositions] = createSignal<Record<string, number>>({})
|
|
|
- const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
|
|
|
-
|
|
|
- const getRoot = () => {
|
|
|
- const el = wrapper
|
|
|
- if (!el) return
|
|
|
-
|
|
|
- const host = el.querySelector("diffs-container")
|
|
|
- if (!(host instanceof HTMLElement)) return
|
|
|
- return host.shadowRoot ?? undefined
|
|
|
- }
|
|
|
-
|
|
|
- const updateAnchors = () => {
|
|
|
- const el = wrapper
|
|
|
- if (!el) return
|
|
|
-
|
|
|
- const root = getRoot()
|
|
|
- if (!root) return
|
|
|
-
|
|
|
- const next: Record<string, number> = {}
|
|
|
- for (const item of comments()) {
|
|
|
- const marker = findMarker(root, item.selection)
|
|
|
- if (!marker) continue
|
|
|
- next[item.id] = markerTop(el, marker)
|
|
|
- }
|
|
|
- setPositions(next)
|
|
|
-
|
|
|
- const range = draftRange()
|
|
|
- if (!range) {
|
|
|
- setDraftTop(undefined)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- const marker = findMarker(root, range)
|
|
|
- if (!marker) {
|
|
|
- setDraftTop(undefined)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- setDraftTop(markerTop(el, marker))
|
|
|
- }
|
|
|
-
|
|
|
- const scheduleAnchors = () => {
|
|
|
- requestAnimationFrame(updateAnchors)
|
|
|
- }
|
|
|
-
|
|
|
- createEffect(() => {
|
|
|
- if (!isImage()) return
|
|
|
- const src = diffImageSrc()
|
|
|
- setImageSrc(src)
|
|
|
- setImageStatus("idle")
|
|
|
- })
|
|
|
-
|
|
|
- createEffect(() => {
|
|
|
- if (!isAudio()) return
|
|
|
- const src = diffAudioSrc()
|
|
|
- setAudioSrc(src)
|
|
|
- setAudioStatus("idle")
|
|
|
- setAudioMime(undefined)
|
|
|
- })
|
|
|
-
|
|
|
- createEffect(() => {
|
|
|
- comments()
|
|
|
- scheduleAnchors()
|
|
|
- })
|
|
|
-
|
|
|
- createEffect(() => {
|
|
|
- const range = draftRange()
|
|
|
- if (!range) return
|
|
|
- setDraft("")
|
|
|
- scheduleAnchors()
|
|
|
- })
|
|
|
-
|
|
|
- createEffect(() => {
|
|
|
- if (!open().includes(file)) return
|
|
|
- if (!isImage()) return
|
|
|
- if (imageSrc()) return
|
|
|
- if (imageStatus() !== "idle") return
|
|
|
- if (isDeleted()) return
|
|
|
-
|
|
|
- const reader = props.readFile
|
|
|
- if (!reader) return
|
|
|
-
|
|
|
- setImageStatus("loading")
|
|
|
- reader(file)
|
|
|
- .then((result) => {
|
|
|
- const src = dataUrl(result)
|
|
|
- if (!src) {
|
|
|
- setImageStatus("error")
|
|
|
- return
|
|
|
- }
|
|
|
- setImageSrc(src)
|
|
|
- setImageStatus("idle")
|
|
|
+ const commentsUi = createLineCommentController<SessionReviewComment>({
|
|
|
+ comments,
|
|
|
+ label: i18n.t("ui.lineComment.submit"),
|
|
|
+ draftKey: () => file,
|
|
|
+ state: {
|
|
|
+ opened: () => {
|
|
|
+ const current = opened()
|
|
|
+ if (!current || current.file !== file) return null
|
|
|
+ return current.id
|
|
|
+ },
|
|
|
+ setOpened: (id) => setOpened(id ? { file, id } : null),
|
|
|
+ selected: selectedLines,
|
|
|
+ setSelected: (range) => setSelection(range ? { file, range } : null),
|
|
|
+ commenting: draftRange,
|
|
|
+ setCommenting: (range) => setCommenting(range ? { file, range } : null),
|
|
|
+ },
|
|
|
+ getSide: selectionSide,
|
|
|
+ clearSelectionOnSelectionEndNull: false,
|
|
|
+ onSubmit: ({ comment, selection }) => {
|
|
|
+ props.onLineComment?.({
|
|
|
+ file,
|
|
|
+ selection,
|
|
|
+ comment,
|
|
|
+ preview: selectionPreview(item(), selection),
|
|
|
+ })
|
|
|
+ },
|
|
|
+ onUpdate: ({ id, comment, selection }) => {
|
|
|
+ props.onLineCommentUpdate?.({
|
|
|
+ id,
|
|
|
+ file,
|
|
|
+ selection,
|
|
|
+ comment,
|
|
|
+ preview: selectionPreview(item(), selection),
|
|
|
})
|
|
|
- .catch(() => {
|
|
|
- setImageStatus("error")
|
|
|
+ },
|
|
|
+ onDelete: (comment) => {
|
|
|
+ props.onLineCommentDelete?.({
|
|
|
+ id: comment.id,
|
|
|
+ file,
|
|
|
})
|
|
|
+ },
|
|
|
+ editSubmitLabel: props.lineCommentActions?.saveLabel,
|
|
|
+ renderCommentActions: props.lineCommentActions
|
|
|
+ ? (comment, controls) => (
|
|
|
+ <ReviewCommentMenu
|
|
|
+ labels={props.lineCommentActions!}
|
|
|
+ onEdit={controls.edit}
|
|
|
+ onDelete={controls.remove}
|
|
|
+ />
|
|
|
+ )
|
|
|
+ : undefined,
|
|
|
})
|
|
|
|
|
|
- createEffect(() => {
|
|
|
- if (!open().includes(file)) return
|
|
|
- if (!isAudio()) return
|
|
|
- if (audioSrc()) return
|
|
|
- if (audioStatus() !== "idle") return
|
|
|
-
|
|
|
- const reader = props.readFile
|
|
|
- if (!reader) return
|
|
|
-
|
|
|
- setAudioStatus("loading")
|
|
|
- reader(file)
|
|
|
- .then((result) => {
|
|
|
- const src = dataUrl(result)
|
|
|
- if (!src) {
|
|
|
- setAudioStatus("error")
|
|
|
- return
|
|
|
- }
|
|
|
- setAudioMime(normalizeMimeType(result?.mimeType))
|
|
|
- setAudioSrc(src)
|
|
|
- setAudioStatus("idle")
|
|
|
- })
|
|
|
- .catch(() => {
|
|
|
- setAudioStatus("error")
|
|
|
- })
|
|
|
+ onCleanup(() => {
|
|
|
+ anchors.delete(file)
|
|
|
+ readyFiles.delete(file)
|
|
|
+ searchHandles.delete(file)
|
|
|
+ if (highlightedFile === file) highlightedFile = undefined
|
|
|
})
|
|
|
|
|
|
const handleLineSelected = (range: SelectedLineRange | null) => {
|
|
|
if (!props.onLineComment) return
|
|
|
-
|
|
|
- if (!range) {
|
|
|
- setSelection(null)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- setSelection({ file, range })
|
|
|
+ commentsUi.onLineSelected(range)
|
|
|
}
|
|
|
|
|
|
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
|
|
|
if (!props.onLineComment) return
|
|
|
-
|
|
|
- if (!range) {
|
|
|
- setCommenting(null)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- setSelection({ file, range })
|
|
|
- setCommenting({ file, range })
|
|
|
- }
|
|
|
-
|
|
|
- const openComment = (comment: SessionReviewComment) => {
|
|
|
- setOpened({ file: comment.file, id: comment.id })
|
|
|
- setSelection({ file: comment.file, range: comment.selection })
|
|
|
- }
|
|
|
-
|
|
|
- const isCommentOpen = (comment: SessionReviewComment) => {
|
|
|
- const current = opened()
|
|
|
- if (!current) return false
|
|
|
- return current.file === comment.file && current.id === comment.id
|
|
|
+ commentsUi.onLineSelectionEnd(range)
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
@@ -585,7 +769,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
{i18n.t("ui.sessionReview.change.removed")}
|
|
|
</span>
|
|
|
</Match>
|
|
|
- <Match when={isImage()}>
|
|
|
+ <Match when={!!mediaKind()}>
|
|
|
<span data-slot="session-review-change" data-type="modified">
|
|
|
{i18n.t("ui.sessionReview.change.modified")}
|
|
|
</span>
|
|
|
@@ -607,33 +791,11 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
ref={(el) => {
|
|
|
wrapper = el
|
|
|
anchors.set(file, el)
|
|
|
- scheduleAnchors()
|
|
|
}}
|
|
|
>
|
|
|
<Show when={expanded()}>
|
|
|
<Switch>
|
|
|
- <Match when={isImage() && imageSrc()}>
|
|
|
- <div data-slot="session-review-image-container">
|
|
|
- <img data-slot="session-review-image" src={imageSrc()} alt={file} />
|
|
|
- </div>
|
|
|
- </Match>
|
|
|
- <Match when={isImage() && isDeleted()}>
|
|
|
- <div data-slot="session-review-image-container" data-removed>
|
|
|
- <span data-slot="session-review-image-placeholder">
|
|
|
- {i18n.t("ui.sessionReview.change.removed")}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- </Match>
|
|
|
- <Match when={isImage() && !imageSrc()}>
|
|
|
- <div data-slot="session-review-image-container">
|
|
|
- <span data-slot="session-review-image-placeholder">
|
|
|
- {imageStatus() === "loading"
|
|
|
- ? i18n.t("ui.sessionReview.image.loading")
|
|
|
- : i18n.t("ui.sessionReview.image.placeholder")}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
- </Match>
|
|
|
- <Match when={!isImage() && tooLarge()}>
|
|
|
+ <Match when={tooLarge()}>
|
|
|
<div data-slot="session-review-large-diff">
|
|
|
<div data-slot="session-review-large-diff-title">
|
|
|
{i18n.t("ui.sessionReview.largeDiff.title")}
|
|
|
@@ -645,26 +807,52 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
})}
|
|
|
</div>
|
|
|
<div data-slot="session-review-large-diff-actions">
|
|
|
- <Button size="normal" variant="secondary" onClick={() => setForce(true)}>
|
|
|
+ <Button
|
|
|
+ size="normal"
|
|
|
+ variant="secondary"
|
|
|
+ onClick={() => setStore("force", file, true)}
|
|
|
+ >
|
|
|
{i18n.t("ui.sessionReview.largeDiff.renderAnyway")}
|
|
|
</Button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</Match>
|
|
|
- <Match when={!isImage()}>
|
|
|
+ <Match when={true}>
|
|
|
<Dynamic
|
|
|
- component={diffComponent}
|
|
|
+ component={fileComponent}
|
|
|
+ mode="diff"
|
|
|
preloadedDiff={item().preloaded}
|
|
|
diffStyle={diffStyle()}
|
|
|
+ expansionLineCount={searchExpanded() ? Number.MAX_SAFE_INTEGER : 20}
|
|
|
onRendered={() => {
|
|
|
+ readyFiles.add(file)
|
|
|
props.onDiffRendered?.()
|
|
|
- scheduleAnchors()
|
|
|
}}
|
|
|
enableLineSelection={props.onLineComment != null}
|
|
|
+ enableHoverUtility={props.onLineComment != null}
|
|
|
onLineSelected={handleLineSelected}
|
|
|
onLineSelectionEnd={handleLineSelectionEnd}
|
|
|
+ onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
|
|
|
+ annotations={commentsUi.annotations()}
|
|
|
+ renderAnnotation={commentsUi.renderAnnotation}
|
|
|
+ renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined}
|
|
|
selectedLines={selectedLines()}
|
|
|
commentedLines={commentedLines()}
|
|
|
+ search={{
|
|
|
+ shortcuts: "disabled",
|
|
|
+ showBar: false,
|
|
|
+ disableVirtualization: searchExpanded(),
|
|
|
+ register: (handle: FileSearchHandle | null) => {
|
|
|
+ if (!handle) {
|
|
|
+ searchHandles.delete(file)
|
|
|
+ readyFiles.delete(file)
|
|
|
+ if (highlightedFile === file) highlightedFile = undefined
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ searchHandles.set(file, handle)
|
|
|
+ },
|
|
|
+ }}
|
|
|
before={{
|
|
|
name: file,
|
|
|
contents: typeof item().before === "string" ? item().before : "",
|
|
|
@@ -673,53 +861,16 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|
|
name: file,
|
|
|
contents: typeof item().after === "string" ? item().after : "",
|
|
|
}}
|
|
|
+ media={{
|
|
|
+ mode: "auto",
|
|
|
+ path: file,
|
|
|
+ before: item().before,
|
|
|
+ after: item().after,
|
|
|
+ readFile: props.readFile,
|
|
|
+ }}
|
|
|
/>
|
|
|
</Match>
|
|
|
</Switch>
|
|
|
-
|
|
|
- <For each={comments()}>
|
|
|
- {(comment) => (
|
|
|
- <LineComment
|
|
|
- id={comment.id}
|
|
|
- top={positions()[comment.id]}
|
|
|
- onMouseEnter={() => setSelection({ file: comment.file, range: comment.selection })}
|
|
|
- onClick={() => {
|
|
|
- if (isCommentOpen(comment)) {
|
|
|
- setOpened(null)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- openComment(comment)
|
|
|
- }}
|
|
|
- open={isCommentOpen(comment)}
|
|
|
- comment={comment.comment}
|
|
|
- selection={selectionLabel(comment.selection)}
|
|
|
- />
|
|
|
- )}
|
|
|
- </For>
|
|
|
-
|
|
|
- <Show when={draftRange()}>
|
|
|
- {(range) => (
|
|
|
- <Show when={draftTop() !== undefined}>
|
|
|
- <LineCommentEditor
|
|
|
- top={draftTop()}
|
|
|
- value={draft()}
|
|
|
- selection={selectionLabel(range())}
|
|
|
- onInput={setDraft}
|
|
|
- onCancel={() => setCommenting(null)}
|
|
|
- onSubmit={(comment) => {
|
|
|
- props.onLineComment?.({
|
|
|
- file,
|
|
|
- selection: range(),
|
|
|
- comment,
|
|
|
- preview: selectionPreview(item(), range()),
|
|
|
- })
|
|
|
- setCommenting(null)
|
|
|
- }}
|
|
|
- />
|
|
|
- </Show>
|
|
|
- )}
|
|
|
- </Show>
|
|
|
</Show>
|
|
|
</div>
|
|
|
</Accordion.Content>
|