fix(diffs): restore cache, navigation, copy, and search behavior

This commit is contained in:
Neil
2026-09-07 14:32:09 -07:00
parent 68d8710d18
commit da94e45ea7
17 changed files with 566 additions and 107 deletions
@@ -87,16 +87,23 @@ export function DiffSectionItem({
const fileDiff = useMemo(
() =>
buildPierreFileDiff({
path: section.path,
oldPath: section.oldPath,
status: section.status,
originalContent: section.originalContent,
modifiedContent: section.modifiedContent,
// Why: keyed by content generation so the worker AST cache survives virtualization remounts.
cacheKey: `${section.key}:${section.contentGeneration ?? 0}`,
parseDiffOptions: buildPierreParseDiffOptions(settings?.diffShowWhitespace)
}),
section.collapsed ||
section.loading ||
section.loadOnDemand ||
section.error ||
section.diffResult?.kind === 'binary' ||
section.largeDiffRenderLimit?.limited
? null
: buildPierreFileDiff({
path: section.path,
oldPath: section.oldPath,
status: section.status,
originalContent: section.originalContent,
modifiedContent: section.modifiedContent,
// Why: keyed by content generation so the worker AST cache survives virtualization remounts.
cacheKey: `${worktreeId}:${section.key}:${section.contentGeneration ?? 0}`,
parseDiffOptions: buildPierreParseDiffOptions(settings?.diffShowWhitespace)
}),
[
section.path,
section.oldPath,
@@ -105,6 +112,13 @@ export function DiffSectionItem({
section.modifiedContent,
section.key,
section.contentGeneration,
section.collapsed,
section.loading,
section.loadOnDemand,
section.error,
section.diffResult?.kind,
section.largeDiffRenderLimit?.limited,
worktreeId,
settings?.diffShowWhitespace
]
)
@@ -209,32 +223,33 @@ export function DiffSectionItem({
return
}
return installEditorSaveShortcut(node, () => void handleSectionSaveRef.current(index))
}, [handleSectionSaveRef, index, isEditable])
}, [handleSectionSaveRef, index, isEditable, section.collapsed])
const renderDiff = useCallback(
() => (
<PierreDiffSurface
fileDiff={fileDiff}
sideBySide={sideBySide}
settings={settings}
isEditable={isEditable}
collapseUnchanged
worktreeId={worktreeId ?? ''}
filePath={section.path}
language={detectLanguage(section.path)}
comments={comments}
onDeleteComment={handleDeleteComment}
onUpdateComment={handleUpdateComment}
onEditChange={handleEditChange}
onPostRender={handlePostRender}
onAddComment={hasLineCommentAction ? handleAddComment : undefined}
pendingComment={pendingComment}
addCommentPlaceholder={addLineCommentPlaceholder}
addCommentLabel={addLineCommentLabel}
onCancelComment={() => setPendingComment(null)}
onSubmitComment={handleSubmitComment}
/>
),
() =>
fileDiff ? (
<PierreDiffSurface
fileDiff={fileDiff}
sideBySide={sideBySide}
settings={settings}
isEditable={isEditable}
collapseUnchanged
worktreeId={worktreeId ?? ''}
filePath={section.path}
language={detectLanguage(section.path)}
comments={comments}
onDeleteComment={handleDeleteComment}
onUpdateComment={handleUpdateComment}
onEditChange={handleEditChange}
onPostRender={handlePostRender}
onAddComment={hasLineCommentAction ? handleAddComment : undefined}
pendingComment={pendingComment}
addCommentPlaceholder={addLineCommentPlaceholder}
addCommentLabel={addLineCommentLabel}
onCancelComment={() => setPendingComment(null)}
onSubmitComment={handleSubmitComment}
/>
) : null,
[
addLineCommentLabel,
addLineCommentPlaceholder,
@@ -17,6 +17,7 @@ import { PierreDiffSurface } from './pierre-diff/PierreDiffSurface'
import { buildPierreFileDiff } from './pierre-diff/pierre-diff-metadata'
import { buildPierreParseDiffOptions } from './pierre-diff/pierre-diff-options'
import { scrollPierreDiffToLine } from './pierre-diff/pierre-diff-scroll'
import { getPierreDiffChangeTargets } from './pierre-diff/pierre-diff-change-targets'
const EMPTY_DIFF_COMMENTS: readonly DecoratedDiffComment[] = []
@@ -70,19 +71,32 @@ export default function DiffViewer({
const fileDiff = useMemo(
() =>
buildPierreFileDiff({
path: relativePath,
status: 'modified',
originalContent,
modifiedContent,
cacheKey: modelKey,
parseDiffOptions: buildPierreParseDiffOptions(settings?.diffShowWhitespace)
}),
[relativePath, originalContent, modifiedContent, modelKey, settings?.diffShowWhitespace]
renderLimit.limited
? null
: buildPierreFileDiff({
path: relativePath,
status: 'modified',
originalContent,
modifiedContent,
cacheKey: modelKey,
parseDiffOptions: buildPierreParseDiffOptions(settings?.diffShowWhitespace)
}),
[
relativePath,
originalContent,
modifiedContent,
modelKey,
settings?.diffShowWhitespace,
renderLimit.limited
]
)
const { registerDiffNavigator, unregisterDiffNavigator } = useDiffNavigatorRegistration()
const changeLines = useMemo(() => fileDiff.hunks.map((hunk) => hunk.additionStart), [fileDiff])
const changeTargets = useMemo(() => getPierreDiffChangeTargets(fileDiff), [fileDiff])
const changeLines = useMemo(
() => changeTargets.map((target) => target.lineNumber),
[changeTargets]
)
useEffect(() => {
const container = scrollContainerRef.current
@@ -97,6 +111,7 @@ export default function DiffViewer({
host: pierreHostRef.current,
container,
lineNumber,
side: changeTargets[hunkIndex]?.side,
hunkIndex,
hunkCount
})
@@ -104,7 +119,13 @@ export default function DiffViewer({
}
registerDiffNavigator(navigator)
return () => unregisterDiffNavigator(navigator)
}, [changeLines, registerDiffNavigator, renderLimit.limited, unregisterDiffNavigator])
}, [
changeLines,
changeTargets,
registerDiffNavigator,
renderLimit.limited,
unregisterDiffNavigator
])
const handlePostRender = useCallback((node: HTMLElement, phase: PostRenderPhase) => {
pierreHostRef.current = phase === 'unmount' ? null : node
@@ -243,7 +264,7 @@ export default function DiffViewer({
saveContentAvailable: largeDiffSaveContentAvailable
})}
/>
) : (
) : fileDiff ? (
<PierreDiffProviders>
<PierreDiffSurface
fileDiff={fileDiff}
@@ -267,7 +288,7 @@ export default function DiffViewer({
onSubmitComment={handleSubmitComment}
/>
</PierreDiffProviders>
)}
) : null}
</div>
</div>
)
@@ -1,6 +1,7 @@
import type { DiffSection } from './diff-section-types'
import {
getLargeDiffRenderLimitFromCounts,
countLinesEmptyAsZero,
type LargeDiffRenderLimit
} from './large-diff-render-limit'
@@ -12,10 +13,12 @@ export function getLiveDiffSectionRenderLimit({
modifiedContent: string
}): LargeDiffRenderLimit {
// Why: the renderer no longer owns a text model, so count lines from the draft itself.
const modifiedLineCount = modifiedContent.length === 0 ? 0 : modifiedContent.split('\n').length
const modifiedLineCount = countLinesEmptyAsZero(modifiedContent)
return getLargeDiffRenderLimitFromCounts({
originalLineCount: section.largeDiffRenderLimit?.lineCounts?.original ?? 0,
originalLineCount:
section.largeDiffRenderLimit?.lineCounts?.original ??
countLinesEmptyAsZero(section.originalContent),
modifiedLineCount,
originalCharacterCount: section.originalContent.length,
modifiedCharacterCount: modifiedContent.length
@@ -1,7 +1,11 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { FileDiff } from '@pierre/diffs/react'
import type { FileDiffMetadata, PostRenderPhase, SelectedLineRange } from '@pierre/diffs'
import type { FileContents } from '@pierre/diffs'
import type {
FileContents,
FileDiffMetadata,
PostRenderPhase,
SelectedLineRange
} from '@pierre/diffs'
import type { EditorOptions } from '@pierre/diffs/edit'
import { useAppStore } from '@/store'
import { RecoverableRenderErrorBoundary } from '@/components/error-boundaries/RecoverableRenderErrorBoundary'
@@ -103,11 +107,15 @@ export function PierreDiffSurface({
}),
enableGutterUtility: Boolean(onAddComment),
onGutterUtilityClick: onAddComment
? (range: SelectedLineRange) =>
? (range: SelectedLineRange) => {
if (range.side === 'deletions' || range.endSide === 'deletions') {
return
}
onAddComment({
lineNumber: Math.max(range.start, range.end),
startLine: range.start === range.end ? undefined : Math.min(range.start, range.end)
})
}
: undefined,
onPostRender: onPostRender
? (node: HTMLElement, _instance: unknown, phase: PostRenderPhase) =>
@@ -121,8 +129,8 @@ export function PierreDiffSurface({
[settings, editorFontZoomLevel]
)
const lineAnnotations = useMemo(
() => buildPierreDiffCommentAnnotations(comments, pendingComment),
[comments, pendingComment]
() => buildPierreDiffCommentAnnotations(comments, filePath, pendingComment),
[comments, filePath, pendingComment]
)
const editorOptions = useMemo<EditorOptions<'file-diff', PierreDiffAnnotationData, undefined>>(
() => ({
@@ -0,0 +1,36 @@
type CachedIdentity = { original: string; modified: string; key: string }
const identities = new Map<string, CachedIdentity>()
const MAX_RETAINED_CHARACTERS = 4_000_000
let retainedCharacters = 0
let nextIdentity = 0
// Pierre uses cache keys as content equality, including across mounted surfaces.
export function getPierreDiffCacheIdentity(
scope: string,
original: string,
modified: string
): string {
const previous = identities.get(scope)
if (previous?.original === original && previous.modified === modified) {
identities.delete(scope)
identities.set(scope, previous)
return previous.key
}
if (previous) {
retainedCharacters -= previous.original.length + previous.modified.length
identities.delete(scope)
}
const key = `orca-diff:${++nextIdentity}`
identities.set(scope, { original, modified, key })
retainedCharacters += original.length + modified.length
while (identities.size > 64 || retainedCharacters > MAX_RETAINED_CHARACTERS) {
const oldest = identities.entries().next().value
if (!oldest) {
break
}
retainedCharacters -= oldest[1].original.length + oldest[1].modified.length
identities.delete(oldest[0])
}
return key
}
@@ -0,0 +1,28 @@
import type { FileDiffMetadata } from '@pierre/diffs'
export type PierreDiffChangeTarget = { lineNumber: number; side: 'additions' | 'deletions' }
export function getPierreDiffChangeTargets(
diff: FileDiffMetadata | null
): PierreDiffChangeTarget[] {
const targets: PierreDiffChangeTarget[] = []
for (const hunk of diff?.hunks ?? []) {
let additionLine = hunk.additionStart
let deletionLine = hunk.deletionStart
for (const block of hunk.hunkContent) {
if (block.type === 'context') {
additionLine += block.lines
deletionLine += block.lines
} else {
targets.push(
block.additions > 0
? { lineNumber: Math.max(1, additionLine), side: 'additions' }
: { lineNumber: Math.max(1, deletionLine), side: 'deletions' }
)
additionLine += block.additions
deletionLine += block.deletions
}
}
}
return targets
}
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest'
import type { DecoratedDiffComment } from '../../diff-comments/decorated-diff-comment'
import { buildPierreDiffCommentAnnotations } from './pierre-diff-comment-annotations'
vi.mock('../../diff-comments/DiffCommentCard', () => ({ DiffCommentCard: () => null }))
vi.mock('../../diff-comments/DiffCommentPopover', () => ({ DiffCommentPopover: () => null }))
vi.mock('../NotesSendMenu', () => ({ NotesSendMenu: () => null }))
const note = (filePath: string): DecoratedDiffComment => ({
id: filePath,
filePath,
worktreeId: 'review',
lineNumber: 2,
body: 'review comment',
createdAt: 1,
side: 'modified'
})
describe('Pierre review annotations', () => {
it('keeps comments for other PR files out of this file', () => {
const annotations = buildPierreDiffCommentAnnotations(
[note('one.ts'), note('two.ts')],
'two.ts'
)
expect(annotations).toHaveLength(1)
expect(annotations[0].metadata).toMatchObject({
kind: 'comment',
comment: { filePath: 'two.ts' }
})
})
it('retains the range of an unsaved draft alongside existing notes', () => {
expect(
buildPierreDiffCommentAnnotations([note('one.ts')], 'one.ts', { lineNumber: 5, startLine: 3 })
).toHaveLength(2)
})
})
@@ -21,13 +21,16 @@ export type PierreDiffCommentAnnotation = DiffLineAnnotation<PierreDiffAnnotatio
*/
export function buildPierreDiffCommentAnnotations(
comments: readonly DecoratedDiffComment[],
filePath: string,
draft?: { lineNumber: number; startLine?: number } | null
): PierreDiffCommentAnnotation[] {
const annotations: PierreDiffCommentAnnotation[] = comments.map((comment) => ({
side: 'additions',
lineNumber: comment.lineNumber,
metadata: { kind: 'comment', comment }
}))
const annotations: PierreDiffCommentAnnotation[] = comments
.filter((comment) => comment.filePath === filePath)
.map((comment) => ({
side: 'additions',
lineNumber: comment.lineNumber,
metadata: { kind: 'comment', comment }
}))
if (draft) {
annotations.push({
side: 'additions',
@@ -2,6 +2,7 @@ import { formatCopiedSelectionWithContext } from '../selection-copy'
import { editorShortcutMatches } from '../editor-shortcuts'
import { formatShortcutLabel } from '@/hooks/useShortcutLabel'
import { useAppStore } from '@/store'
import { getPierreSelectionRange } from './pierre-diff-selection'
import {
PRIMARY_SELECTION_MAX_LENGTH,
isPrimarySelectionEnabled,
@@ -10,15 +11,6 @@ import {
const PRIMARY_SELECTION_DEBOUNCE_MS = 200
/** Resolves the 1-based line of the row containing a selection boundary node. */
function lineOf(node: Node | null): number | null {
const element = node instanceof Element ? node : (node?.parentElement ?? null)
const row = element?.closest('[data-line]')
const raw = row?.getAttribute('data-line')
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN
return Number.isFinite(parsed) ? parsed : null
}
/**
* Restores `editor.copyContext` for Pierre-rendered diffs. Monaco exposed the
* selection as an IRange; here the equivalent comes from the shadow root's
@@ -32,14 +24,19 @@ export function installPierreContextualCopy(
// is an overlay pinned to the selection's client rect instead.
const hint = document.createElement('div')
hint.className =
'pointer-events-none fixed z-50 rounded-md border border-border/90 bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-[0_6px_18px_rgba(15,23,42,0.18)] backdrop-blur whitespace-nowrap'
'pointer-events-none fixed z-50 rounded-md border border-border/90 bg-background px-2.5 py-1 text-xs font-medium text-foreground shadow-floating backdrop-blur whitespace-nowrap'
hint.style.display = 'none'
document.body.appendChild(hint)
let primarySelectionTimer: number | null = null
const readSelection = (): Selection | null => {
const root = container.querySelector('diffs-container')?.shadowRoot
return (root as unknown as { getSelection?: () => Selection | null })?.getSelection?.() ?? null
const selection =
(root as unknown as { getSelection?: () => Selection | null })?.getSelection?.() ?? null
return root?.contains(selection?.anchorNode ?? null) &&
root.contains(selection?.focusNode ?? null)
? selection
: null
}
const hideHint = (): void => {
@@ -49,10 +46,9 @@ export function installPierreContextualCopy(
const updateHint = (): void => {
const selection = readSelection()
const text = selection?.toString() ?? ''
const startLine = lineOf(selection?.anchorNode ?? null)
const endLine = lineOf(selection?.focusNode ?? null)
const range = getPierreSelectionRange(selection)
// Why: copy-with-context is a multi-line affordance; a single line copies plainly.
if (!text || startLine == null || endLine == null || startLine === endLine) {
if (!text || !range || range.startLineNumber === range.endLineNumber) {
hideHint()
return
}
@@ -95,19 +91,13 @@ export function installPierreContextualCopy(
if (!editorShortcutMatches('editor.copyContext', event)) {
return
}
const host = container.querySelector('diffs-container')
const root = host?.shadowRoot
// Why: Chromium scopes the selection to the shadow root that owns the range.
const selection = (
root as unknown as { getSelection?: () => Selection | null }
)?.getSelection?.()
const selection = readSelection()
const selectedText = selection?.toString() ?? ''
if (!selectedText) {
return
}
const startLine = lineOf(selection?.anchorNode ?? null)
const endLine = lineOf(selection?.focusNode ?? null)
if (startLine == null || endLine == null) {
const range = getPierreSelectionRange(selection)
if (!range) {
return
}
const { relativePath, language } = getFileInfo()
@@ -115,12 +105,7 @@ export function installPierreContextualCopy(
relativePath,
language,
selectedText,
selection: {
startLineNumber: Math.min(startLine, endLine),
endLineNumber: Math.max(startLine, endLine),
startColumn: 1,
endColumn: 1
}
selection: range
})
if (!formatted) {
return
@@ -133,9 +118,11 @@ export function installPierreContextualCopy(
container.addEventListener('keydown', handleKeyDown, true)
document.addEventListener('selectionchange', handleSelectionChange)
document.addEventListener('scroll', hideHint, true)
return () => {
container.removeEventListener('keydown', handleKeyDown, true)
document.removeEventListener('selectionchange', handleSelectionChange)
document.removeEventListener('scroll', hideHint, true)
if (primarySelectionTimer !== null) {
window.clearTimeout(primarySelectionTimer)
}
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { buildPierreFileDiff } from './pierre-diff-metadata'
import { getPierreDiffChangeTargets } from './pierre-diff-change-targets'
const input = {
path: 'example.ts',
status: 'modified',
cacheKey: 'workspace:file:0',
originalContent: 'one\ntwo\nthree\nfour\nfive\n',
modifiedContent: 'one\nTWO\nthree\nFOUR\nfive\n',
parseDiffOptions: { ignoreWhitespace: true }
}
describe('Pierre diff content identity and navigation', () => {
it('reuses unchanged content but invalidates edits, external updates, and whitespace options', () => {
const first = buildPierreFileDiff(input)
expect(buildPierreFileDiff(input).cacheKey).toBe(first.cacheKey)
for (const update of [
{ modifiedContent: 'new draft\n' },
{ originalContent: 'new base\n' },
{ parseDiffOptions: { ignoreWhitespace: false } },
{ path: 'another.ts' }
]) {
expect(buildPierreFileDiff({ ...input, ...update }).cacheKey).not.toBe(first.cacheKey)
}
})
it('does not share stale content across workspaces with the same path and generation', () => {
const first = buildPierreFileDiff(input)
const second = buildPierreFileDiff({ ...input, modifiedContent: 'another workspace\n' })
expect(second.cacheKey).not.toBe(first.cacheKey)
expect(second.additionLines.join('')).toBe('another workspace\n')
})
it.each(['added', 'untracked', 'deleted'])('gives %s files worker cache identities', (status) => {
expect(buildPierreFileDiff({ ...input, status }).cacheKey).toBeTruthy()
})
it('navigates every change inside one context hunk', () => {
const diff = buildPierreFileDiff(input)
expect(diff.hunks).toHaveLength(1)
expect(getPierreDiffChangeTargets(diff)).toEqual([
{ lineNumber: 2, side: 'additions' },
{ lineNumber: 4, side: 'additions' }
])
})
it('targets the old side for pure deletions, including deleted files', () => {
expect(
getPierreDiffChangeTargets(
buildPierreFileDiff({
...input,
modifiedContent: 'one\nthree\nfour\nfive\n'
})
)
).toEqual([{ lineNumber: 2, side: 'deletions' }])
expect(
getPierreDiffChangeTargets(buildPierreFileDiff({ ...input, status: 'deleted' }))
).toEqual([{ lineNumber: 1, side: 'deletions' }])
})
})
@@ -1,5 +1,6 @@
import { parseDiffFromFile, type FileContents, type FileDiffMetadata } from '@pierre/diffs'
import type { CreatePatchOptionsNonabortable } from 'diff'
import { getPierreDiffCacheIdentity } from './pierre-diff-cache-identity'
/** Statuses whose old side does not exist, so Pierre should render an add. */
const ADDED_STATUSES = new Set(['added', 'untracked'])
@@ -36,12 +37,15 @@ export function buildPierreFileDiff({
}): FileDiffMetadata {
const isAdded = ADDED_STATUSES.has(status)
const isDeleted = status === 'deleted'
const identity = getPierreDiffCacheIdentity(
JSON.stringify([cacheKey, path, oldPath, status, parseDiffOptions]),
originalContent,
modifiedContent
)
const oldFile = isAdded
? null
: toFileContents(oldPath ?? path, originalContent, cacheKey && `${cacheKey}:old`)
const newFile = isDeleted
? null
: toFileContents(path, modifiedContent, cacheKey && `${cacheKey}:new`)
: toFileContents(oldPath ?? path, originalContent, `${identity}:old`)
const newFile = isDeleted ? null : toFileContents(path, modifiedContent, `${identity}:new`)
return parseDiffFromFile(oldFile, newFile, parseDiffOptions)
return { ...parseDiffFromFile(oldFile, newFile, { ...parseDiffOptions }), cacheKey: identity }
}
@@ -0,0 +1,65 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import DiffViewer from '../DiffViewer'
import { buildPierreFileDiff } from './pierre-diff-metadata'
import { getLargeDiffRenderLimit } from '../large-diff-render-limit'
vi.mock('@/store', () => ({ useAppStore: (selector: (s: object) => unknown) => selector({}) }))
vi.mock('../editor-shortcuts', () => ({ installEditorSaveShortcut: () => () => {} }))
vi.mock('../diff-navigation-context', () => ({
useDiffNavigatorRegistration: () => ({
registerDiffNavigator: () => {},
unregisterDiffNavigator: () => {}
})
}))
vi.mock('./pierre-diff-metadata', () => ({
buildPierreFileDiff: vi.fn(() => {
throw new Error('limited content reached parser')
})
}))
vi.mock('./PierreDiffProviders', () => ({ PierreDiffProviders: () => null }))
vi.mock('./PierreDiffSurface', () => ({ PierreDiffSurface: () => null }))
vi.mock('../LargeDiffFallback', () => ({ LargeDiffFallback: () => <div>Large diff fallback</div> }))
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('renders a large-file fallback without synchronously computing the diff', () => {
const modifiedContent = 'x'.repeat(6_000_001)
render(
<DiffViewer
modelKey="large"
originalContent=""
modifiedContent={modifiedContent}
relativePath="large.txt"
filePath="large.txt"
language="plaintext"
sideBySide={false}
/>
)
expect(screen.getByText('Large diff fallback')).toBeTruthy()
expect(buildPierreFileDiff).not.toHaveBeenCalled()
})
it('honors a host-provided render limit when the RPC bodies are omitted', () => {
render(
<DiffViewer
modelKey="remote"
originalContent=""
modifiedContent=""
relativePath="large.txt"
filePath="large.txt"
language="plaintext"
sideBySide={false}
largeDiffRenderLimit={getLargeDiffRenderLimit({
originalContent: '',
modifiedContent: 'x'.repeat(6_000_001)
})}
/>
)
expect(screen.getByText('Large diff fallback')).toBeTruthy()
expect(buildPierreFileDiff).not.toHaveBeenCalled()
})
@@ -7,19 +7,28 @@ export function scrollPierreDiffToLine({
host,
container,
lineNumber,
side = 'additions',
hunkIndex,
hunkCount
}: {
host: HTMLElement | null
container: HTMLElement | null
lineNumber: number
side?: 'additions' | 'deletions'
hunkIndex: number
hunkCount: number
}): boolean {
if (!container) {
return false
}
const row = host?.shadowRoot?.querySelector(`[data-line="${lineNumber}"]`)
const lineType = side === 'additions' ? 'change-addition' : 'change-deletion'
const root = host?.shadowRoot
const row =
root?.querySelector(`[data-code][data-${side}] [data-line="${lineNumber}"]`) ??
root?.querySelector(`[data-code] [data-line="${lineNumber}"][data-line-type="${lineType}"]`) ??
root?.querySelector(
`[data-code]:not([data-deletions]) [data-line="${lineNumber}"]:not([data-line-type="change-deletion"])`
)
if (row instanceof HTMLElement) {
const offset = row.getBoundingClientRect().top - container.getBoundingClientRect().top
container.scrollTop += offset - container.clientHeight / 3
@@ -0,0 +1,45 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { getPierreSelectionRange } from './pierre-diff-selection'
import { formatCopiedSelectionWithContext } from '../selection-copy'
function selection(endOffset: number): Selection {
const root = document.createElement('div')
root.innerHTML =
'<div data-line="10"><span>first</span> line</div><div data-line="11"><span>last</span> line</div>'
const range = document.createRange()
range.setStart(root.children[0].firstChild!.firstChild!, 1)
range.setEnd(root.children[1].firstChild!.firstChild!, endOffset)
return { rangeCount: 1, getRangeAt: () => range } as unknown as Selection
}
describe('Pierre contextual copy boundaries', () => {
it('includes a partially selected final line in the label', () => {
const range = getPierreSelectionRange(selection(2))!
expect(range).toEqual({ startLineNumber: 10, startColumn: 2, endLineNumber: 11, endColumn: 3 })
expect(
formatCopiedSelectionWithContext({
relativePath: 'file.ts',
language: 'typescript',
selectedText: 'irst line\nla',
selection: range
})
).toContain('Lines: 10-11')
})
it('excludes a final line selected only at its first column', () => {
const range = getPierreSelectionRange(selection(0))!
expect(
formatCopiedSelectionWithContext({
relativePath: 'file.ts',
language: 'typescript',
selectedText: 'irst line\n',
selection: range
})
).toContain('Line: 10\n')
})
it('ignores absent selections', () => {
expect(getPierreSelectionRange(null)).toBeNull()
})
})
@@ -0,0 +1,33 @@
import type { IRange } from 'monaco-editor'
function selectionBoundary(node: Node, offset: number): { line: number; column: number } | null {
const element = node instanceof Element ? node : node.parentElement
const row = element?.closest('[data-line]')
const line = Number(row?.getAttribute('data-line'))
if (!row || !Number.isInteger(line) || line < 1) {
return null
}
const prefix = document.createRange()
prefix.selectNodeContents(row)
prefix.setEnd(node, offset)
return { line, column: prefix.toString().length + 1 }
}
// DOM ranges are ordered even when the user drags backwards.
export function getPierreSelectionRange(selection: Selection | null): IRange | null {
if (!selection?.rangeCount) {
return null
}
const range = selection.getRangeAt(0)
const start = selectionBoundary(range.startContainer, range.startOffset)
const end = selectionBoundary(range.endContainer, range.endOffset)
if (!start || !end) {
return null
}
return {
startLineNumber: start.line,
startColumn: start.column,
endLineNumber: end.line,
endColumn: end.column
}
}
@@ -0,0 +1,72 @@
// @vitest-environment happy-dom
import { act, renderHook, cleanup } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { usePierreDiffFind } from './use-pierre-diff-find'
vi.mock('../editor-shortcuts', () => ({
editorShortcutMatches: (_: string, event: KeyboardEvent) => event.key === 'f' && event.ctrlKey
}))
vi.mock('@/lib/shortcut-platform', () => ({ getShortcutPlatform: () => 'linux' }))
afterEach(() => {
cleanup()
vi.useRealTimers()
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 find = () =>
act(() =>
result.current.handleContainerKeyDown({
key: 'f',
ctrlKey: true,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
} as unknown as React.KeyboardEvent<HTMLElement>)
)
return { result, attachContent, 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 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() }))
act(() =>
result.current.handleContainerKeyDown({ key: 'Escape' } as React.KeyboardEvent<HTMLElement>)
)
act(() => vi.runOnlyPendingTimers())
expect(search).not.toHaveBeenCalled()
expect(result.current.editEnabled).toBe(false)
})
})
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { EditorFocusOptions } from '@pierre/diffs/edit'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { editorShortcutMatches } from '../editor-shortcuts'
@@ -10,7 +10,7 @@ import { editorShortcutMatches } from '../editor-shortcuts'
*/
function findPierreContentElement(container: HTMLElement | null): HTMLElement | null {
const host = container?.querySelector('diffs-container')
const editable = host?.shadowRoot?.querySelector('[contenteditable]')
const editable = host?.shadowRoot?.querySelector('[contenteditable="true"]')
return editable instanceof HTMLElement ? editable : null
}
@@ -61,9 +61,44 @@ export function usePierreDiffFind({
}): 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)
}
findFrameRef.current = requestAnimationFrame(() => {
findFrameRef.current = null
const target = findPierreContentElement(containerRef.current)
if (!target || !pendingFindRef.current) {
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)
}
},
[]
)
const handleContainerKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
if (replayingFindRef.current) {
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') {
@@ -71,15 +106,19 @@ export function usePierreDiffFind({
setFindActive(false)
return
}
if (findActive || !editorShortcutMatches('editor.find', event)) {
if (!editorShortcutMatches('editor.find', event)) {
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]
[findActive, containerRef, openMountedSearch]
)
// Why: leaving the surface ends a find-only session too; the panel is gone.
@@ -99,20 +138,13 @@ export function usePierreDiffFind({
if (!pendingFindRef.current) {
return
}
pendingFindRef.current = false
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.
requestAnimationFrame(() => {
const target = findPierreContentElement(containerRef.current)
if (target) {
target.focus()
dispatchPierreOpenSearchPanel(target)
}
})
openMountedSearch()
},
[containerRef]
[openMountedSearch]
)
const exitFind = useCallback(() => {