tf_code/packages/ui/src/components/session-review.tsx
2026-03-06 06:49:15 -06:00

903 lines
33 KiB
TypeScript

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 { IconButton } from "./icon-button"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { Tooltip } from "./tooltip"
import { ScrollView } from "./scroll-view"
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, untrack, type JSX } from "solid-js"
import { onCleanup } 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
export type SessionReviewDiffStyle = "unified" | "split"
export type SessionReviewComment = {
id: string
file: string
selection: SelectedLineRange
comment: string
}
export type SessionReviewLineComment = {
file: string
selection: SelectedLineRange
comment: string
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 {
title?: JSX.Element
empty?: JSX.Element
split?: boolean
diffStyle?: SessionReviewDiffStyle
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
focusedFile?: string
open?: string[]
onOpenChange?: (open: string[]) => void
scrollRef?: (el: HTMLDivElement) => void
onScroll?: JSX.EventHandlerUnion<HTMLDivElement, Event>
class?: string
classList?: Record<string, boolean | undefined>
classes?: { root?: string; header?: string; container?: string }
actions?: JSX.Element
diffs: (FileDiff & { preloaded?: PreloadMultiFileDiffResult<any> })[]
onViewFile?: (file: string) => void
readFile?: (path: string) => Promise<FileContent | undefined>
}
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 {
const sum = checksum(file)
if (!sum) return
return `session-review-diff-${sum}`
}
type SessionReviewSelection = {
file: string
range: SelectedLineRange
}
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 fileComponent = useFileComponent()
const anchors = new Map<string, HTMLElement>()
const searchHandles = new Map<string, FileSearchHandle>()
const readyFiles = new Set<string>()
const [store, setStore] = createStore<{ open: string[]; force: Record<string, boolean> }>({
open: [],
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)
if (props.open !== undefined) return
setStore("open", open)
}
const handleExpandOrCollapseAll = () => {
const next = open().length > 0 ? [] : files()
handleChange(next)
}
const clearViewerSearch = () => {
for (const handle of searchHandles.values()) handle.clear()
highlightedFile = undefined
}
const openFileLabel = () => i18n.t("ui.sessionReview.openFile")
const selectionLabel = (range: SelectedLineRange) => {
const start = Math.min(range.start, range.end)
const end = Math.max(range.start, range.end)
if (start === end) return i18n.t("ui.sessionReview.selection.line", { line: start })
return i18n.t("ui.sessionReview.selection.lines", { start, end })
}
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) => {
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) => {
const side = selectionSide(range)
const contents = side === "deletions" ? diff.before : diff.after
if (typeof contents !== "string" || contents.length === 0) return undefined
return previewSelectedLines(contents, range)
}
createEffect(() => {
const focus = props.focusedComment
if (!focus) return
untrack(() => {
focusToken++
const token = focusToken
setOpened(focus)
const comment = (props.comments ?? []).find((c) => c.file === focus.file && c.id === focus.id)
if (comment) setSelection({ file: comment.file, range: cloneSelectedLineRange(comment.selection) })
const current = open()
if (!current.includes(focus.file)) {
handleChange([...current, focus.file])
}
const scrollTo = (attempt: number) => {
if (token !== focusToken) return
const root = scroll
if (!root) return
const wrapper = anchors.get(focus.file)
const anchor = wrapper?.querySelector(`[data-comment-id="${focus.id}"]`)
const ready =
anchor instanceof HTMLElement && anchor.style.pointerEvents !== "none" && anchor.style.opacity !== "0"
const target = ready ? anchor : wrapper
if (!target) {
if (attempt >= 120) return
requestAnimationFrame(() => scrollTo(attempt + 1))
return
}
const rootRect = root.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const offset = targetRect.top - rootRect.top
const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2
root.scrollTop = Math.max(0, next)
if (ready) return
if (attempt >= 120) return
requestAnimationFrame(() => scrollTo(attempt + 1))
}
requestAnimationFrame(() => scrollTo(0))
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 (
<div data-component="session-review" class={props.class} classList={props.classList}>
<div data-slot="session-review-header" class={props.classes?.header}>
<div data-slot="session-review-title">{props.title ?? i18n.t("ui.sessionReview.title")}</div>
<div data-slot="session-review-actions">
<Show when={hasDiffs() && props.onDiffStyleChange}>
<RadioGroup
options={["unified", "split"] as const}
current={diffStyle()}
size="small"
value={(style) => style}
label={(style) =>
i18n.t(style === "unified" ? "ui.sessionReview.diffStyle.unified" : "ui.sessionReview.diffStyle.split")
}
onSelect={(style) => style && props.onDiffStyleChange?.(style)}
/>
</Show>
<Show when={hasDiffs()}>
<Button
size="small"
icon="chevron-grabber-vertical"
class="w-[106px] justify-start"
onClick={handleExpandOrCollapseAll}
>
<Switch>
<Match when={open().length > 0}>{i18n.t("ui.sessionReview.collapseAll")}</Match>
<Match when={true}>{i18n.t("ui.sessionReview.expandAll")}</Match>
</Switch>
</Button>
</Show>
{props.actions}
</div>
</div>
<ScrollView
data-slot="session-review-scroll"
viewportRef={(el) => {
scroll = el
props.scrollRef?.(el)
}}
onScroll={props.onScroll as any}
onKeyDown={handleReviewKeyDown}
classList={{
[props.classes?.root ?? ""]: !!props.classes?.root,
}}
>
<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}>
<div class="pb-6">
<Accordion multiple value={open()} onChange={handleChange}>
<For each={files()}>
{(file) => {
let wrapper: HTMLDivElement | undefined
const diff = createMemo(() => diffs().get(file))
const item = () => diff()!
const expanded = createMemo(() => open().includes(file))
const force = () => !!store.force[file]
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file))
const commentedLines = createMemo(() => comments().map((c) => c.selection))
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 (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 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,
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),
})
},
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,
})
onCleanup(() => {
anchors.delete(file)
readyFiles.delete(file)
searchHandles.delete(file)
if (highlightedFile === file) highlightedFile = undefined
})
const handleLineSelected = (range: SelectedLineRange | null) => {
if (!props.onLineComment) return
commentsUi.onLineSelected(range)
}
const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
if (!props.onLineComment) return
commentsUi.onLineSelectionEnd(range)
}
return (
<Accordion.Item
value={file}
id={diffId(file)}
data-file={file}
data-slot="session-review-accordion-item"
data-selected={props.focusedFile === file ? "" : undefined}
>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-review-trigger-content">
<div data-slot="session-review-file-info">
<FileIcon node={{ path: file, type: "file" }} />
<div data-slot="session-review-file-name-container">
<Show when={file.includes("/")}>
<span data-slot="session-review-directory">{`\u202A${getDirectory(file)}\u202C`}</span>
</Show>
<span data-slot="session-review-filename">{getFilename(file)}</span>
<Show when={props.onViewFile}>
<Tooltip value={openFileLabel()} placement="top" gutter={4}>
<button
data-slot="session-review-view-button"
type="button"
aria-label={openFileLabel()}
onClick={(e) => {
e.stopPropagation()
props.onViewFile?.(file)
}}
>
<Icon name="open-file" size="small" />
</button>
</Tooltip>
</Show>
</div>
</div>
<div data-slot="session-review-trigger-actions">
<Switch>
<Match when={isAdded()}>
<div data-slot="session-review-change-group" data-type="added">
<span data-slot="session-review-change" data-type="added">
{i18n.t("ui.sessionReview.change.added")}
</span>
<DiffChanges changes={item()} />
</div>
</Match>
<Match when={isDeleted()}>
<span data-slot="session-review-change" data-type="removed">
{i18n.t("ui.sessionReview.change.removed")}
</span>
</Match>
<Match when={!!mediaKind()}>
<span data-slot="session-review-change" data-type="modified">
{i18n.t("ui.sessionReview.change.modified")}
</span>
</Match>
<Match when={true}>
<DiffChanges changes={item()} />
</Match>
</Switch>
<span data-slot="session-review-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content data-slot="session-review-accordion-content">
<div
data-slot="session-review-diff-wrapper"
ref={(el) => {
wrapper = el
anchors.set(file, el)
}}
>
<Show when={expanded()}>
<Switch>
<Match when={tooLarge()}>
<div data-slot="session-review-large-diff">
<div data-slot="session-review-large-diff-title">
{i18n.t("ui.sessionReview.largeDiff.title")}
</div>
<div data-slot="session-review-large-diff-meta">
{i18n.t("ui.sessionReview.largeDiff.meta", {
limit: MAX_DIFF_CHANGED_LINES.toLocaleString(),
current: changedLines().toLocaleString(),
})}
</div>
<div data-slot="session-review-large-diff-actions">
<Button
size="normal"
variant="secondary"
onClick={() => setStore("force", file, true)}
>
{i18n.t("ui.sessionReview.largeDiff.renderAnyway")}
</Button>
</div>
</div>
</Match>
<Match when={true}>
<Dynamic
component={fileComponent}
mode="diff"
preloadedDiff={item().preloaded}
diffStyle={diffStyle()}
expansionLineCount={searchExpanded() ? Number.MAX_SAFE_INTEGER : 20}
onRendered={() => {
readyFiles.add(file)
props.onDiffRendered?.()
}}
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 : "",
}}
after={{
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>
</Show>
</div>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</div>
</Show>
</div>
</ScrollView>
</div>
)
}