mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-14 20:54:42 +00:00
chore(app): simplify review pane (#17066)
This commit is contained in:
186
packages/app/e2e/session/session-review.spec.ts
Normal file
186
packages/app/e2e/session/session-review.spec.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { waitSessionIdle, withSession } from "../actions"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
|
const count = 14
|
||||||
|
|
||||||
|
function body(mark: string) {
|
||||||
|
return [
|
||||||
|
`title ${mark}`,
|
||||||
|
`mark ${mark}`,
|
||||||
|
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function files(tag: string) {
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const id = String(i).padStart(2, "0")
|
||||||
|
return {
|
||||||
|
file: `review-scroll-${id}.txt`,
|
||||||
|
mark: `${tag}-${id}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function seed(list: ReturnType<typeof files>) {
|
||||||
|
const out = ["*** Begin Patch"]
|
||||||
|
|
||||||
|
for (const item of list) {
|
||||||
|
out.push(`*** Add File: ${item.file}`)
|
||||||
|
for (const line of body(item.mark)) out.push(`+${line}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push("*** End Patch")
|
||||||
|
return out.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit(file: string, prev: string, next: string) {
|
||||||
|
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
|
||||||
|
"\n",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
|
||||||
|
await sdk.session.promptAsync({
|
||||||
|
sessionID,
|
||||||
|
agent: "build",
|
||||||
|
system: [
|
||||||
|
"You are seeding deterministic e2e UI state.",
|
||||||
|
"Your only valid response is one apply_patch tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({ patchText })}`,
|
||||||
|
"Do not call any other tools.",
|
||||||
|
"Do not output plain text.",
|
||||||
|
].join("\n"),
|
||||||
|
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitSessionIdle(sdk, sessionID, 120_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show(page: Parameters<typeof test>[0]["page"]) {
|
||||||
|
const btn = page.getByRole("button", { name: "Toggle review" }).first()
|
||||||
|
await expect(btn).toBeVisible()
|
||||||
|
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
|
||||||
|
await expect(btn).toHaveAttribute("aria-expanded", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expand(page: Parameters<typeof test>[0]["page"]) {
|
||||||
|
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
|
||||||
|
const open = await close
|
||||||
|
.isVisible()
|
||||||
|
.then((value) => value)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
|
||||||
|
if (open) {
|
||||||
|
await close.click()
|
||||||
|
await expect(btn).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(btn).toBeVisible()
|
||||||
|
await btn.click()
|
||||||
|
await expect(close).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
|
||||||
|
await page.waitForFunction(
|
||||||
|
({ file, mark }) => {
|
||||||
|
const head = Array.from(document.querySelectorAll("h3")).find(
|
||||||
|
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
|
||||||
|
)
|
||||||
|
if (!(head instanceof HTMLElement)) return false
|
||||||
|
|
||||||
|
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
|
||||||
|
if (!(host instanceof HTMLElement)) return false
|
||||||
|
const root = host.shadowRoot
|
||||||
|
return root?.textContent?.includes(`mark ${mark}`) ?? false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ file, mark },
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
|
||||||
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
|
const tag = `review-${Date.now()}`
|
||||||
|
const list = files(tag)
|
||||||
|
const hit = list[list.length - 2]!
|
||||||
|
const next = `${tag}-live`
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1600, height: 1000 })
|
||||||
|
|
||||||
|
await withProject(async (project) => {
|
||||||
|
const sdk = createSdk(project.directory)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e review ${tag}`, async (session) => {
|
||||||
|
await patch(sdk, session.id, seed(list))
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
|
||||||
|
return info?.summary?.files ?? 0
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(list.length)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
|
return diff.length
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(list.length)
|
||||||
|
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await show(page)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||||
|
await expect(view).toBeVisible()
|
||||||
|
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
||||||
|
await expect(heads).toHaveCount(list.length, {
|
||||||
|
timeout: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expand(page)
|
||||||
|
await waitMark(page, hit.file, hit.mark)
|
||||||
|
|
||||||
|
const row = page
|
||||||
|
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
|
||||||
|
.first()
|
||||||
|
await expect(row).toBeVisible()
|
||||||
|
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
|
||||||
|
|
||||||
|
await expect.poll(() => view.evaluate((el) => el.scrollTop)).toBeGreaterThan(200)
|
||||||
|
const prev = await view.evaluate((el) => el.scrollTop)
|
||||||
|
|
||||||
|
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
|
const item = diff.find((item) => item.file === hit.file)
|
||||||
|
return typeof item?.after === "string" ? item.after : ""
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toContain(`mark ${next}`)
|
||||||
|
|
||||||
|
await waitMark(page, hit.file, next)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => Math.abs((await view.evaluate((el) => el.scrollTop)) - prev), { timeout: 60_000 })
|
||||||
|
.toBeLessThanOrEqual(16)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -862,6 +862,36 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||||
|
if (store.changes === "turn") return emptyTurn()
|
||||||
|
|
||||||
|
if (hasReview() && !diffsReady()) {
|
||||||
|
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reviewEmptyKey() === "session.review.noVcs") {
|
||||||
|
return (
|
||||||
|
<div class={input.emptyClass}>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="text-14-medium text-text-strong">Create a Git repository</div>
|
||||||
|
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||||
|
Track, review, and undo changes in this project
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="large" disabled={ui.git} onClick={initGit}>
|
||||||
|
{ui.git ? "Creating Git repository..." : "Create Git repository"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={input.emptyClass}>
|
||||||
|
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const reviewContent = (input: {
|
const reviewContent = (input: {
|
||||||
diffStyle: DiffStyle
|
diffStyle: DiffStyle
|
||||||
onDiffStyleChange?: (style: DiffStyle) => void
|
onDiffStyleChange?: (style: DiffStyle) => void
|
||||||
@@ -870,98 +900,25 @@ export default function Page() {
|
|||||||
emptyClass: string
|
emptyClass: string
|
||||||
}) => (
|
}) => (
|
||||||
<Show when={!store.deferRender}>
|
<Show when={!store.deferRender}>
|
||||||
<Switch>
|
<SessionReviewTab
|
||||||
<Match when={store.changes === "turn" && !!params.id}>
|
title={changesTitle()}
|
||||||
<SessionReviewTab
|
empty={reviewEmpty(input)}
|
||||||
title={changesTitle()}
|
diffs={reviewDiffs}
|
||||||
empty={emptyTurn()}
|
view={view}
|
||||||
diffs={reviewDiffs}
|
diffStyle={input.diffStyle}
|
||||||
view={view}
|
onDiffStyleChange={input.onDiffStyleChange}
|
||||||
diffStyle={input.diffStyle}
|
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||||
onDiffStyleChange={input.onDiffStyleChange}
|
focusedFile={tree.activeDiff}
|
||||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||||
focusedFile={tree.activeDiff}
|
onLineCommentUpdate={updateCommentInContext}
|
||||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
onLineCommentDelete={removeCommentFromContext}
|
||||||
onLineCommentUpdate={updateCommentInContext}
|
lineCommentActions={reviewCommentActions()}
|
||||||
onLineCommentDelete={removeCommentFromContext}
|
comments={comments.all()}
|
||||||
lineCommentActions={reviewCommentActions()}
|
focusedComment={comments.focus()}
|
||||||
comments={comments.all()}
|
onFocusedCommentChange={comments.setFocus}
|
||||||
focusedComment={comments.focus()}
|
onViewFile={openReviewFile}
|
||||||
onFocusedCommentChange={comments.setFocus}
|
classes={input.classes}
|
||||||
onViewFile={openReviewFile}
|
/>
|
||||||
classes={input.classes}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match when={hasReview()}>
|
|
||||||
<Show
|
|
||||||
when={diffsReady()}
|
|
||||||
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
|
|
||||||
>
|
|
||||||
<SessionReviewTab
|
|
||||||
title={changesTitle()}
|
|
||||||
diffs={reviewDiffs}
|
|
||||||
view={view}
|
|
||||||
diffStyle={input.diffStyle}
|
|
||||||
onDiffStyleChange={input.onDiffStyleChange}
|
|
||||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
|
||||||
focusedFile={tree.activeDiff}
|
|
||||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
|
||||||
onLineCommentUpdate={updateCommentInContext}
|
|
||||||
onLineCommentDelete={removeCommentFromContext}
|
|
||||||
lineCommentActions={reviewCommentActions()}
|
|
||||||
comments={comments.all()}
|
|
||||||
focusedComment={comments.focus()}
|
|
||||||
onFocusedCommentChange={comments.setFocus}
|
|
||||||
onViewFile={openReviewFile}
|
|
||||||
classes={input.classes}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<SessionReviewTab
|
|
||||||
title={changesTitle()}
|
|
||||||
empty={
|
|
||||||
store.changes === "turn" ? (
|
|
||||||
emptyTurn()
|
|
||||||
) : reviewEmptyKey() === "session.review.noVcs" ? (
|
|
||||||
<div class={input.emptyClass}>
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="text-14-medium text-text-strong">Create a Git repository</div>
|
|
||||||
<div
|
|
||||||
class="text-14-regular text-text-base max-w-md"
|
|
||||||
style={{ "line-height": "var(--line-height-normal)" }}
|
|
||||||
>
|
|
||||||
Track, review, and undo changes in this project
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button size="large" disabled={ui.git} onClick={initGit}>
|
|
||||||
{ui.git ? "Creating Git repository..." : "Create Git repository"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div class={input.emptyClass}>
|
|
||||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
diffs={reviewDiffs}
|
|
||||||
view={view}
|
|
||||||
diffStyle={input.diffStyle}
|
|
||||||
onDiffStyleChange={input.onDiffStyleChange}
|
|
||||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
|
||||||
focusedFile={tree.activeDiff}
|
|
||||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
|
||||||
onLineCommentUpdate={updateCommentInContext}
|
|
||||||
onLineCommentDelete={removeCommentFromContext}
|
|
||||||
lineCommentActions={reviewCommentActions()}
|
|
||||||
comments={comments.all()}
|
|
||||||
focusedComment={comments.focus()}
|
|
||||||
onFocusedCommentChange={comments.setFocus}
|
|
||||||
onViewFile={openReviewFile}
|
|
||||||
classes={input.classes}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { sampledChecksum } from "@opencode-ai/util/encode"
|
import { sampledChecksum } from "@opencode-ai/util/encode"
|
||||||
import {
|
import {
|
||||||
DEFAULT_VIRTUAL_FILE_METRICS,
|
DEFAULT_VIRTUAL_FILE_METRICS,
|
||||||
type ExpansionDirections,
|
|
||||||
type DiffLineAnnotation,
|
type DiffLineAnnotation,
|
||||||
type FileContents,
|
type FileContents,
|
||||||
type FileDiffMetadata,
|
|
||||||
File as PierreFile,
|
File as PierreFile,
|
||||||
type FileDiffOptions,
|
type FileDiffOptions,
|
||||||
FileDiff,
|
FileDiff,
|
||||||
@@ -22,7 +20,7 @@ import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMo
|
|||||||
import { createDefaultOptions, styleVariables } from "../pierre"
|
import { createDefaultOptions, styleVariables } from "../pierre"
|
||||||
import { markCommentedDiffLines, markCommentedFileLines } from "../pierre/commented-lines"
|
import { markCommentedDiffLines, markCommentedFileLines } from "../pierre/commented-lines"
|
||||||
import { fixDiffSelection, findDiffSide, type DiffSelectionSide } from "../pierre/diff-selection"
|
import { fixDiffSelection, findDiffSide, type DiffSelectionSide } from "../pierre/diff-selection"
|
||||||
import { createFileFind, type FileFindReveal } from "../pierre/file-find"
|
import { createFileFind } from "../pierre/file-find"
|
||||||
import {
|
import {
|
||||||
applyViewerScheme,
|
applyViewerScheme,
|
||||||
clearReadyWatcher,
|
clearReadyWatcher,
|
||||||
@@ -65,21 +63,11 @@ type SharedProps<T> = {
|
|||||||
search?: FileSearchControl
|
search?: FileSearchControl
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FileSearchReveal = FileFindReveal
|
|
||||||
|
|
||||||
export type FileSearchHandle = {
|
export type FileSearchHandle = {
|
||||||
focus: () => void
|
focus: () => void
|
||||||
setQuery: (value: string) => void
|
|
||||||
clear: () => void
|
|
||||||
reveal: (hit: FileSearchReveal) => boolean
|
|
||||||
expand: (hit: FileSearchReveal) => boolean
|
|
||||||
refresh: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FileSearchControl = {
|
export type FileSearchControl = {
|
||||||
shortcuts?: "global" | "disabled"
|
|
||||||
showBar?: boolean
|
|
||||||
disableVirtualization?: boolean
|
|
||||||
register: (handle: FileSearchHandle | null) => void
|
register: (handle: FileSearchHandle | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,40 +109,6 @@ const sharedKeys = [
|
|||||||
const textKeys = ["file", ...sharedKeys] as const
|
const textKeys = ["file", ...sharedKeys] as const
|
||||||
const diffKeys = ["before", "after", ...sharedKeys] as const
|
const diffKeys = ["before", "after", ...sharedKeys] as const
|
||||||
|
|
||||||
function expansionForHit(diff: FileDiffMetadata, hit: FileSearchReveal) {
|
|
||||||
if (diff.isPartial || diff.hunks.length === 0) return
|
|
||||||
|
|
||||||
const side =
|
|
||||||
hit.side === "deletions"
|
|
||||||
? {
|
|
||||||
start: (hunk: FileDiffMetadata["hunks"][number]) => hunk.deletionStart,
|
|
||||||
count: (hunk: FileDiffMetadata["hunks"][number]) => hunk.deletionCount,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
start: (hunk: FileDiffMetadata["hunks"][number]) => hunk.additionStart,
|
|
||||||
count: (hunk: FileDiffMetadata["hunks"][number]) => hunk.additionCount,
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < diff.hunks.length; i++) {
|
|
||||||
const hunk = diff.hunks[i]
|
|
||||||
const start = side.start(hunk)
|
|
||||||
if (hit.line < start) {
|
|
||||||
return {
|
|
||||||
index: i,
|
|
||||||
direction: i === 0 ? "down" : "both",
|
|
||||||
} satisfies { index: number; direction: ExpansionDirections }
|
|
||||||
}
|
|
||||||
|
|
||||||
const end = start + Math.max(side.count(hunk) - 1, -1)
|
|
||||||
if (hit.line <= end) return
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
index: diff.hunks.length,
|
|
||||||
direction: "up",
|
|
||||||
} satisfies { index: number; direction: ExpansionDirections }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared viewer hook
|
// Shared viewer hook
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -167,7 +121,6 @@ type MouseHit = {
|
|||||||
|
|
||||||
type ViewerConfig = {
|
type ViewerConfig = {
|
||||||
enableLineSelection: () => boolean
|
enableLineSelection: () => boolean
|
||||||
search: () => FileSearchControl | undefined
|
|
||||||
selectedLines: () => SelectedLineRange | null | undefined
|
selectedLines: () => SelectedLineRange | null | undefined
|
||||||
commentedLines: () => SelectedLineRange[]
|
commentedLines: () => SelectedLineRange[]
|
||||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||||
@@ -207,7 +160,6 @@ function useFileViewer(config: ViewerConfig) {
|
|||||||
wrapper: () => wrapper,
|
wrapper: () => wrapper,
|
||||||
overlay: () => overlay,
|
overlay: () => overlay,
|
||||||
getRoot,
|
getRoot,
|
||||||
shortcuts: config.search()?.shortcuts,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// -- selection scheduling --
|
// -- selection scheduling --
|
||||||
@@ -407,14 +359,10 @@ function useFileViewer(config: ViewerConfig) {
|
|||||||
|
|
||||||
type Viewer = ReturnType<typeof useFileViewer>
|
type Viewer = ReturnType<typeof useFileViewer>
|
||||||
|
|
||||||
type ModeAdapter = Omit<
|
type ModeAdapter = Omit<ViewerConfig, "enableLineSelection" | "selectedLines" | "commentedLines" | "onLineSelectionEnd">
|
||||||
ViewerConfig,
|
|
||||||
"enableLineSelection" | "search" | "selectedLines" | "commentedLines" | "onLineSelectionEnd"
|
|
||||||
>
|
|
||||||
|
|
||||||
type ModeConfig = {
|
type ModeConfig = {
|
||||||
enableLineSelection: () => boolean
|
enableLineSelection: () => boolean
|
||||||
search: () => FileSearchControl | undefined
|
|
||||||
selectedLines: () => SelectedLineRange | null | undefined
|
selectedLines: () => SelectedLineRange | null | undefined
|
||||||
commentedLines: () => SelectedLineRange[] | undefined
|
commentedLines: () => SelectedLineRange[] | undefined
|
||||||
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
onLineSelectionEnd: (range: SelectedLineRange | null) => void
|
||||||
@@ -437,7 +385,6 @@ type VirtualStrategy = {
|
|||||||
function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
|
function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
|
||||||
return useFileViewer({
|
return useFileViewer({
|
||||||
enableLineSelection: config.enableLineSelection,
|
enableLineSelection: config.enableLineSelection,
|
||||||
search: config.search,
|
|
||||||
selectedLines: config.selectedLines,
|
selectedLines: config.selectedLines,
|
||||||
commentedLines: () => config.commentedLines() ?? [],
|
commentedLines: () => config.commentedLines() ?? [],
|
||||||
onLineSelectionEnd: config.onLineSelectionEnd,
|
onLineSelectionEnd: config.onLineSelectionEnd,
|
||||||
@@ -448,32 +395,13 @@ function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
|
|||||||
function useSearchHandle(opts: {
|
function useSearchHandle(opts: {
|
||||||
search: () => FileSearchControl | undefined
|
search: () => FileSearchControl | undefined
|
||||||
find: ReturnType<typeof createFileFind>
|
find: ReturnType<typeof createFileFind>
|
||||||
expand?: (hit: FileSearchReveal) => boolean
|
|
||||||
}) {
|
}) {
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const search = opts.search()
|
const search = opts.search()
|
||||||
if (!search) return
|
if (!search) return
|
||||||
|
|
||||||
const handle = {
|
const handle = {
|
||||||
focus: () => {
|
focus: () => opts.find.focus(),
|
||||||
opts.find.focus()
|
|
||||||
},
|
|
||||||
setQuery: (value: string) => {
|
|
||||||
opts.find.activate()
|
|
||||||
opts.find.setQuery(value, { scroll: false })
|
|
||||||
},
|
|
||||||
clear: () => {
|
|
||||||
opts.find.clear()
|
|
||||||
},
|
|
||||||
reveal: (hit: FileSearchReveal) => {
|
|
||||||
opts.find.activate()
|
|
||||||
return opts.find.reveal(hit)
|
|
||||||
},
|
|
||||||
expand: (hit: FileSearchReveal) => opts.expand?.(hit) ?? false,
|
|
||||||
refresh: () => {
|
|
||||||
opts.find.activate()
|
|
||||||
opts.find.refresh()
|
|
||||||
},
|
|
||||||
} satisfies FileSearchHandle
|
} satisfies FileSearchHandle
|
||||||
|
|
||||||
search.register(handle)
|
search.register(handle)
|
||||||
@@ -563,6 +491,29 @@ function renderViewer<I extends RenderTarget>(opts: {
|
|||||||
opts.onReady()
|
opts.onReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preserve(viewer: Viewer) {
|
||||||
|
const root = scrollParent(viewer.wrapper)
|
||||||
|
if (!root) return () => {}
|
||||||
|
|
||||||
|
const high = viewer.container.getBoundingClientRect().height
|
||||||
|
if (!high) return () => {}
|
||||||
|
|
||||||
|
const top = viewer.wrapper.getBoundingClientRect().top - root.getBoundingClientRect().top
|
||||||
|
const prev = viewer.container.style.minHeight
|
||||||
|
viewer.container.style.minHeight = `${Math.ceil(high)}px`
|
||||||
|
|
||||||
|
let done = false
|
||||||
|
return () => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
viewer.container.style.minHeight = prev
|
||||||
|
|
||||||
|
const next = viewer.wrapper.getBoundingClientRect().top - root.getBoundingClientRect().top
|
||||||
|
const delta = next - top
|
||||||
|
if (delta) root.scrollTop += delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scrollParent(el: HTMLElement): HTMLElement | undefined {
|
function scrollParent(el: HTMLElement): HTMLElement | undefined {
|
||||||
let parent = el.parentElement
|
let parent = el.parentElement
|
||||||
while (parent) {
|
while (parent) {
|
||||||
@@ -606,7 +557,7 @@ function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enab
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
|
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): VirtualStrategy {
|
||||||
let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
|
||||||
|
|
||||||
const release = () => {
|
const release = () => {
|
||||||
@@ -616,10 +567,6 @@ function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined, ena
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
get: () => {
|
get: () => {
|
||||||
if (!enabled()) {
|
|
||||||
release()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (shared) return shared.virtualizer
|
if (shared) return shared.virtualizer
|
||||||
|
|
||||||
const container = host()
|
const container = host()
|
||||||
@@ -689,7 +636,6 @@ function diffSelectionSide(node: Node | null) {
|
|||||||
function ViewerShell(props: {
|
function ViewerShell(props: {
|
||||||
mode: "text" | "diff"
|
mode: "text" | "diff"
|
||||||
viewer: ReturnType<typeof useFileViewer>
|
viewer: ReturnType<typeof useFileViewer>
|
||||||
search: FileSearchControl | undefined
|
|
||||||
class: string | undefined
|
class: string | undefined
|
||||||
classList: ComponentProps<"div">["classList"] | undefined
|
classList: ComponentProps<"div">["classList"] | undefined
|
||||||
}) {
|
}) {
|
||||||
@@ -708,7 +654,7 @@ function ViewerShell(props: {
|
|||||||
onPointerDown={props.viewer.find.onPointerDown}
|
onPointerDown={props.viewer.find.onPointerDown}
|
||||||
onFocus={props.viewer.find.onFocus}
|
onFocus={props.viewer.find.onFocus}
|
||||||
>
|
>
|
||||||
<Show when={(props.search?.showBar ?? true) && props.viewer.find.open()}>
|
<Show when={props.viewer.find.open()}>
|
||||||
<FileSearchBar
|
<FileSearchBar
|
||||||
pos={props.viewer.find.pos}
|
pos={props.viewer.find.pos}
|
||||||
query={props.viewer.find.query}
|
query={props.viewer.find.query}
|
||||||
@@ -855,7 +801,6 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
|||||||
viewer = useModeViewer(
|
viewer = useModeViewer(
|
||||||
{
|
{
|
||||||
enableLineSelection: () => props.enableLineSelection === true,
|
enableLineSelection: () => props.enableLineSelection === true,
|
||||||
search: () => local.search,
|
|
||||||
selectedLines: () => local.selectedLines,
|
selectedLines: () => local.selectedLines,
|
||||||
commentedLines: () => local.commentedLines,
|
commentedLines: () => local.commentedLines,
|
||||||
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
|
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
|
||||||
@@ -941,9 +886,7 @@ function TextViewer<T>(props: TextFileProps<T>) {
|
|||||||
virtuals.cleanup()
|
virtuals.cleanup()
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return <ViewerShell mode="text" viewer={viewer} class={local.class} classList={local.classList} />
|
||||||
<ViewerShell mode="text" viewer={viewer} search={local.search} class={local.class} classList={local.classList} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1029,7 +972,6 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
|||||||
viewer = useModeViewer(
|
viewer = useModeViewer(
|
||||||
{
|
{
|
||||||
enableLineSelection: () => props.enableLineSelection === true,
|
enableLineSelection: () => props.enableLineSelection === true,
|
||||||
search: () => local.search,
|
|
||||||
selectedLines: () => local.selectedLines,
|
selectedLines: () => local.selectedLines,
|
||||||
commentedLines: () => local.commentedLines,
|
commentedLines: () => local.commentedLines,
|
||||||
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
|
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
|
||||||
@@ -1037,10 +979,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
|||||||
adapter,
|
adapter,
|
||||||
)
|
)
|
||||||
|
|
||||||
const virtuals = createSharedVirtualStrategy(
|
const virtuals = createSharedVirtualStrategy(() => viewer.container)
|
||||||
() => viewer.container,
|
|
||||||
() => local.search?.disableVirtualization !== true,
|
|
||||||
)
|
|
||||||
|
|
||||||
const large = createMemo(() => {
|
const large = createMemo(() => {
|
||||||
const before = typeof local.before?.contents === "string" ? local.before.contents : ""
|
const before = typeof local.before?.contents === "string" ? local.before.contents : ""
|
||||||
@@ -1074,12 +1013,13 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
|||||||
return { ...perf, disableLineNumbers: true }
|
return { ...perf, disableLineNumbers: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
const notify = () => {
|
const notify = (done?: VoidFunction) => {
|
||||||
notifyRendered({
|
notifyRendered({
|
||||||
viewer,
|
viewer,
|
||||||
isReady: (root) => root.querySelector("[data-line]") != null,
|
isReady: (root) => root.querySelector("[data-line]") != null,
|
||||||
settleFrames: 1,
|
settleFrames: 1,
|
||||||
onReady: () => {
|
onReady: () => {
|
||||||
|
done?.()
|
||||||
setSelectedLines(viewer.lastSelection)
|
setSelectedLines(viewer.lastSelection)
|
||||||
viewer.find.refresh({ reset: true })
|
viewer.find.refresh({ reset: true })
|
||||||
local.onRendered?.()
|
local.onRendered?.()
|
||||||
@@ -1090,20 +1030,6 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
|||||||
useSearchHandle({
|
useSearchHandle({
|
||||||
search: () => local.search,
|
search: () => local.search,
|
||||||
find: viewer.find,
|
find: viewer.find,
|
||||||
expand: (hit) => {
|
|
||||||
const active = instance as
|
|
||||||
| ((FileDiff<T> | VirtualizedFileDiff<T>) & {
|
|
||||||
fileDiff?: FileDiffMetadata
|
|
||||||
})
|
|
||||||
| undefined
|
|
||||||
if (!active?.fileDiff) return false
|
|
||||||
|
|
||||||
const next = expansionForHit(active.fileDiff, hit)
|
|
||||||
if (!next) return false
|
|
||||||
|
|
||||||
active.expandHunk(next.index, next.direction)
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// -- render instance --
|
// -- render instance --
|
||||||
@@ -1114,6 +1040,9 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
|||||||
const virtualizer = virtuals.get()
|
const virtualizer = virtuals.get()
|
||||||
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
|
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
|
||||||
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
|
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
|
||||||
|
const done = preserve(viewer)
|
||||||
|
|
||||||
|
onCleanup(done)
|
||||||
|
|
||||||
const cacheKey = (contents: string) => {
|
const cacheKey = (contents: string) => {
|
||||||
if (!large()) return sampledChecksum(contents, contents.length)
|
if (!large()) return sampledChecksum(contents, contents.length)
|
||||||
@@ -1138,7 +1067,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
|||||||
containerWrapper: viewer.container,
|
containerWrapper: viewer.container,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onReady: notify,
|
onReady: () => notify(done),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1158,9 +1087,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
|||||||
dragEndSide = undefined
|
dragEndSide = undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return <ViewerShell mode="diff" viewer={viewer} class={local.class} classList={local.classList} />
|
||||||
<ViewerShell mode="diff" viewer={viewer} search={local.search} class={local.class} classList={local.classList} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { buildSessionSearchHits, stepSessionSearchIndex } from "./session-review-search"
|
|
||||||
|
|
||||||
describe("session review search", () => {
|
|
||||||
test("builds hits with line, col, and side", () => {
|
|
||||||
const hits = buildSessionSearchHits({
|
|
||||||
query: "alpha",
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
file: "a.txt",
|
|
||||||
before: "alpha\nbeta alpha",
|
|
||||||
after: "ALPHA",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(hits).toEqual([
|
|
||||||
{ file: "a.txt", side: "deletions", line: 1, col: 1, len: 5 },
|
|
||||||
{ file: "a.txt", side: "deletions", line: 2, col: 6, len: 5 },
|
|
||||||
{ file: "a.txt", side: "additions", line: 1, col: 1, len: 5 },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses non-overlapping matches", () => {
|
|
||||||
const hits = buildSessionSearchHits({
|
|
||||||
query: "aa",
|
|
||||||
files: [{ file: "a.txt", after: "aaaa" }],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(hits.map((hit) => hit.col)).toEqual([1, 3])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("wraps next and previous navigation", () => {
|
|
||||||
expect(stepSessionSearchIndex(5, 0, -1)).toBe(4)
|
|
||||||
expect(stepSessionSearchIndex(5, 4, 1)).toBe(0)
|
|
||||||
expect(stepSessionSearchIndex(5, 2, 1)).toBe(3)
|
|
||||||
expect(stepSessionSearchIndex(0, 0, 1)).toBe(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
export type SessionSearchHit = {
|
|
||||||
file: string
|
|
||||||
side: "additions" | "deletions"
|
|
||||||
line: number
|
|
||||||
col: number
|
|
||||||
len: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type SessionSearchFile = {
|
|
||||||
file: string
|
|
||||||
before?: string
|
|
||||||
after?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function hitsForSide(args: { file: string; side: SessionSearchHit["side"]; text: string; needle: string }) {
|
|
||||||
return args.text.split("\n").flatMap((line, i) => {
|
|
||||||
if (!line) return []
|
|
||||||
|
|
||||||
const hay = line.toLowerCase()
|
|
||||||
let at = hay.indexOf(args.needle)
|
|
||||||
if (at < 0) return []
|
|
||||||
|
|
||||||
const out: SessionSearchHit[] = []
|
|
||||||
while (at >= 0) {
|
|
||||||
out.push({
|
|
||||||
file: args.file,
|
|
||||||
side: args.side,
|
|
||||||
line: i + 1,
|
|
||||||
col: at + 1,
|
|
||||||
len: args.needle.length,
|
|
||||||
})
|
|
||||||
at = hay.indexOf(args.needle, at + args.needle.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSessionSearchHits(args: { query: string; files: SessionSearchFile[] }) {
|
|
||||||
const value = args.query.trim().toLowerCase()
|
|
||||||
if (!value) return []
|
|
||||||
|
|
||||||
return args.files.flatMap((file) => {
|
|
||||||
const out: SessionSearchHit[] = []
|
|
||||||
if (typeof file.before === "string") {
|
|
||||||
out.push(...hitsForSide({ file: file.file, side: "deletions", text: file.before, needle: value }))
|
|
||||||
}
|
|
||||||
if (typeof file.after === "string") {
|
|
||||||
out.push(...hitsForSide({ file: file.file, side: "additions", text: file.after, needle: value }))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stepSessionSearchIndex(total: number, current: number, dir: 1 | -1) {
|
|
||||||
if (total <= 0) return 0
|
|
||||||
if (current < 0 || current >= total) return dir > 0 ? 0 : total - 1
|
|
||||||
return (current + dir + total) % total
|
|
||||||
}
|
|
||||||
@@ -9,9 +9,6 @@ import { IconButton } from "./icon-button"
|
|||||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||||
import { Tooltip } from "./tooltip"
|
import { Tooltip } from "./tooltip"
|
||||||
import { ScrollView } from "./scroll-view"
|
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 { useFileComponent } from "../context/file"
|
||||||
import { useI18n } from "../context/i18n"
|
import { useI18n } from "../context/i18n"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
@@ -63,6 +60,8 @@ export type SessionReviewCommentActions = {
|
|||||||
|
|
||||||
export type SessionReviewFocus = { file: string; id: string }
|
export type SessionReviewFocus = { file: string; id: string }
|
||||||
|
|
||||||
|
type ReviewDiff = FileDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
|
||||||
|
|
||||||
export interface SessionReviewProps {
|
export interface SessionReviewProps {
|
||||||
title?: JSX.Element
|
title?: JSX.Element
|
||||||
empty?: JSX.Element
|
empty?: JSX.Element
|
||||||
@@ -86,7 +85,7 @@ export interface SessionReviewProps {
|
|||||||
classList?: Record<string, boolean | undefined>
|
classList?: Record<string, boolean | undefined>
|
||||||
classes?: { root?: string; header?: string; container?: string }
|
classes?: { root?: string; header?: string; container?: string }
|
||||||
actions?: JSX.Element
|
actions?: JSX.Element
|
||||||
diffs: (FileDiff & { preloaded?: PreloadMultiFileDiffResult<any> })[]
|
diffs: ReviewDiff[]
|
||||||
onViewFile?: (file: string) => void
|
onViewFile?: (file: string) => void
|
||||||
readFile?: (path: string) => Promise<FileContent | undefined>
|
readFile?: (path: string) => Promise<FileContent | undefined>
|
||||||
}
|
}
|
||||||
@@ -135,15 +134,10 @@ type SessionReviewSelection = {
|
|||||||
|
|
||||||
export const SessionReview = (props: SessionReviewProps) => {
|
export const SessionReview = (props: SessionReviewProps) => {
|
||||||
let scroll: HTMLDivElement | undefined
|
let scroll: HTMLDivElement | undefined
|
||||||
let searchInput: HTMLInputElement | undefined
|
|
||||||
let focusToken = 0
|
let focusToken = 0
|
||||||
let revealToken = 0
|
|
||||||
let highlightedFile: string | undefined
|
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const fileComponent = useFileComponent()
|
const fileComponent = useFileComponent()
|
||||||
const anchors = new Map<string, HTMLElement>()
|
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> }>({
|
const [store, setStore] = createStore<{ open: string[]; force: Record<string, boolean> }>({
|
||||||
open: [],
|
open: [],
|
||||||
force: {},
|
force: {},
|
||||||
@@ -152,18 +146,12 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
const [selection, setSelection] = createSignal<SessionReviewSelection | null>(null)
|
const [selection, setSelection] = createSignal<SessionReviewSelection | null>(null)
|
||||||
const [commenting, setCommenting] = createSignal<SessionReviewSelection | null>(null)
|
const [commenting, setCommenting] = createSignal<SessionReviewSelection | null>(null)
|
||||||
const [opened, setOpened] = createSignal<SessionReviewFocus | 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 open = () => props.open ?? store.open
|
||||||
const files = createMemo(() => props.diffs.map((d) => d.file))
|
const files = createMemo(() => props.diffs.map((diff) => diff.file))
|
||||||
const diffs = createMemo(() => new Map(props.diffs.map((d) => [d.file, d] as const)))
|
const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const)))
|
||||||
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
||||||
const hasDiffs = () => files().length > 0
|
const hasDiffs = () => files().length > 0
|
||||||
const searchValue = createMemo(() => searchQuery().trim())
|
|
||||||
const searchExpanded = createMemo(() => searchValue().length > 0)
|
|
||||||
|
|
||||||
const handleChange = (open: string[]) => {
|
const handleChange = (open: string[]) => {
|
||||||
props.onOpenChange?.(open)
|
props.onOpenChange?.(open)
|
||||||
@@ -176,266 +164,8 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
handleChange(next)
|
handleChange(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearViewerSearch = () => {
|
|
||||||
for (const handle of searchHandles.values()) handle.clear()
|
|
||||||
highlightedFile = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const openFileLabel = () => i18n.t("ui.sessionReview.openFile")
|
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 selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
|
||||||
|
|
||||||
const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => {
|
const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => {
|
||||||
@@ -499,58 +229,6 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div data-component="session-review" class={props.class} classList={props.classList}>
|
<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-header" class={props.classes?.header}>
|
||||||
@@ -594,31 +272,10 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
props.scrollRef?.(el)
|
props.scrollRef?.(el)
|
||||||
}}
|
}}
|
||||||
onScroll={props.onScroll as any}
|
onScroll={props.onScroll as any}
|
||||||
onKeyDown={handleReviewKeyDown}
|
|
||||||
classList={{
|
classList={{
|
||||||
[props.classes?.root ?? ""]: !!props.classes?.root,
|
[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}>
|
<div data-slot="session-review-container" class={props.classes?.container}>
|
||||||
<Show when={hasDiffs()} fallback={props.empty}>
|
<Show when={hasDiffs()} fallback={props.empty}>
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
@@ -627,8 +284,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
{(file) => {
|
{(file) => {
|
||||||
let wrapper: HTMLDivElement | undefined
|
let wrapper: HTMLDivElement | undefined
|
||||||
|
|
||||||
const diff = createMemo(() => diffs().get(file))
|
const item = createMemo(() => diffs().get(file)!)
|
||||||
const item = () => diff()!
|
|
||||||
|
|
||||||
const expanded = createMemo(() => open().includes(file))
|
const expanded = createMemo(() => open().includes(file))
|
||||||
const force = () => !!store.force[file]
|
const force = () => !!store.force[file]
|
||||||
@@ -720,9 +376,6 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
anchors.delete(file)
|
anchors.delete(file)
|
||||||
readyFiles.delete(file)
|
|
||||||
searchHandles.delete(file)
|
|
||||||
if (highlightedFile === file) highlightedFile = undefined
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleLineSelected = (range: SelectedLineRange | null) => {
|
const handleLineSelected = (range: SelectedLineRange | null) => {
|
||||||
@@ -839,9 +492,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
mode="diff"
|
mode="diff"
|
||||||
preloadedDiff={item().preloaded}
|
preloadedDiff={item().preloaded}
|
||||||
diffStyle={diffStyle()}
|
diffStyle={diffStyle()}
|
||||||
expansionLineCount={searchExpanded() ? Number.MAX_SAFE_INTEGER : 20}
|
|
||||||
onRendered={() => {
|
onRendered={() => {
|
||||||
readyFiles.add(file)
|
|
||||||
props.onDiffRendered?.()
|
props.onDiffRendered?.()
|
||||||
}}
|
}}
|
||||||
enableLineSelection={props.onLineComment != null}
|
enableLineSelection={props.onLineComment != null}
|
||||||
@@ -854,21 +505,6 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined}
|
renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined}
|
||||||
selectedLines={selectedLines()}
|
selectedLines={selectedLines()}
|
||||||
commentedLines={commentedLines()}
|
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={{
|
before={{
|
||||||
name: file,
|
name: file,
|
||||||
contents: typeof item().before === "string" ? item().before : "",
|
contents: typeof item().before === "string" ? item().before : "",
|
||||||
|
|||||||
@@ -8,20 +8,6 @@ export type FindHost = {
|
|||||||
isOpen: () => boolean
|
isOpen: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileFindSide = "additions" | "deletions"
|
|
||||||
|
|
||||||
export type FileFindReveal = {
|
|
||||||
side: FileFindSide
|
|
||||||
line: number
|
|
||||||
col: number
|
|
||||||
len: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type FileFindHit = FileFindReveal & {
|
|
||||||
range: Range
|
|
||||||
alt?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const hosts = new Set<FindHost>()
|
const hosts = new Set<FindHost>()
|
||||||
let target: FindHost | undefined
|
let target: FindHost | undefined
|
||||||
let current: FindHost | undefined
|
let current: FindHost | undefined
|
||||||
@@ -112,7 +98,6 @@ type CreateFileFindOptions = {
|
|||||||
wrapper: () => HTMLElement | undefined
|
wrapper: () => HTMLElement | undefined
|
||||||
overlay: () => HTMLDivElement | undefined
|
overlay: () => HTMLDivElement | undefined
|
||||||
getRoot: () => ShadowRoot | undefined
|
getRoot: () => ShadowRoot | undefined
|
||||||
shortcuts?: "global" | "disabled"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFileFind(opts: CreateFileFindOptions) {
|
export function createFileFind(opts: CreateFileFindOptions) {
|
||||||
@@ -120,7 +105,7 @@ export function createFileFind(opts: CreateFileFindOptions) {
|
|||||||
let overlayFrame: number | undefined
|
let overlayFrame: number | undefined
|
||||||
let overlayScroll: HTMLElement[] = []
|
let overlayScroll: HTMLElement[] = []
|
||||||
let mode: "highlights" | "overlay" = "overlay"
|
let mode: "highlights" | "overlay" = "overlay"
|
||||||
let hits: FileFindHit[] = []
|
let hits: Range[] = []
|
||||||
|
|
||||||
const [open, setOpen] = createSignal(false)
|
const [open, setOpen] = createSignal(false)
|
||||||
const [query, setQuery] = createSignal("")
|
const [query, setQuery] = createSignal("")
|
||||||
@@ -161,7 +146,7 @@ export function createFileFind(opts: CreateFileFindOptions) {
|
|||||||
const frag = document.createDocumentFragment()
|
const frag = document.createDocumentFragment()
|
||||||
|
|
||||||
for (let i = 0; i < hits.length; i++) {
|
for (let i = 0; i < hits.length; i++) {
|
||||||
const range = hits[i].range
|
const range = hits[i]
|
||||||
const active = i === currentIndex
|
const active = i === currentIndex
|
||||||
for (const rect of Array.from(range.getClientRects())) {
|
for (const rect of Array.from(range.getClientRects())) {
|
||||||
if (!rect.width || !rect.height) continue
|
if (!rect.width || !rect.height) continue
|
||||||
@@ -237,7 +222,7 @@ export function createFileFind(opts: CreateFileFindOptions) {
|
|||||||
|
|
||||||
const scan = (root: ShadowRoot, value: string) => {
|
const scan = (root: ShadowRoot, value: string) => {
|
||||||
const needle = value.toLowerCase()
|
const needle = value.toLowerCase()
|
||||||
const ranges: FileFindHit[] = []
|
const ranges: Range[] = []
|
||||||
const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter(
|
const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter(
|
||||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||||
)
|
)
|
||||||
@@ -250,28 +235,6 @@ export function createFileFind(opts: CreateFileFindOptions) {
|
|||||||
let at = hay.indexOf(needle)
|
let at = hay.indexOf(needle)
|
||||||
if (at === -1) continue
|
if (at === -1) continue
|
||||||
|
|
||||||
const row = col.closest("[data-line], [data-alt-line]")
|
|
||||||
if (!(row instanceof HTMLElement)) continue
|
|
||||||
|
|
||||||
const primary = parseInt(row.dataset.line ?? "", 10)
|
|
||||||
const alt = parseInt(row.dataset.altLine ?? "", 10)
|
|
||||||
const line = (() => {
|
|
||||||
if (!Number.isNaN(primary)) return primary
|
|
||||||
if (!Number.isNaN(alt)) return alt
|
|
||||||
})()
|
|
||||||
if (line === undefined) continue
|
|
||||||
|
|
||||||
const side = (() => {
|
|
||||||
const code = col.closest("[data-code]")
|
|
||||||
if (code instanceof HTMLElement) return code.hasAttribute("data-deletions") ? "deletions" : "additions"
|
|
||||||
|
|
||||||
const row = col.closest("[data-line-type]")
|
|
||||||
if (!(row instanceof HTMLElement)) return "additions"
|
|
||||||
const type = row.dataset.lineType
|
|
||||||
if (type === "change-deletion") return "deletions"
|
|
||||||
return "additions"
|
|
||||||
})() as FileFindSide
|
|
||||||
|
|
||||||
const nodes: Text[] = []
|
const nodes: Text[] = []
|
||||||
const ends: number[] = []
|
const ends: number[] = []
|
||||||
const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT)
|
const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT)
|
||||||
@@ -305,14 +268,7 @@ export function createFileFind(opts: CreateFileFindOptions) {
|
|||||||
const range = document.createRange()
|
const range = document.createRange()
|
||||||
range.setStart(start.node, start.offset)
|
range.setStart(start.node, start.offset)
|
||||||
range.setEnd(end.node, end.offset)
|
range.setEnd(end.node, end.offset)
|
||||||
ranges.push({
|
ranges.push(range)
|
||||||
range,
|
|
||||||
side,
|
|
||||||
line,
|
|
||||||
alt: Number.isNaN(alt) ? undefined : alt,
|
|
||||||
col: at + 1,
|
|
||||||
len: value.length,
|
|
||||||
})
|
|
||||||
at = hay.indexOf(needle, at + value.length)
|
at = hay.indexOf(needle, at + value.length)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,17 +277,12 @@ export function createFileFind(opts: CreateFileFindOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scrollToRange = (range: Range) => {
|
const scrollToRange = (range: Range) => {
|
||||||
const scroll = () => {
|
const start = range.startContainer
|
||||||
const start = range.startContainer
|
const el = start instanceof Element ? start : start.parentElement
|
||||||
const el = start instanceof Element ? start : start.parentElement
|
el?.scrollIntoView({ block: "center", inline: "center" })
|
||||||
el?.scrollIntoView({ block: "center", inline: "center" })
|
|
||||||
}
|
|
||||||
|
|
||||||
scroll()
|
|
||||||
requestAnimationFrame(scroll)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setHighlights = (ranges: FileFindHit[], currentIndex: number) => {
|
const setHighlights = (ranges: Range[], currentIndex: number) => {
|
||||||
const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights
|
const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights
|
||||||
const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight
|
const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight
|
||||||
if (!api || typeof Highlight !== "function") return false
|
if (!api || typeof Highlight !== "function") return false
|
||||||
@@ -339,37 +290,14 @@ export function createFileFind(opts: CreateFileFindOptions) {
|
|||||||
api.delete("opencode-find")
|
api.delete("opencode-find")
|
||||||
api.delete("opencode-find-current")
|
api.delete("opencode-find-current")
|
||||||
|
|
||||||
const active = ranges[currentIndex]?.range
|
const active = ranges[currentIndex]
|
||||||
if (active) api.set("opencode-find-current", new Highlight(active))
|
if (active) api.set("opencode-find-current", new Highlight(active))
|
||||||
|
|
||||||
const rest = ranges.flatMap((hit, i) => (i === currentIndex ? [] : [hit.range]))
|
const rest = ranges.filter((_, i) => i !== currentIndex)
|
||||||
if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
|
if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const select = (currentIndex: number, scroll: boolean) => {
|
|
||||||
const active = hits[currentIndex]?.range
|
|
||||||
if (!active) return false
|
|
||||||
|
|
||||||
setIndex(currentIndex)
|
|
||||||
|
|
||||||
if (mode === "highlights") {
|
|
||||||
if (!setHighlights(hits, currentIndex)) {
|
|
||||||
mode = "overlay"
|
|
||||||
apply({ reset: true, scroll })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (scroll) scrollToRange(active)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
clearHighlightFind()
|
|
||||||
syncOverlayScroll()
|
|
||||||
if (scroll) scrollToRange(active)
|
|
||||||
scheduleOverlay()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const apply = (args?: { reset?: boolean; scroll?: boolean }) => {
|
const apply = (args?: { reset?: boolean; scroll?: boolean }) => {
|
||||||
if (!open()) return
|
if (!open()) return
|
||||||
|
|
||||||
@@ -393,7 +321,7 @@ export function createFileFind(opts: CreateFileFindOptions) {
|
|||||||
setCount(total)
|
setCount(total)
|
||||||
setIndex(currentIndex)
|
setIndex(currentIndex)
|
||||||
|
|
||||||
const active = ranges[currentIndex]?.range
|
const active = ranges[currentIndex]
|
||||||
if (mode === "highlights") {
|
if (mode === "highlights") {
|
||||||
clearOverlay()
|
clearOverlay()
|
||||||
clearOverlayScroll()
|
clearOverlayScroll()
|
||||||
@@ -420,23 +348,11 @@ export function createFileFind(opts: CreateFileFindOptions) {
|
|||||||
if (current === host) current = undefined
|
if (current === host) current = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const clear = () => {
|
|
||||||
setQuery("")
|
|
||||||
clearFind()
|
|
||||||
}
|
|
||||||
|
|
||||||
const activate = () => {
|
|
||||||
if (opts.shortcuts !== "disabled") {
|
|
||||||
if (current && current !== host) current.close()
|
|
||||||
current = host
|
|
||||||
target = host
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!open()) setOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const focus = () => {
|
const focus = () => {
|
||||||
activate()
|
if (current && current !== host) current.close()
|
||||||
|
current = host
|
||||||
|
target = host
|
||||||
|
if (!open()) setOpen(true)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
apply({ scroll: true })
|
apply({ scroll: true })
|
||||||
input?.focus()
|
input?.focus()
|
||||||
@@ -450,30 +366,25 @@ export function createFileFind(opts: CreateFileFindOptions) {
|
|||||||
if (total <= 0) return
|
if (total <= 0) return
|
||||||
|
|
||||||
const currentIndex = (index() + dir + total) % total
|
const currentIndex = (index() + dir + total) % total
|
||||||
select(currentIndex, true)
|
setIndex(currentIndex)
|
||||||
}
|
|
||||||
|
|
||||||
const reveal = (targetHit: FileFindReveal) => {
|
const active = hits[currentIndex]
|
||||||
if (!open()) return false
|
if (!active) return
|
||||||
if (hits.length === 0) return false
|
|
||||||
|
|
||||||
const exact = hits.findIndex(
|
if (mode === "highlights") {
|
||||||
(hit) =>
|
if (!setHighlights(hits, currentIndex)) {
|
||||||
hit.side === targetHit.side &&
|
mode = "overlay"
|
||||||
hit.line === targetHit.line &&
|
apply({ reset: true, scroll: true })
|
||||||
hit.col === targetHit.col &&
|
return
|
||||||
hit.len === targetHit.len,
|
}
|
||||||
)
|
scrollToRange(active)
|
||||||
const fallback = hits.findIndex(
|
return
|
||||||
(hit) =>
|
}
|
||||||
(hit.line === targetHit.line || hit.alt === targetHit.line) &&
|
|
||||||
hit.col === targetHit.col &&
|
|
||||||
hit.len === targetHit.len,
|
|
||||||
)
|
|
||||||
|
|
||||||
const nextIndex = exact >= 0 ? exact : fallback
|
clearHighlightFind()
|
||||||
if (nextIndex < 0) return false
|
syncOverlayScroll()
|
||||||
return select(nextIndex, true)
|
scrollToRange(active)
|
||||||
|
scheduleOverlay()
|
||||||
}
|
}
|
||||||
|
|
||||||
const host: FindHost = {
|
const host: FindHost = {
|
||||||
@@ -486,21 +397,17 @@ export function createFileFind(opts: CreateFileFindOptions) {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
mode = supportsHighlights() ? "highlights" : "overlay"
|
mode = supportsHighlights() ? "highlights" : "overlay"
|
||||||
if (opts.shortcuts !== "disabled") {
|
installShortcuts()
|
||||||
installShortcuts()
|
hosts.add(host)
|
||||||
hosts.add(host)
|
if (!target) target = host
|
||||||
if (!target) target = host
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (opts.shortcuts !== "disabled") {
|
hosts.delete(host)
|
||||||
hosts.delete(host)
|
if (current === host) {
|
||||||
if (current === host) {
|
current = undefined
|
||||||
current = undefined
|
clearHighlightFind()
|
||||||
clearHighlightFind()
|
|
||||||
}
|
|
||||||
if (target === host) target = undefined
|
|
||||||
}
|
}
|
||||||
|
if (target === host) target = undefined
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -541,25 +448,20 @@ export function createFileFind(opts: CreateFileFindOptions) {
|
|||||||
setInput: (el: HTMLInputElement) => {
|
setInput: (el: HTMLInputElement) => {
|
||||||
input = el
|
input = el
|
||||||
},
|
},
|
||||||
setQuery: (value: string, args?: { scroll?: boolean }) => {
|
setQuery: (value: string) => {
|
||||||
setQuery(value)
|
setQuery(value)
|
||||||
setIndex(0)
|
setIndex(0)
|
||||||
apply({ reset: true, scroll: args?.scroll ?? true })
|
apply({ reset: true, scroll: true })
|
||||||
},
|
},
|
||||||
clear,
|
|
||||||
activate,
|
|
||||||
focus,
|
focus,
|
||||||
close,
|
close,
|
||||||
next,
|
next,
|
||||||
reveal,
|
|
||||||
refresh: (args?: { reset?: boolean; scroll?: boolean }) => apply(args),
|
refresh: (args?: { reset?: boolean; scroll?: boolean }) => apply(args),
|
||||||
onPointerDown: () => {
|
onPointerDown: () => {
|
||||||
if (opts.shortcuts === "disabled") return
|
|
||||||
target = host
|
target = host
|
||||||
opts.wrapper()?.focus({ preventScroll: true })
|
opts.wrapper()?.focus({ preventScroll: true })
|
||||||
},
|
},
|
||||||
onFocus: () => {
|
onFocus: () => {
|
||||||
if (opts.shortcuts === "disabled") return
|
|
||||||
target = host
|
target = host
|
||||||
},
|
},
|
||||||
onInputKeyDown: (event: KeyboardEvent) => {
|
onInputKeyDown: (event: KeyboardEvent) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user