fix(diff): restore read-only and original-side search with cancellable matching

This commit is contained in:
Neil
2026-09-07 16:35:35 -07:00
parent 0652209e78
commit cfe243af69
17 changed files with 1181 additions and 185 deletions
+18
View File
@@ -37,6 +37,24 @@ index 9bdaaa3ef199f3e443f77106a9ac7eddcc4b05b6..461364d3af60a954a766ef285ddef147
const fileDiff = this.getLatestDiff();
if (fileDiff == null || fileDiff.editSessionDirty !== true) return false;
const { collapsedContextThreshold = 1 } = this.options;
diff --git a/dist/index.d.ts b/dist/index.d.ts
index 10626ccf2c08151951c7a42a7a014ba4b147869b..175f05ac3a4638d5f3df8f3f09b432da3d61e61c 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -1,3 +1,4 @@
+export { iterateOverDiff } from "./utils/iterateOverDiff.js";
import { AnnotationLineMap, AnnotationSide, AnnotationSpan, AppliedThemeStyleCache, BaseCodeOptions, BaseDiffOptions, BaseDiffOptionsWithDefaults, BundledLanguage, ChangeContent, ChangeTypes, CodeColumnType, CodeToHastOptions, CodeViewDiffItem, CodeViewFileItem, CodeViewItem, CodeViewItemScrollTarget, CodeViewLayout, CodeViewLineScrollTarget, CodeViewPositionScrollTarget, CodeViewRangeScrollTarget, CodeViewScrollBehavior, CodeViewScrollTarget, ConflictResolverTypes, ContextContent, CreatePatchOptionsNonabortable, CustomPreProperties, DecorationItem, DiffAcceptRejectHunkConfig, DiffAcceptRejectHunkType, DiffFileInput, DiffIndicators, DiffLineAnnotation, DiffLineEventBaseProps, DiffTokenEventBaseProps, DiffsHighlighter, DiffsThemeNames, ExpansionDirections, ExtensionFormatMap, FileContents, FileDiffContentsLoader, FileDiffLoadedChangedFiles, FileDiffLoadedFiles, FileDiffLoadedPureRenamedFile, FileDiffMetadata, FileHeaderRenderMode, ForceDiffPlainTextOptions, ForceFilePlainTextOptions, GapSpan, HighlightedToken, HighlighterTypes, Hunk, HunkData, HunkExpansionRegion, HunkLineType, HunkSeparators, LanguageRegistration, LineAnnotation, LineDiffTypes, LineEventBaseProps, LineInfo, LineSpans, LineTypes, MaybeDiffFileInput, MergeConflictActionPayload, MergeConflictMarkerRow, MergeConflictMarkerRowType, MergeConflictRegion, MergeConflictResolution, NumericScrollLineAnchor, ObservedAnnotationNodes, ObservedGridNodes, ParsedPatch, PendingCodeViewLayoutReset, PostRenderPhase, PrePropertiesConfig, ProcessFileConflictData, RenderDiffFilesResult, RenderDiffOptions, RenderDiffResult, RenderFileMetadata, RenderFileOptions, RenderFileResult, RenderHeaderFilenameSuffixCallback, RenderHeaderMetadataCallback, RenderHeaderPrefixCallback, RenderRange, RenderWindow, RenderedDiffASTCache, RenderedFileASTCache, SelectedLineRange, SelectionPoint, SelectionSide, SharedRenderState, ShikiTransformer, SmoothScrollSettings, StickySpecs, SupportedLanguages, ThemeRegistration, ThemeRegistrationResolved, ThemeTypes, ThemedDiffResult, ThemedFileResult, ThemedToken, ThemesType, TokenEventBase, VirtualFileMetrics, VirtualWindowSpecs } from "./types.js";
import { FileDiffEditCompleteEvent, FileEditCompleteEvent } from "./editor/types.js";
import { GetHoveredLineResult, GetLineIndexUtility, InteractionManager, InteractionManagerBaseOptions, InteractionManagerMode, InteractionManagerOptions, LogTypes, MergeConflictActionTarget, OnDiffLineClickProps, OnDiffLineEnterLeaveProps, OnLineClickProps, OnLineEnterLeaveProps, OnTokenEventProps, SelectionWriteOptions, pluckInteractionOptions } from "./managers/InteractionManager.js";
diff --git a/dist/index.js b/dist/index.js
index 5fa10ffa775e8f5e933b6fa415935ef6ab11c4dd..d64b134796b5dcf9bfb167c5ec700d9ea6a90c08 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1,3 +1,4 @@
+export { iterateOverDiff } from "./utils/iterateOverDiff.js";
import { ALTERNATE_FILE_NAMES_GIT, CODE_VIEW_FOOTER_ATTRIBUTE, CODE_VIEW_HEADER_ATTRIBUTE, COMMIT_METADATA_SPLIT, CORE_CSS_ATTRIBUTE, CUSTOM_HEADER_SLOT_ID, DEFAULT_CODE_VIEW_FILE_METRICS, DEFAULT_CODE_VIEW_LAYOUT, DEFAULT_COLLAPSED_CONTEXT_THRESHOLD, DEFAULT_EXPANDED_REGION, DEFAULT_RENDER_RANGE, DEFAULT_SMOOTH_SCROLL_SETTINGS, DEFAULT_THEMES, DEFAULT_TOKENIZE_MAX_LENGTH, DEFAULT_VIRTUAL_FILE_METRICS, DIFFS_DEVELOPMENT_BUILD, DIFFS_SCROLLBAR_GUTTER_MEASURED_PROPERTY, DIFFS_SCROLLBAR_MEASURE_ATTRIBUTE, DIFFS_TAG_NAME, EMPTY_RENDER_RANGE, FILENAME_HEADER_REGEX, FILENAME_HEADER_REGEX_GIT, FILE_CONTEXT_BLOB, GIT_DIFF_FILE_BREAK_REGEX, HEADER_FILENAME_SUFFIX_SLOT_ID, HEADER_METADATA_SLOT_ID, HEADER_PREFIX_SLOT_ID, HUNK_HEADER, INDEX_LINE_METADATA, MERGE_CONFLICT_BASE_MARKER_REGEX, MERGE_CONFLICT_END_MARKER_REGEX, MERGE_CONFLICT_SEPARATOR_MARKER_REGEX, MERGE_CONFLICT_START_MARKER_REGEX, SPLIT_WITH_NEWLINES, THEME_CSS_ATTRIBUTE, UNIFIED_DIFF_FILE_BREAK_REGEX, UNSAFE_CSS_ATTRIBUTE } from "./constants.js";
import { AttachedLanguages, RegisteredCustomLanguages, ResolvedLanguages, ResolvingLanguages } from "./highlighter/languages/constants.js";
import { areLanguagesAttached } from "./highlighter/languages/areLanguagesAttached.js";
diff --git a/dist/managers/InteractionManager.d.ts b/dist/managers/InteractionManager.d.ts
index 9f243f97a6eb28c22176b6b3abba022756571fdc..806bb52ac15de68044ea5a5cfbb5016eae38ccae 100644
--- a/dist/managers/InteractionManager.d.ts
+4
View File
@@ -24,3 +24,7 @@ Drag completion rechecks the predicate. Other consumers retain the upstream defa
Keep `useTokenTransformer: true` on the pool. Coverage lives in
`pierre-diff-worker-edit-cache.test.ts` and the large-diff Electron specs. Remove
the patch when an upstream release provides the same behavior.
The package entrypoint also exposes its existing `iterateOverDiff` iterator.
Search uses it to map original/context line numbers onto virtualized split and
unified rows, reusing Pierre's hunk logic without copying its implementation.
+3 -3
View File
@@ -109,7 +109,7 @@ overrides:
monaco-editor>dompurify: 3.4.13
patchedDependencies:
'@pierre/diffs@1.4.1': cbcdcdfecdf4f666a96870b0bac2297cdb8cbcc5fbb12aece2280772aa8f8362
'@pierre/diffs@1.4.1': cf3934292c1f82103136fce3b19bfe68097c39175218fa78c2ac3bbc3f332b0d
'@vscode/windows-process-tree@0.8.0': f8ea245391c94da5770045aeea01fa6de466c2199c6ef46b5b769b398aa9823e
'@xterm/addon-ligatures@0.11.0-beta.300': 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
'@xterm/addon-search@0.17.0-beta.300': eee5338dd2621ece46e79c61ec06766cd7fadaf79ffdb24e2a8ab68e97ef31f0
@@ -143,7 +143,7 @@ importers:
version: 2.5.6
'@pierre/diffs':
specifier: 1.4.1
version: 1.4.1(patch_hash=cbcdcdfecdf4f666a96870b0bac2297cdb8cbcc5fbb12aece2280772aa8f8362)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
version: 1.4.1(patch_hash=cf3934292c1f82103136fce3b19bfe68097c39175218fa78c2ac3bbc3f332b0d)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@xterm/addon-serialize':
specifier: 0.15.0-beta.300
version: 0.15.0-beta.300(patch_hash=851eac3d75e6d8c013b9f4c053e61d824b23965cb19ecc28e335e05059f3a294)(@xterm/xterm@6.1.0-beta.303(patch_hash=98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d))
@@ -8280,7 +8280,7 @@ snapshots:
tslib: 2.8.1
webcrypto-core: 1.9.2
'@pierre/diffs@1.4.1(patch_hash=cbcdcdfecdf4f666a96870b0bac2297cdb8cbcc5fbb12aece2280772aa8f8362)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
'@pierre/diffs@1.4.1(patch_hash=cf3934292c1f82103136fce3b19bfe68097c39175218fa78c2ac3bbc3f332b0d)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@pierre/theme': 2.0.0
'@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.4.3)
@@ -133,7 +133,7 @@ function codePointAt(text: string, index: number): string | undefined {
return codePoint === undefined ? undefined : String.fromCodePoint(codePoint)
}
function isWholeWordMatch(text: string, start: number, end: number): boolean {
export function isWholeWordMatch(text: string, start: number, end: number): boolean {
const before = codePointBefore(text, start)
const after = codePointAt(text, end)
return !isWordCharacter(before) && !isWordCharacter(after)
@@ -0,0 +1,199 @@
import {
CaseSensitive,
ChevronDown,
ChevronRight,
ChevronUp,
Regex,
Replace,
ReplaceAll,
WholeWord,
X
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import type { DiffSearchQuery } from './pierre-diff-search'
export type DiffSearchSide = 'additions' | 'deletions'
export type PierreDiffSearchBarProps = {
inputRef: React.RefObject<HTMLInputElement | null>
query: DiffSearchQuery
side: DiffSearchSide
replacement: string
replaceOpen: boolean
canReplace: boolean
canNavigate: boolean
canReplaceAll: boolean
status: string
onQuery: (query: DiffSearchQuery) => void
onSide: (side: DiffSearchSide) => void
onReplacement: (text: string) => void
onToggleReplace: () => void
onReplace: (all: boolean) => void
onNavigate: (direction: 1 | -1) => void
onClose: () => void
}
function SearchButton({
label,
children,
...props
}: React.ComponentProps<typeof Button> & { label: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={label}
onMouseDown={(event) => event.preventDefault()}
{...props}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}
export function PierreDiffSearchBar(props: PierreDiffSearchBarProps) {
const { query, onQuery } = props
return (
<div className="sticky top-0 z-20 h-0" data-diff-search>
<div
className="absolute right-2 top-2 flex max-w-full flex-col gap-1 rounded-md border border-border bg-background p-1 text-foreground shadow-floating"
onKeyDown={(event) => event.stopPropagation()}
>
<div className="flex items-center gap-1">
{props.canReplace && (
<SearchButton
label="Toggle replace"
aria-expanded={props.replaceOpen}
onClick={props.onToggleReplace}
>
{props.replaceOpen ? <ChevronDown /> : <ChevronRight />}
</SearchButton>
)}
<Input
ref={props.inputRef}
aria-label="Find in diff"
placeholder="Find"
value={query.text}
className="h-7 min-w-0 w-40 text-xs"
onChange={(event) => onQuery({ ...query, text: event.target.value })}
onKeyDown={(event) => {
if (isImeCompositionKeyDown(event)) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
props.onNavigate(event.shiftKey ? -1 : 1)
}
if (event.key === 'Escape') {
event.preventDefault()
props.onClose()
}
}}
/>
{(
[
['matchCase', 'Match case', CaseSensitive],
['wholeWord', 'Match whole word', WholeWord],
['regex', 'Use regular expression', Regex]
] as const
).map(([key, label, Icon]) => (
<SearchButton
key={key}
label={label}
aria-pressed={query[key]}
className={query[key] ? 'bg-accent text-accent-foreground' : undefined}
onClick={() => onQuery({ ...query, [key]: !query[key] })}
>
<Icon />
</SearchButton>
))}
<SearchButton
label="Previous match"
disabled={!props.canNavigate}
onClick={() => props.onNavigate(-1)}
>
<ChevronUp />
</SearchButton>
<SearchButton
label="Next match"
disabled={!props.canNavigate}
onClick={() => props.onNavigate(1)}
>
<ChevronDown />
</SearchButton>
<SearchButton label="Close search" onClick={props.onClose}>
<X />
</SearchButton>
</div>
<div className="flex items-center gap-1">
{(
[
['deletions', 'Original'],
['additions', 'Modified']
] as const
).map(([side, label]) => (
<Button
key={side}
variant="ghost"
size="xs"
aria-pressed={props.side === side}
className={props.side === side ? 'bg-accent text-accent-foreground' : undefined}
onClick={() => props.onSide(side)}
>
{label}
</Button>
))}
<span className="ml-auto px-1 text-xs text-muted-foreground" role="status">
{props.status}
</span>
</div>
{props.canReplace && props.replaceOpen && (
<div className="flex items-center gap-1">
<Input
aria-label="Replace in diff"
placeholder="Replace"
value={props.replacement}
className="h-7 min-w-0 text-xs"
onChange={(event) => props.onReplacement(event.target.value)}
onKeyDown={(event) => {
if (isImeCompositionKeyDown(event)) {
return
}
if (event.key === 'Enter') {
event.preventDefault()
props.onReplace(false)
}
if (event.key === 'Escape') {
event.preventDefault()
props.onClose()
}
}}
/>
<SearchButton
label="Replace"
disabled={!props.canNavigate}
onClick={() => props.onReplace(false)}
>
<Replace />
</SearchButton>
<SearchButton
label="Replace all"
disabled={!props.canReplaceAll}
onClick={() => props.onReplace(true)}
>
<ReplaceAll />
</SearchButton>
</div>
)}
</div>
</div>
)
}
@@ -25,6 +25,7 @@ import {
type PierreDiffCommentAnnotation
} from './pierre-diff-comment-annotations'
import { usePierreDiffFind } from './use-pierre-diff-find'
import { PierreDiffSearchBar } from './PierreDiffSearchBar'
import { installPierreContextualCopy } from './pierre-diff-context-copy'
import { editorShortcutMatches } from '../editor-shortcuts'
import { usePierreDiffNoteNavigation } from './use-pierre-diff-note-navigation'
@@ -103,9 +104,17 @@ export function PierreDiffSurface({
const containerRef = useRef<HTMLDivElement | null>(null)
const editorRef = useRef<Editor<'file-diff', PierreDiffAnnotationData, undefined> | null>(null)
const onEditChangeRef = useRef(onEditChange)
onEditChangeRef.current = onEditChange
const { editEnabled, handleContainerKeyDown, handleContainerBlur, handleEditorAttach } =
usePierreDiffFind({ isEditable, containerRef })
const {
searchBar,
handleContainerKeyDown,
onPointerDown,
onPostRender: searchPostRender,
onEditChange: searchEditChange
} = usePierreDiffFind({ isEditable, containerRef, editorRef, fileDiff })
onEditChangeRef.current = (file) => {
onEditChange?.(file)
searchEditChange()
}
const navigateToNote = usePierreDiffNoteNavigation({ worktreeId, filePath, comments })
const commentableLines = useMemo(
() => (commentableLineNumbers ? new Set(commentableLineNumbers) : null),
@@ -148,6 +157,7 @@ export function PierreDiffSurface({
onPostRender: (node: HTMLElement, instance: PierreDiffInstance, phase: PostRenderPhase) => {
onPostRender?.(node, phase, instance)
navigateToNote(node, phase, instance)
searchPostRender(node, phase, instance)
}
}),
[
@@ -157,6 +167,7 @@ export function PierreDiffSurface({
onPostRender,
onAddComment,
navigateToNote,
searchPostRender,
commentableLines,
addCommentLabel
]
@@ -179,14 +190,10 @@ export function PierreDiffSurface({
{
onAttach: (editor) => {
editorRef.current = editor
handleEditorAttach(editor)
},
onComplete: () => {
editorRef.current = null
},
// Why: Cmd+F opens edit mode even on read-only diffs, so ignore changes
// unless this surface can actually save. Otherwise a stray keystroke in
// the find panel marks a staged or branch section dirty with no save path.
onChange: (event) => {
if (isEditable) {
onEditChangeRef.current?.(event.file)
@@ -196,7 +203,7 @@ export function PierreDiffSurface({
isEditable ? editStateKey : undefined,
fileDiff
),
[handleEditorAttach, isEditable, editStateKey, fileDiff]
[isEditable, editStateKey, fileDiff]
)
const renderAnnotation = useCallback(
(annotation: PierreDiffCommentAnnotation) =>
@@ -242,6 +249,7 @@ export function PierreDiffSurface({
event.currentTarget.focus({ preventScroll: true })
}
}}
onPointerDownCapture={onPointerDown}
onPointerUp={() => {
// Pierre must consume the native caret before a keystroke can arrive.
if (isEditable) {
@@ -258,8 +266,8 @@ export function PierreDiffSurface({
}
handleContainerKeyDown(event)
}}
onBlur={handleContainerBlur}
>
{searchBar && <PierreDiffSearchBar {...searchBar} />}
{/* Why: @pierre/diffs throws from its own ref teardown on some remounts
("A FileDiff instance should exist when unmounting"). Contain it to the
one file instead of letting an experimental dependency take down the
@@ -277,7 +285,7 @@ export function PierreDiffSurface({
options={options}
style={style}
metrics={metrics}
edit={editEnabled}
edit={isEditable}
editorOptions={editorOptions}
lineAnnotations={lineAnnotations}
renderAnnotation={renderAnnotation}
@@ -3,6 +3,7 @@ import { DEFAULT_VIRTUAL_FILE_METRICS, type FileDiffOptions, type ThemesType } f
import type { CreatePatchOptionsNonabortable } from 'diff'
import type { GlobalSettings } from '../../../../../shared/global-settings-types'
import { computeDiffEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom'
import { PIERRE_SEARCH_CSS } from './pierre-diff-search-view'
import { buildFontFamily } from '@/components/terminal-pane/layout-serialization'
/**
@@ -62,7 +63,7 @@ export function buildPierreDiffOptions<LAnnotation>({
// from here — without it Pierre asks Shiki for its unregistered default
// `pierre-dark` and the render throws instead of painting.
theme: PIERRE_DIFF_THEMES,
unsafeCSS: PIERRE_GUTTER_BUTTON_CSS,
unsafeCSS: PIERRE_GUTTER_BUTTON_CSS + PIERRE_SEARCH_CSS,
themeType: settings?.theme ?? 'system',
overflow: settings?.diffWordWrap ? 'wrap' : 'scroll',
parseDiffOptions: buildPierreParseDiffOptions(settings?.diffShowWhitespace),
@@ -0,0 +1,154 @@
import { iterateOverDiff, type FileDiffMetadata } from '@pierre/diffs'
import type { DiffSearchMatch } from './pierre-diff-search'
import type { DiffSearchSide } from './PierreDiffSearchBar'
export const PIERRE_SEARCH_CSS = `
::highlight(orca-diff-find) { background-color: color-mix(in srgb, var(--markdown-search-match) 50%, transparent); color: inherit; }
::highlight(orca-diff-find-active) { background-color: color-mix(in srgb, var(--markdown-search-match-active) 60%, transparent); color: inherit; }
`
const highlights = new Map<object, { matches: Range[]; active: Range[] }>()
export function paintPierreSearchHighlights(
owner: object,
ranges?: { matches: Range[]; active: Range[] }
) {
if (ranges) {
highlights.set(owner, ranges)
} else {
highlights.delete(owner)
}
if (typeof Highlight === 'undefined' || !CSS.highlights) {
return
}
for (const [name, key] of [
['orca-diff-find', 'matches'],
['orca-diff-find-active', 'active']
] as const) {
const highlight = new Highlight()
for (const entry of highlights.values()) {
for (const range of entry[key]) {
highlight.add(range)
}
}
if (highlight.size) {
CSS.highlights.set(name, highlight)
} else {
CSS.highlights.delete(name)
}
}
}
export function pierreRowTextRange(row: HTMLElement, start: number, end: number): Range | null {
const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT)
const range = document.createRange()
let offset = 0
let started = false
let node: Node | null
while ((node = walker.nextNode())) {
const length = node.textContent?.length ?? 0
if (!started && offset + length >= start) {
range.setStart(node, Math.max(0, start - offset))
started = true
}
if (started && offset + length >= end) {
range.setEnd(node, Math.max(0, end - offset))
return range
}
offset += length
}
return null
}
export function getPierreSearchRanges(
host: HTMLElement,
diff: FileDiffMetadata,
side: DiffSearchSide,
matches: DiffSearchMatch[],
active?: DiffSearchMatch
) {
const rows = [
...(host.shadowRoot?.querySelectorAll<HTMLElement>('[data-code] [data-line]') ?? [])
]
const split = Boolean(host.shadowRoot?.querySelector('[data-code][data-deletions]'))
const indexPart = split ? 1 : 0
const indexes = rows
.map((row) => Number(row.dataset.lineIndex?.split(',')[indexPart]))
.filter(Number.isFinite)
const lineByIndex = new Map<number, number>()
if (indexes.length) {
iterateOverDiff({
diff,
diffStyle: split ? 'split' : 'unified',
expandedHunks: true,
startingLine: Math.min(...indexes),
totalLines: Math.max(...indexes) - Math.min(...indexes) + 1,
callback: ({ additionLine, deletionLine }) => {
const line = side === 'additions' ? additionLine : deletionLine
if (line) {
lineByIndex.set(split ? line.splitLineIndex : line.unifiedLineIndex, line.lineNumber)
}
}
})
}
const result: { matches: Range[]; active: Range[] } = { matches: [], active: [] }
for (const row of rows) {
if (split && !row.closest(`[data-code][data-${side}]`)) {
continue
}
const line = lineByIndex.get(Number(row.dataset.lineIndex?.split(',')[indexPart]))
if (line === undefined) {
continue
}
// Matches are ordered, so only visit those intersecting this mounted row.
let lo = 0,
hi = matches.length
while (lo < hi) {
const mid = (lo + hi) >>> 1
if (matches[mid].range.end.line < line - 1) {
lo = mid + 1
} else {
hi = mid
}
}
for (let i = lo; i < matches.length && matches[i].range.start.line <= line - 1; i++) {
const match = matches[i]
const start = match.range.start.line === line - 1 ? match.range.start.character : 0
const end =
match.range.end.line === line - 1
? match.range.end.character
: (row.textContent?.length ?? 0)
const range = pierreRowTextRange(row, start, end)
if (range) {
result.matches.push(range)
if (match === active) {
result.active.push(range)
}
}
}
}
return result
}
export function pierreSearchRevealLine(
diff: FileDiffMetadata,
lineNumber: number,
side: DiffSearchSide
): number {
if (side === 'additions') {
return lineNumber
}
let modifiedLine = lineNumber
iterateOverDiff({
diff,
diffStyle: 'unified',
expandedHunks: true,
callback: ({ deletionLine, additionLine }) => {
if (deletionLine?.lineNumber !== lineNumber) {
return
}
modifiedLine = additionLine?.lineNumber ?? lineNumber
return true
}
})
return modifiedLine
}
@@ -0,0 +1,45 @@
import { expect, it } from 'vitest'
import { searchPierreDiff } from './pierre-diff-search'
function search(text: string, query: string, options = {}, replacement = '') {
return searchPierreDiff({
text,
query: { text: query, regex: false, matchCase: false, wholeWord: false, ...options },
replacement
})
}
it('searches multiline text and maps CRLF positions', () => {
const result = search('before\r\nfirst\r\nsecond\r\nafter', 'first\r\nsecond')
expect(result.matches.map((match) => match.range)).toEqual([
{ start: { line: 1, character: 0 }, end: { line: 2, character: 6 } }
])
})
it('supports case, Unicode whole words and literal regex characters', () => {
expect(search('Cat cat catapult caté', 'cat', { wholeWord: true }).matches).toHaveLength(2)
expect(search('Cat cat', 'cat', { matchCase: true }).matches).toHaveLength(1)
expect(search('a.b axb', 'a.b').matches).toHaveLength(1)
})
it('expands capture replacements with lookbehind and named groups', () => {
const text = 'prefix key:42 suffix'
const pattern = '(?<=key:)(?<number>\\d+)'
const template = "$<number>-$1-$$-$&-$`-$'"
const match = search(text, pattern, { regex: true }, template).matches[0]
const replaced = text.slice(0, match.start) + match.replacement + text.slice(match.end)
expect(replaced).toBe(text.replace(new RegExp(pattern, 'gmu'), template))
})
it('reports invalid expressions and makes progress through zero-width Unicode matches', () => {
expect(search('text', '[', { regex: true }).error).toBe('Invalid regular expression')
expect(search('😀a', '(?=.)', { regex: true }).matches.map((match) => match.start)).toEqual([
0, 2
])
})
it('caps results without allowing replace-all to silently replace a prefix', () => {
const result = search('x '.repeat(10_002), 'x')
expect(result.matches).toHaveLength(10_000)
expect(result.truncated).toBe(true)
})
@@ -0,0 +1,87 @@
import { TextDocument, type Range } from '@pierre/diffs/edit'
import { isWholeWordMatch } from '../markdown-preview-search'
export type DiffSearchQuery = {
text: string
matchCase: boolean
wholeWord: boolean
regex: boolean
}
export type DiffSearchMatch = { range: Range; start: number; end: number; replacement: string }
export type DiffSearchRequest = { text: string; query: DiffSearchQuery; replacement: string }
export type DiffSearchResult = { matches: DiffSearchMatch[]; truncated: boolean; error?: string }
export const MAX_DIFF_SEARCH_MATCHES = 10_000
function replacementText(template: string, match: RegExpExecArray, text: string): string {
return template.replace(/\$(\$|&|`|'|\d{1,2}|<[^>]+>)/g, (token, name: string) => {
if (name === '$') {
return '$'
}
if (name === '&') {
return match[0]
}
if (name === '`') {
return text.slice(0, match.index)
}
if (name === "'") {
return text.slice(match.index + match[0].length)
}
if (name.startsWith('<')) {
return match.groups ? (match.groups[name.slice(1, -1)] ?? '') : token
}
const index = Number(name)
if (index > 0 && index < match.length) {
return match[index] ?? ''
}
const first = Number(name[0])
return name.length === 2 && first > 0 && first < match.length
? (match[first] ?? '') + name[1]
: token
})
}
// Runs only in a terminable worker; even pathological regexes cannot block typing.
export function searchPierreDiff({
text,
query,
replacement
}: DiffSearchRequest): DiffSearchResult {
if (!query.text) {
return { matches: [], truncated: false }
}
const source = query.regex ? query.text : query.text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
let pattern: RegExp
try {
pattern = new RegExp(source, query.matchCase ? 'gmu' : 'gimu')
} catch {
return { matches: [], truncated: false, error: 'Invalid regular expression' }
}
const document = new TextDocument('diff-search', text)
const matches: DiffSearchMatch[] = []
let replacementCharacters = 0
let match: RegExpExecArray | null
while ((match = pattern.exec(text))) {
const start = match.index
const end = start + match[0].length
if (!query.wholeWord || isWholeWordMatch(text, start, end)) {
if (matches.length === MAX_DIFF_SEARCH_MATCHES) {
return { matches, truncated: true }
}
const nextReplacement = query.regex ? replacementText(replacement, match, text) : replacement
replacementCharacters += nextReplacement.length
if (replacementCharacters > 16_000_000) {
return { matches: [], truncated: false, error: 'Replacement is too large' }
}
matches.push({
start,
end,
range: { start: document.positionAt(start), end: document.positionAt(end) },
replacement: nextReplacement
})
}
if (start === end) {
pattern.lastIndex += (text.codePointAt(end) ?? 0) > 0xffff ? 2 : 1
}
}
return { matches, truncated: false }
}
@@ -0,0 +1,17 @@
import {
searchPierreDiff,
type DiffSearchRequest,
type DiffSearchResult
} from './pierre-diff-search'
const scope = globalThis as unknown as {
onmessage: (event: MessageEvent<DiffSearchRequest>) => void
postMessage: (response: DiffSearchResult) => void
}
scope.onmessage = ({ data }) => {
try {
scope.postMessage(searchPierreDiff(data))
} catch {
scope.postMessage({ matches: [], truncated: false, error: 'Could not search this file' })
}
}
@@ -1,72 +1,118 @@
// @vitest-environment happy-dom
import { act, renderHook, cleanup } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, expect, it, vi } from 'vitest'
import type { FileDiffMetadata } from '@pierre/diffs'
import { usePierreDiffFind } from './use-pierre-diff-find'
const { results } = vi.hoisted(() => ({ results: vi.fn() }))
vi.mock('./use-pierre-diff-search-results', () => ({ usePierreDiffSearchResults: results }))
vi.mock('./use-pierre-diff-search-view', () => ({ usePierreDiffSearchView: () => vi.fn() }))
vi.mock('../editor-shortcuts', () => ({
editorShortcutMatches: (_: string, event: KeyboardEvent) => event.key === 'f' && event.ctrlKey
editorShortcutMatches: (action: string, event: KeyboardEvent) =>
event.key === (action === 'editor.find' ? 'f' : 'h') && event.ctrlKey
}))
vi.mock('@/lib/shortcut-platform', () => ({ getShortcutPlatform: () => 'linux' }))
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.clearAllMocks()
document.body.replaceChildren()
})
function setup(isEditable: boolean) {
vi.useFakeTimers()
const container = document.createElement('div')
const host = document.createElement('diffs-container')
const shadow = host.attachShadow({ mode: 'open' })
container.append(host)
document.body.append(container)
const { result } = renderHook(() =>
usePierreDiffFind({ isEditable, containerRef: { current: container } })
)
const attachContent = () => {
const content = document.createElement('div')
content.setAttribute('contenteditable', 'true')
shadow.append(content)
return content
const editor = {
getText: vi.fn(() => 'modified'),
applyEdits: vi.fn(),
setSelections: vi.fn(),
focus: vi.fn()
}
const find = () =>
const fileDiff = { additionLines: ['modified'], deletionLines: ['original'] } as FileDiffMetadata
const containerRef = { current: container }
const editorRef = { current: editor } as never
const { result } = renderHook(() =>
usePierreDiffFind({
isEditable,
containerRef,
editorRef,
fileDiff
})
)
const find = (key = 'f') =>
act(() =>
result.current.handleContainerKeyDown({
key: 'f',
key,
nativeEvent: new KeyboardEvent('keydown', { key, ctrlKey: true }),
ctrlKey: true,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} as unknown as React.KeyboardEvent<HTMLElement>)
)
return { result, attachContent, find }
return { result, editor, find }
}
describe('Pierre find shortcut', () => {
it('opens on the first press when the editable surface is already attached', () => {
const { attachContent, find } = setup(true)
const content = attachContent()
const search = vi.fn()
content.addEventListener('keydown', search)
find()
act(() => vi.runOnlyPendingTimers())
expect(search).toHaveBeenCalledOnce()
expect(search.mock.calls[0][0]).toMatchObject({ key: 'f', ctrlKey: true })
it('opens find on the first press without creating a writable read-only session', () => {
results.mockReturnValue(null)
const { result, find, editor } = setup(false)
find()
expect(result.current.searchBar?.canReplace).toBe(false)
act(() => result.current.searchBar?.onReplace(true))
expect(editor.applyEdits).not.toHaveBeenCalled()
act(() => result.current.searchBar?.onClose())
expect(result.current.searchBar).toBeNull()
})
it('searches original content and never replaces on that side', () => {
results.mockReturnValue(null)
const { result, find } = setup(true)
find()
act(() => result.current.searchBar?.onSide('deletions'))
expect(results.mock.lastCall?.[0].text).toBe('original')
expect(result.current.searchBar?.canReplace).toBe(false)
})
it('fences replacement against edits made after async search started', () => {
results.mockReturnValue({
matches: [
{
range: { start: { line: 1, character: 0 }, end: { line: 1, character: 8 } },
replacement: 'new'
}
],
truncated: false
})
const { result, find, editor } = setup(true)
find('h')
expect(result.current.searchBar?.replaceOpen).toBe(true)
editor.getText.mockReturnValue('newer edit')
act(() => result.current.searchBar?.onReplace(true))
expect(editor.applyEdits).not.toHaveBeenCalled()
})
it('opens after a read-only find session attaches and cancels on Escape', () => {
const { result, attachContent, find } = setup(false)
find()
expect(result.current.editEnabled).toBe(true)
const content = attachContent()
const search = vi.fn()
content.addEventListener('keydown', search)
act(() => result.current.handleEditorAttach({ focus: vi.fn() }))
it('moves between results with F3 and Shift+F3', () => {
const range = { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } }
results.mockReturnValue({ matches: [{ range }, { range }], truncated: false })
const { result, find } = setup(false)
find()
act(() =>
result.current.searchBar?.onQuery({
text: 'm',
regex: false,
matchCase: false,
wholeWord: false
})
)
const key = (shiftKey: boolean) =>
act(() =>
result.current.handleContainerKeyDown({ key: 'Escape' } as React.KeyboardEvent<HTMLElement>)
result.current.handleContainerKeyDown({
key: 'F3',
shiftKey,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} as unknown as React.KeyboardEvent<HTMLElement>)
)
act(() => vi.runOnlyPendingTimers())
expect(search).not.toHaveBeenCalled()
expect(result.current.editEnabled).toBe(false)
})
key(false)
expect(result.current.searchBar?.status).toBe('2/2')
key(true)
expect(result.current.searchBar?.status).toBe('1/2')
})
@@ -1,162 +1,217 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { EditorFocusOptions } from '@pierre/diffs/edit'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { resolveFindAgainShortcut, type Editor } from '@pierre/diffs/edit'
import type { FileDiffMetadata } from '@pierre/diffs'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event'
import { editorShortcutMatches } from '../editor-shortcuts'
import type { PierreDiffAnnotationData } from './pierre-diff-comment-annotations'
import type { DiffSearchQuery } from './pierre-diff-search'
import type { DiffSearchSide, PierreDiffSearchBarProps } from './PierreDiffSearchBar'
import { usePierreDiffSearchResults } from './use-pierre-diff-search-results'
import { usePierreDiffSearchView } from './use-pierre-diff-search-view'
/**
* Pierre only accepts key events whose target is its own content element, so
* reach into the shadow root rather than trusting whatever holds focus — at
* attach time focus has not landed there yet.
*/
function findPierreContentElement(container: HTMLElement | null): HTMLElement | null {
const host = container?.querySelector('diffs-container')
const editable = host?.shadowRoot?.querySelector('[contenteditable="true"]')
return editable instanceof HTMLElement ? editable : null
}
type DiffEditor = Editor<'file-diff', PierreDiffAnnotationData, undefined>
// Why: Pierre only ships its search panel with edit mode, and it has no
// programmatic command dispatch — replay the shortcut its own listener expects.
function dispatchPierreOpenSearchPanel(target: HTMLElement): void {
const isMac = getShortcutPlatform() === 'darwin'
target.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'f',
code: 'KeyF',
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
composed: true,
cancelable: true
})
)
}
// Why: only `focus` is needed here, so stay structural and annotation-agnostic.
type FocusableEditor = { focus: (options?: EditorFocusOptions) => void }
export type PierreDiffFind = {
/** True when the surface should mount an edit session (real editing or find). */
editEnabled: boolean
/** Capture-phase keydown handler for the surface container. */
handleContainerKeyDown: (event: React.KeyboardEvent<HTMLElement>) => void
/** Ends a find-only session when focus leaves the surface. */
handleContainerBlur: (event: React.FocusEvent<HTMLElement>) => void
/** Pass to `editorOptions.onAttach` so the panel opens on the first press. */
handleEditorAttach: (editor: FocusableEditor) => void
/** Leaves a find-only session so a read-only diff stops accepting input. */
exitFind: () => void
}
/**
* Bridges our ⌘F keybinding onto Pierre's edit-mode search panel. On a
* read-only diff the session exists only for find, and nothing is ever written
* back, so dismissing it discards any stray keystrokes.
*/
export function usePierreDiffFind({
isEditable,
containerRef
containerRef,
editorRef,
fileDiff
}: {
isEditable: boolean
containerRef: React.RefObject<HTMLElement | null>
}): PierreDiffFind {
const [findActive, setFindActive] = useState(false)
const pendingFindRef = useRef(false)
const replayingFindRef = useRef(false)
const findFrameRef = useRef<number | null>(null)
const openMountedSearch = useCallback(() => {
if (findFrameRef.current !== null) {
cancelAnimationFrame(findFrameRef.current)
editorRef: React.RefObject<DiffEditor | null>
fileDiff: FileDiffMetadata
}) {
const [open, setOpen] = useState(false)
const [side, setSide] = useState<DiffSearchSide>('additions')
const sideRef = useRef<DiffSearchSide>('additions')
const inputRef = useRef<HTMLInputElement | null>(null)
const [query, setQuery] = useState<DiffSearchQuery>({
text: '',
regex: false,
matchCase: false,
wholeWord: false
})
const [replacement, setReplacement] = useState('')
const [replaceOpen, setReplaceOpen] = useState(false)
const [revision, setRevision] = useState(0)
const [selection, setSelection] = useState<{ request: object; index: number }>()
const request = useMemo(
() =>
open
? {
text:
side === 'deletions'
? fileDiff.deletionLines.join('')
: (editorRef.current?.getText() ?? fileDiff.additionLines.join('')),
query,
replacement,
revision
}
: null,
[open, side, fileDiff, query, replacement, revision, editorRef]
)
const result = usePierreDiffSearchResults(request)
const matches = result?.matches
const index = selection?.request === request ? selection.index : 0
const active = matches?.[index]
const onPostRender = usePierreDiffSearchView({ fileDiff, side, matches, active })
const canReplace = isEditable && side === 'additions'
useEffect(() => {
if (canReplace && active) {
editorRef.current?.setSelections([{ ...active.range, direction: 'forward' }])
}
findFrameRef.current = requestAnimationFrame(() => {
findFrameRef.current = null
const target = findPierreContentElement(containerRef.current)
if (!target || !pendingFindRef.current) {
}, [canReplace, active, editorRef])
const close = useCallback(() => {
setOpen(false)
if (isEditable && side === 'additions') {
editorRef.current?.focus({ preventScroll: true })
} else {
containerRef.current?.focus({ preventScroll: true })
}
}, [isEditable, side, editorRef, containerRef])
const navigate = useCallback(
(direction: 1 | -1) => {
if (!request || !matches?.length) {
return
}
pendingFindRef.current = false
target.focus({ preventScroll: true })
replayingFindRef.current = true
try {
dispatchPierreOpenSearchPanel(target)
} finally {
replayingFindRef.current = false
}
})
}, [containerRef])
useEffect(
() => () => {
if (findFrameRef.current !== null) {
cancelAnimationFrame(findFrameRef.current)
}
setSelection({ request, index: (index + direction + matches.length) % matches.length })
},
[]
[request, matches, index]
)
const replace = (all: boolean) => {
const editor = editorRef.current
// Async matching must never apply offsets from an older document or query.
if (
!canReplace ||
!editor ||
!request ||
!result ||
result.error ||
request.text !== editor.getText() ||
(all && result.truncated)
) {
return
}
const targets = all ? result.matches : active ? [active] : []
if (!targets.length) {
return
}
editor.applyEdits(targets.map(({ range, replacement: newText }) => ({ range, newText })))
setRevision((value) => value + 1)
}
const handleContainerKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (replayingFindRef.current) {
if (isImeCompositionKeyDown(event)) {
return
}
// Why: a find-only session must not outlive the search panel, or a
// read-only diff stays editable forever after a single Cmd+F.
if (findActive && event.key === 'Escape') {
pendingFindRef.current = false
setFindActive(false)
if (open && event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
close()
return
}
if (!editorShortcutMatches('editor.find', event)) {
const again =
event.key === 'F3'
? event.shiftKey
? 'previous'
: 'next'
: resolveFindAgainShortcut(event.nativeEvent, getShortcutPlatform() === 'darwin')
if (open && again) {
event.preventDefault()
event.stopPropagation()
navigate(again === 'previous' ? -1 : 1)
return
}
const find = editorShortcutMatches('editor.find', event)
const replace = editorShortcutMatches('editor.replace', event)
if (!find && !replace) {
return
}
event.preventDefault()
event.stopPropagation()
pendingFindRef.current = true
setFindActive(true)
// Editable surfaces already attached; toggling find does not reattach them.
if (findPierreContentElement(containerRef.current)) {
openMountedSearch()
}
},
[findActive, containerRef, openMountedSearch]
)
// Why: leaving the surface ends a find-only session too; the panel is gone.
const handleContainerBlur = useCallback(
(event: React.FocusEvent<HTMLElement>) => {
if (!findActive || event.currentTarget.contains(event.relatedTarget as Node | null)) {
if (event.repeat) {
return
}
pendingFindRef.current = false
setFindActive(false)
},
[findActive]
)
const handleEditorAttach = useCallback(
(editor: FocusableEditor) => {
if (!pendingFindRef.current) {
return
const nextSide = replace && isEditable ? 'additions' : sideRef.current
setSide(nextSide)
if (replace && isEditable && nextSide === 'additions') {
setReplaceOpen(true)
}
editor.focus({ lineNumber: 'first-visible', preventScroll: true })
// Why: the editable DOM is not focusable until after this commit paints,
// so a same-tick dispatch misses Pierre's content element and the first
// Cmd+F is swallowed — which is why it used to take two presses.
openMountedSearch()
setOpen(true)
const root = containerRef.current?.querySelector('diffs-container')?.shadowRoot
const selection = (
root as ShadowRoot & { getSelection?: () => Selection | null }
)?.getSelection?.()
const selected = root?.contains(selection?.anchorNode ?? null) ? selection?.toString() : ''
if (selected) {
setQuery((value) => ({ ...value, text: selected }))
}
inputRef.current?.focus({ preventScroll: true })
inputRef.current?.select()
},
[openMountedSearch]
[containerRef, isEditable, open, close, navigate]
)
useEffect(() => {
if (open) {
inputRef.current?.focus({ preventScroll: true })
inputRef.current?.select()
}
}, [open])
const exitFind = useCallback(() => {
pendingFindRef.current = false
setFindActive(false)
}, [])
const searchBar: PierreDiffSearchBarProps | null = open
? {
inputRef,
query,
side,
replacement,
replaceOpen,
canReplace,
canNavigate: Boolean(matches?.length),
canReplaceAll: Boolean(matches?.length) && !result?.truncated,
status:
result?.error ??
(!query.text
? '0/0'
: !result
? 'Searching…'
: !matches?.length
? 'No results'
: `${index + 1}/${matches.length}${result.truncated ? '+' : ''}`),
onQuery: setQuery,
onReplacement: setReplacement,
onToggleReplace: () => setReplaceOpen((value) => !value),
onSide: (value) => {
sideRef.current = value
setSide(value)
inputRef.current?.focus({ preventScroll: true })
},
onNavigate: navigate,
onReplace: replace,
onClose: close
}
: null
return {
editEnabled: isEditable || findActive,
searchBar,
handleContainerKeyDown,
handleContainerBlur,
handleEditorAttach,
exitFind
onPostRender,
onEditChange: () => {
if (open) {
setRevision((value) => value + 1)
}
},
onPointerDown: (event: React.PointerEvent<HTMLElement>) => {
const path = event.nativeEvent.composedPath()
const original = path.some(
(node) =>
node instanceof Element &&
(node.matches('[data-code][data-deletions]') ||
node.matches('[data-line-type="change-deletion"]'))
)
if (path.some((node) => node instanceof Element && node.matches('[data-code]'))) {
sideRef.current = original ? 'deletions' : 'additions'
}
}
}
}
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import { act, cleanup, renderHook } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { usePierreDiffSearchResults } from './use-pierre-diff-search-results'
const workers: FakeWorker[] = []
class FakeWorker {
onmessage?: (event: { data: unknown }) => void
postMessage = vi.fn()
terminate = vi.fn()
constructor() {
workers.push(this)
}
}
function request(text: string) {
return {
text: 'contents',
query: { text, regex: true, matchCase: false, wholeWord: false },
replacement: ''
}
}
function setup() {
vi.useFakeTimers()
vi.stubGlobal('Worker', FakeWorker)
const hook = renderHook(({ input }) => usePierreDiffSearchResults(input), {
initialProps: { input: request('first') }
})
act(() => vi.advanceTimersByTime(120))
return hook
}
afterEach(() => {
cleanup()
workers.length = 0
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('terminates abandoned regex work and rejects its late result', () => {
const hook = setup()
const first = workers[0]
hook.rerender({ input: request('second') })
expect(first.terminate).toHaveBeenCalled()
act(() => first.onmessage?.({ data: { matches: ['stale'] } }))
expect(hook.result.current).toBeNull()
act(() => vi.advanceTimersByTime(120))
act(() => workers[1].onmessage?.({ data: { matches: [], truncated: false } }))
expect(hook.result.current).toEqual({ matches: [], truncated: false })
expect(workers[1].terminate).toHaveBeenCalled()
})
it('terminates a stalled worker and reports a recoverable timeout', () => {
const hook = setup()
act(() => vi.advanceTimersByTime(5_000))
expect(workers[0].terminate).toHaveBeenCalled()
expect(hook.result.current?.error).toContain('Search took too long')
})
it('terminates active work on unmount', () => {
const hook = setup()
hook.unmount()
expect(workers[0].terminate).toHaveBeenCalled()
})
@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react'
import type { DiffSearchRequest, DiffSearchResult } from './pierre-diff-search'
export function usePierreDiffSearchResults(request: DiffSearchRequest | null) {
const [result, setResult] = useState<{ request: DiffSearchRequest; value: DiffSearchResult }>()
useEffect(() => {
if (!request || !request.query.text) {
return
}
let worker: Worker | undefined
let timeout: ReturnType<typeof setTimeout> | undefined
let disposed = false
const finish = (value: DiffSearchResult) => {
worker?.terminate()
clearTimeout(timeout)
if (!disposed) {
setResult({ request, value })
}
}
const debounce = setTimeout(() => {
try {
worker = new Worker(new URL('./pierre-diff-search.worker.ts', import.meta.url), {
type: 'module'
})
worker.onmessage = ({ data }: MessageEvent<DiffSearchResult>) => finish(data)
worker.onerror = () =>
finish({ matches: [], truncated: false, error: 'Could not search this file' })
worker.onmessageerror = () =>
finish({ matches: [], truncated: false, error: 'Could not read search results' })
timeout = setTimeout(
() =>
finish({
matches: [],
truncated: false,
error: 'Search took too long. Try a simpler expression.'
}),
5_000
)
worker.postMessage(request)
} catch {
finish({ matches: [], truncated: false, error: 'Could not start search' })
}
}, 120)
return () => {
disposed = true
clearTimeout(debounce)
clearTimeout(timeout)
worker?.terminate()
}
}, [request])
return result?.request === request ? result.value : null
}
@@ -0,0 +1,96 @@
import { useCallback, useLayoutEffect, useRef } from 'react'
import type { FileDiffMetadata, PostRenderPhase } from '@pierre/diffs'
import type { PierreDiffInstance } from './PierreDiffSurface'
import type { DiffSearchMatch } from './pierre-diff-search'
import type { DiffSearchSide } from './PierreDiffSearchBar'
import {
getPierreSearchRanges,
paintPierreSearchHighlights,
pierreSearchRevealLine
} from './pierre-diff-search-view'
import { scrollPierreDiffToLine } from './pierre-diff-scroll'
export function usePierreDiffSearchView({
fileDiff,
side,
matches,
active
}: {
fileDiff: FileDiffMetadata
side: DiffSearchSide
matches?: DiffSearchMatch[]
active?: DiffSearchMatch
}) {
const viewRef = useRef<{ host: HTMLElement; instance: PierreDiffInstance } | null>(null)
const owner = useRef({})
const frame = useRef<number | null>(null)
const pending = useRef(false)
const schedule = useCallback(() => {
if (frame.current !== null) {
return
}
frame.current = requestAnimationFrame(() => {
frame.current = null
const view = viewRef.current
if (!view || !matches) {
paintPierreSearchHighlights(owner.current)
return
}
const { host, instance } = view
if (active && pending.current) {
if (
instance.revealLine(pierreSearchRevealLine(fileDiff, active.range.start.line + 1, side))
) {
return
}
scrollPierreDiffToLine({
host,
container: host.closest('.scrollbar-editor'),
lineNumber: active.range.start.line + 1,
side,
linePosition: instance.getLinePosition?.(active.range.start.line + 1, side),
hunkIndex: 0,
hunkCount: 0
})
}
const ranges = getPierreSearchRanges(host, fileDiff, side, matches, active)
paintPierreSearchHighlights(owner.current, ranges)
if (pending.current && ranges.active.length) {
const range = ranges.active[0]
const row = range.startContainer.parentElement?.closest<HTMLElement>('[data-code]')
if (row) {
const rect = range.getBoundingClientRect(),
viewport = row.getBoundingClientRect()
if (rect.left < viewport.left || rect.right > viewport.right) {
row.scrollLeft += rect.left - viewport.left - row.clientWidth / 3
}
}
pending.current = false
}
})
}, [matches, active, fileDiff, side])
useLayoutEffect(() => {
pending.current = Boolean(active)
schedule()
const token = owner.current
return () => {
if (frame.current !== null) {
cancelAnimationFrame(frame.current)
}
frame.current = null
paintPierreSearchHighlights(token)
}
}, [schedule, active])
return useCallback(
(host: HTMLElement, phase: PostRenderPhase, instance: PierreDiffInstance) => {
if (phase === 'unmount') {
viewRef.current = null
paintPierreSearchHighlights(owner.current)
} else {
viewRef.current = { host, instance }
schedule()
}
},
[schedule]
)
}
+152
View File
@@ -0,0 +1,152 @@
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady } from './helpers/store'
import { addAndActivateRepo } from './helpers/isolated-repo-activation'
import { createIsolatedLargeDiffRepo } from './large-diff-repro-fixtures'
test.use({ seedTestRepo: false })
for (const mode of ['editable-file', 'readonly-combined']) {
test(`finds both sides and preserves edit permissions in ${mode}`, async ({
orcaPage,
registerPostElectronShutdownCleanup
}, testInfo) => {
const original = `export const oldOnly = 1\n${Array.from({ length: 599 }, (_, index) => `export const value${index} = ${index}\n`).join('')}`
const modified = `// inserted line\n${original.replace('oldOnly', 'newOnly')}`
const fixture = createIsolatedLargeDiffRepo(original)
registerPostElectronShutdownCleanup(async () =>
rmSync(fixture.repoPath, { recursive: true, force: true })
)
writeFileSync(fixture.absolutePath, modified)
await waitForSessionReady(orcaPage)
await addAndActivateRepo(orcaPage, fixture.repoPath)
await orcaPage.evaluate(
(readonly) =>
window.__store!.getState().updateSettings({
diffDefaultView: readonly ? 'inline' : 'side-by-side',
diffWordWrap: false
}),
mode === 'readonly-combined'
)
await orcaPage.getByRole('button', { name: /^Source Control/ }).click()
if (mode === 'readonly-combined') {
await orcaPage.getByRole('button', { name: 'Stage All', exact: true }).click()
await expect(
orcaPage.locator('[data-testid="source-control-entry"][data-source-control-area="staged"]')
).toHaveCount(1)
await orcaPage.getByRole('button', { name: 'View all', exact: true }).first().click()
} else {
await orcaPage.locator('[data-testid="source-control-entry"]').first().click()
}
const host = orcaPage.locator('diffs-container').first()
const originalRow = host
.locator('[data-code] [data-line][data-line-type="change-deletion"]')
.first()
await originalRow.click({ timeout: 20_000 })
await orcaPage.keyboard.press('ControlOrMeta+f')
const search = orcaPage.locator('[data-diff-search]')
const input = search.getByRole('textbox', { name: 'Find in diff', exact: true })
await expect(input).toBeFocused()
await expect(search.getByRole('button', { name: 'Original', exact: true })).toHaveAttribute(
'aria-pressed',
'true'
)
await input.fill('oldOnly')
await expect(search.getByRole('status')).toHaveText('1/1')
await expect(search.getByRole('button', { name: 'Toggle replace', exact: true })).toHaveCount(0)
await search.getByRole('button', { name: 'Use regular expression', exact: true }).click()
await input.fill('value44[89]')
await expect(search.getByRole('status')).toHaveText('1/2')
await input.press('F3')
await expect(search.getByRole('status')).toHaveText('2/2')
await input.press('Shift+F3')
await expect(search.getByRole('status')).toHaveText('1/2')
await search.getByRole('button', { name: 'Use regular expression', exact: true }).click()
await input.fill('value448')
await expect(search.getByRole('status')).toHaveText('1/1')
await expect(
host.locator('[data-code] [data-line]').filter({ hasText: 'value448 = 448' }).first()
).toBeInViewport()
await orcaPage.screenshot({ path: testInfo.outputPath(`${mode}-original-search.png`) })
await search.getByRole('button', { name: 'Modified', exact: true }).click()
await input.fill('newOnly')
await expect(search.getByRole('status')).toHaveText('1/1')
if (mode === 'readonly-combined') {
await expect(host.locator('[contenteditable="true"]')).toHaveCount(0)
await expect(search.getByRole('button', { name: 'Toggle replace', exact: true })).toHaveCount(
0
)
const row = host
.locator('[data-code] [data-line][data-line-type="change-addition"]')
.filter({ hasText: 'newOnly' })
await row.click()
await orcaPage.keyboard.type('CORRUPTED')
await expect(row).toHaveText('export const newOnly = 1\n')
expect(readFileSync(fixture.absolutePath, 'utf8')).toBe(modified)
} else {
await search.getByRole('button', { name: 'Use regular expression', exact: true }).click()
await input.fill('new(Only)')
await search.getByRole('button', { name: 'Toggle replace', exact: true }).click()
await search.getByRole('textbox', { name: 'Replace in diff', exact: true }).fill('changed$1')
await expect(search.getByRole('status')).toHaveText('1/1')
await search.getByRole('button', { name: 'Replace all', exact: true }).click()
const row = host
.locator('[data-code][data-additions] [data-line]')
.filter({ hasText: 'changedOnly' })
await expect(row).toBeVisible()
await search.getByRole('button', { name: 'Close search', exact: true }).click()
await orcaPage.keyboard.press('ControlOrMeta+z')
await expect(host.locator('[data-code][data-additions]')).toContainText('newOnly')
await orcaPage.keyboard.press('ControlOrMeta+s')
await expect.poll(() => readFileSync(fixture.absolutePath, 'utf8')).toBe(modified)
}
})
}
test('cancels a pathological regex without blocking input', async ({
orcaPage,
registerPostElectronShutdownCleanup
}) => {
const fixture = createIsolatedLargeDiffRepo('export const before = 1\n')
registerPostElectronShutdownCleanup(async () =>
rmSync(fixture.repoPath, { recursive: true, force: true })
)
writeFileSync(fixture.absolutePath, `export const after = 1\n// ${'a'.repeat(80)}!\n`)
await waitForSessionReady(orcaPage)
await addAndActivateRepo(orcaPage, fixture.repoPath)
await orcaPage.getByRole('button', { name: /^Source Control/ }).click()
await orcaPage.locator('[data-testid="source-control-entry"]').first().click()
const host = orcaPage.locator('diffs-container').first()
await host
.locator('[data-code] [data-line][data-line-type="change-addition"]')
.first()
.click({ timeout: 20_000 })
await orcaPage.keyboard.press('ControlOrMeta+f')
const search = orcaPage.locator('[data-diff-search]')
const input = search.getByRole('textbox', { name: 'Find in diff', exact: true })
await search.getByRole('button', { name: 'Use regular expression', exact: true }).click()
await orcaPage.evaluate(() => {
let last = performance.now()
const probe = { maxLag: 0, timer: 0 }
probe.timer = window.setInterval(() => {
const now = performance.now()
probe.maxLag = Math.max(probe.maxLag, now - last - 20)
last = now
}, 20)
;(window as unknown as { diffSearchProbe: typeof probe }).diffSearchProbe = probe
})
await input.fill('(a+)+$')
await expect(search.getByRole('status')).toContainText('Search took too long', { timeout: 8_000 })
await input.fill('(a+)+!$')
await expect(search.getByRole('status')).toHaveText('1/1', { timeout: 3_000 })
await input.fill('(a+)+$')
await orcaPage.waitForTimeout(300)
await input.fill('after')
await expect(search.getByRole('status')).toHaveText('1/1', { timeout: 3_000 })
const maxLag = await orcaPage.evaluate(() => {
const probe = (window as unknown as { diffSearchProbe: { timer: number; maxLag: number } })
.diffSearchProbe
clearInterval(probe.timer)
return probe.maxLag
})
expect(maxLag).toBeLessThan(1_000)
})