Files
backnotprop__plannotator/packages/review-editor/dock/panels/ReviewDiffPanel.tsx
T
Michael Ramos d0c32c8863 fix(review): preserve dragged diff ranges on compact touch before commenting (#1333)
* feat: add mobile touch range selection prototypes

* docs: redirect mobile range selection spike

* revert: remove command-mediated touch range prototype

* docs: align mobile line selection with DiffsHub

* feat: preserve mobile diff ranges before commenting

* fix(review): repaint a preserved mobile range on a second drag

A preserved range leaves pendingSelection non-null, so DiffViewer hands
Pierre a defined selectedLines and Pierre switches to controlled
selection: updateSelection then only records a proposed range and leaves
painting to the host. With no change handler wired, a second drag never
repainted, so the old highlight stayed put and the finger was untracked
until release. Wire onLineSelectionChange back into app state, only on
compact touch, so desktop keeps an options object with no such key.

Also route a null range to the toolbar host instead of swallowing it in
the preserve branch, mirroring AllFilesCodeView's early return so an open
composer (Ask AI included) closes with the selection it was written for.

* fix(review): 44px hit area for Pierre's gutter comment button on touch

With a dragged range now preserved instead of opening the composer, that
button is the only way to start writing about it, and it is roughly 20px
square: below the data-pn-touch-target standard the rest of the compact
shell holds. Grow its invisible ::before hit area to the 44px token,
leaving the glyph alone.

The rule ships through the same unsafeCSS both diff surfaces already
inject, which lands in Pierre's shadow root inside @layer unsafe (last in
the library's layer order, so no !important). It is injected only when
the shell is compact rather than gated in CSS: html:has() matches nothing
from inside a shadow root, and @media (pointer: coarse) would wrongly
claim a desktop with a touchscreen.

* test(review): cover the compact-touch preserved range on both diff surfaces

DiffViewer.compactTouchSelection.test.tsx drives the FileDiff options the
component hands Pierre: a drag preserves the range instead of opening the
composer and paints it through selectedLines, a second drag repaints
through onLineSelectionChange, the gutter action opens the composer, a
cleared range still reaches the toolbar host, and desktop keeps routing
completed drags straight to the composer with no change handler at all.
The last three assertions fail against the pre-fix component.

The AllFilesCodeView compact test only checked what was published upward,
which a range nothing paints would also satisfy; it now asserts the range
reaches the CodeView props. That needs the App loop, so the mount feeds
published selections back down as pendingSelection: without it the
reconcile effect clears the highlight the preserve branch just painted.

* chore(guides-show): regenerate viewer manifest pin for the touch selection changes
2026-08-16 21:47:43 -07:00

132 lines
5.3 KiB
TypeScript

import React, { useMemo } from 'react';
import type { IDockviewPanelProps } from 'dockview-react';
import { DiffViewer } from '../../components/DiffViewer';
import { useReviewState } from '../ReviewStateContext';
import { getReviewDiffPanelFilePath, type ReviewDiffPanelParams } from '../reviewPanelTypes';
import { annotationMatchesPrScope } from '../../utils/annotationScope';
/**
* Thin adapter between dockview's panel API and the existing DiffViewer.
*
* Receives `filePath` from dockview params, reads everything else from
* the ReviewStateContext. The existing DiffViewer component is not modified.
*/
export const ReviewDiffPanel: React.FC<IDockviewPanelProps> = (props) => {
const state = useReviewState();
const filePath =
getReviewDiffPanelFilePath(props.params) ??
getReviewDiffPanelFilePath(props.api.getParameters<ReviewDiffPanelParams>());
const file = filePath
? state.files.find(candidate => candidate.path === filePath)
: undefined;
const isFocusedFile = !!file && state.focusedFilePath === file.path;
const fileAnnotations = useMemo(
() => {
if (!file) return [];
const currentPrUrl = state.prMetadata?.url;
const currentDiffScope = state.prDiffScope;
return state.allAnnotations.filter((a) =>
a.filePath === file.path &&
annotationMatchesPrScope(a, currentPrUrl, currentDiffScope)
);
},
[state.allAnnotations, file, state.prMetadata, state.prDiffScope]
);
const aiMessagesForFile = useMemo(
() =>
file
? state.aiMessages.filter(
(m) => m.question.filePath === file.path
)
: [],
[state.aiMessages, file]
);
const searchMatchesForFile = useMemo(
() =>
file && isFocusedFile
? state.activeFileSearchMatches
: [],
[state.activeFileSearchMatches, isFocusedFile, file]
);
if (!file) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
File not found
</div>
);
}
// Keying on reviewBase forces a remount when the user picks a new base.
// Otherwise the file-content fetch for the new base can land before the new
// patch, and Pierre briefly reconciles old-patch + new-content → "trailing
// context mismatch" warnings in the console.
return (
<div key={`${file.path}:${state.reviewBase ?? ''}:${state.activeDiffBase ?? ''}:${state.feedbackDiffContext?.snapshotId ?? ''}`} className="h-full relative">
<DiffViewer
patch={file.patch}
filePath={file.path}
oldPath={file.oldPath}
status={file.status}
reviewBase={state.reviewBase}
reviewSnapshotId={state.feedbackDiffContext?.snapshotId}
prUrl={state.prMetadata?.url}
prDiffScope={state.prDiffScope}
isFocused={isFocusedFile}
diffStyle={state.diffStyle}
diffOverflow={state.diffOverflow}
diffIndicators={state.diffIndicators}
lineDiffType={state.lineDiffType}
disableLineNumbers={state.disableLineNumbers}
disableBackground={state.disableBackground}
expandUnchanged={state.expandUnchanged}
fontFamily={state.fontFamily}
fontSize={state.fontSize}
annotations={fileAnnotations}
selectedAnnotationId={state.selectedAnnotationId}
scrollTargetAnnotation={state.scrollTargetAnnotation}
pendingSelection={state.pendingSelection}
compactTouchLayout={state.isCompactTouchLayout}
onLineSelection={state.onLineSelection}
onAddAnnotation={state.onAddAnnotation}
onAddFileComment={state.onAddFileComment}
onEditAnnotation={state.onEditAnnotation}
onSelectAnnotation={state.onSelectAnnotation}
onDeleteAnnotation={state.onDeleteAnnotation}
isViewed={state.viewedFiles.has(file.path)}
onToggleViewed={() => state.onToggleViewed(file.path)}
showViewedControls={state.showViewedControls}
isStaged={state.stagedFiles.has(file.path)}
isStaging={state.stagingFile === file.path}
onStage={() => state.onStage(file.path)}
// Per-path gate (falls back to the mode-level flag): in since-base the
// single-file header lists committed files too — mode-level canStageFiles
// alone would offer a no-op Git Add on them that flips local state.
// Mirrors the `a` shortcut and the all-files header.
canStage={state.canStagePath ? state.canStagePath(file.path) : state.canStageFiles}
showStageControls={state.showStageControls}
stageError={state.stageError}
searchQuery={state.isSearchPending ? '' : state.debouncedSearchQuery}
searchMatches={searchMatchesForFile}
activeSearchMatchId={isFocusedFile ? state.activeSearchMatchId : null}
activeSearchMatch={
isFocusedFile && state.activeSearchMatch?.filePath === file.path
? state.activeSearchMatch
: null
}
aiAvailable={state.aiAvailable}
onAskAI={state.onAskAI}
isAILoading={state.isAILoading}
onViewAIResponse={state.onViewAIResponse}
aiMessages={aiMessagesForFile}
onClickAIMarker={state.onClickAIMarker}
aiHistoryMessages={isFocusedFile ? state.aiHistoryForSelection : []}
onCodeNavRequest={state.onCodeNavRequest}
/>
</div>
);
};