Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ jobs:
packages/review-editor/components/TokenHoverCard.test.tsx
packages/review-editor/components/TokenHoverAnnouncementDialog.test.tsx
packages/review-editor/utils/stitchTokenIdentifier.test.ts
packages/review-editor/utils/diffSelection.test.ts
packages/review-editor/hooks/useAutoViewed.test.tsx
packages/review-editor/hooks/useCallFlowAnalysis.test.tsx
packages/review-editor/hooks/useCallFlowInstall.test.tsx
Expand Down
4 changes: 2 additions & 2 deletions packages/core/guide-viewer-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
import type { GuideViewerAssets } from "./guide-format";

export const GUIDE_VIEWER_MANIFEST: Omit<GuideViewerAssets, "baseUrl"> = {
js: "viewer.KTNT-M2b.js",
js: "viewer.C8eISG76.js",
css: "viewer.NkTIi4sR.css",
jsIntegrity: "sha384-UGxkmDjeL0LMAKSAnleY0ewq4d4vtotFlHvHYWaK6UGIWmV30DyT5wSKQEz2NHdV",
jsIntegrity: "sha384-u11b9grzN+4gFAWEBBA6KaZBMGHNQYMiHZVMd3M315Pdq/ILkSi7bqzK3jMEh5eX",
cssIntegrity: "sha384-2tINtoWgdpcbwUZudhmxJiiW7Tu+29vXj6P12fLLkRtc2sWERGoHl71K35L+UR9f",
langs: {
"astro": "chunks/astro.Ts5EKq2l.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,73 @@ describe('AllFilesCodeView compact-touch line selection', () => {

expect(toolbarSelections).toEqual([range]);
});

test.skipIf(!hasDom)('multi-line selection + gutter click on the middle line publishes the full range (#991)', async () => {
await mount(false);
const { options, item } = getSelectionCallbacks();

const line4 = document.createElement('div');
line4.setAttribute('data-line', '4');
line4.setAttribute('data-additions', '');
const line8 = document.createElement('div');
line8.setAttribute('data-line', '8');
line8.setAttribute('data-additions', '');
host!.appendChild(line4);
host!.appendChild(line8);

const originalGetSelection = window.getSelection;
window.getSelection = () => ({
isCollapsed: false,
toString: () => 'line 4 through 8',
anchorNode: line4,
focusNode: line8,
removeAllRanges: () => {},
} as unknown as Selection);

try {
const scrollContainer = (host!.querySelector('.overflow-y-auto') as HTMLElement) ?? host!;
scrollContainer.dispatchEvent(new Event('pointerdown', { bubbles: true }));

const middleLineRange: SelectedLineRange = { start: 6, end: 6, side: 'additions' };
await act(async () => {
options.onGutterUtilityClick?.(middleLineRange, { item });
});

expect(toolbarSelections).toEqual([{ start: 4, end: 8, side: 'additions' }]);
} finally {
window.getSelection = originalGetSelection;
line4.remove();
line8.remove();
}
});

test.skipIf(!hasDom)('no-selection gutter click preserves the single-line fallback (#991)', async () => {
await mount(false);
const { options, item } = getSelectionCallbacks();

const originalGetSelection = window.getSelection;
window.getSelection = () => ({
isCollapsed: true,
toString: () => '',
anchorNode: null,
focusNode: null,
removeAllRanges: () => {},
} as unknown as Selection);

try {
const scrollContainer = (host!.querySelector('.overflow-y-auto') as HTMLElement) ?? host!;
scrollContainer.dispatchEvent(new Event('pointerdown', { bubbles: true }));

const singleLineRange: SelectedLineRange = { start: 6, end: 6, side: 'additions' };
await act(async () => {
options.onGutterUtilityClick?.(singleLineRange, { item });
});

expect(toolbarSelections).toEqual([singleLineRange]);
} finally {
window.getSelection = originalGetSelection;
}
});
});

describe('AllFilesCodeView readOnly (portable guide host)', () => {
Expand Down
97 changes: 94 additions & 3 deletions packages/review-editor/components/AllFilesCodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,21 @@ import { useIsWorkerPoolReadyOrDisabled, useWorkerPoolThemeSync } from '../worke
import type { DiffFile, AnnotationScrollTarget } from '../types';
import { buildFileTree, getVisualFileOrder } from '../utils/buildFileTree';
import { buildCodeNavRequest } from '../utils/buildCodeNavRequest';
import { getDiffSelection, getLineNumberFromNode, getSideFromNode } from '../utils/diffSelection';
import { getDiffSelection, getLineNumberFromNode, getSideFromNode, snapshotDiffSelection, type DiffSelectionSnapshot } from '../utils/diffSelection';
import { isContentConsistentWithPatch } from '../utils/patchConsistency';
import { hashString } from '../utils/hashString';
import {
resolveLineSelectionBehavior,
type LineSelectionSource,
} from '../utils/lineSelectionBehavior';
import {
findHunkLineElement,
getElementScrollTop,
getHunkTargetLine,
resolveTargetHunkIndex,
scrollToHunkElement,
type HunkLike,
} from '../utils/hunkNavigation';
import { isContentlessBinaryPatch, isOversizedReviewStubPatch } from '@plannotator/shared/diff-paths';
import { OversizedFileNotice } from './OversizedFileNotice';
import { ToolbarHost, type ToolbarHostHandle } from './ToolbarHost';
Expand Down Expand Up @@ -637,6 +645,7 @@ export const AllFilesCodeView: React.FC<AllFilesCodeViewProps> = ({
useWorkerPoolThemeSync(pierreTheme.syntaxTheme);
const viewerRef = useRef<CodeViewHandle<DiffAnnotationMetadata> | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const selectionSnapshotRef = useRef<DiffSelectionSnapshot | null>(null);
// State mirror of the scroll container so the leading-content portal can
// mount once CodeView has rendered it (a plain ref can't trigger that).
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -1570,9 +1579,34 @@ export const AllFilesCodeView: React.FC<AllFilesCodeViewProps> = ({
useEffect(() => {
const root = scrollRef.current;
if (!root) return;
let clearTimer: ReturnType<typeof setTimeout> | null = null;
const onPointerDown = () => {
if (clearTimer) {
clearTimeout(clearTimer);
clearTimer = null;
}
selectionSnapshotRef.current = snapshotDiffSelection(root);
};
const onCancel = () => {
selectionSnapshotRef.current = null;
};
const onPointerUp = () => {
clearTimer = setTimeout(() => {
selectionSnapshotRef.current = null;
}, 200);
};
root.addEventListener('pointerdown', onPointerDown, true);
root.addEventListener('pointercancel', onCancel, true);
root.addEventListener('pointerup', onPointerUp, true);
const handler = () => handleContentTextSelection();
root.addEventListener('mouseup', handler, true);
return () => root.removeEventListener('mouseup', handler, true);
return () => {
if (clearTimer) clearTimeout(clearTimer);
root.removeEventListener('pointerdown', onPointerDown, true);
root.removeEventListener('pointercancel', onCancel, true);
root.removeEventListener('pointerup', onPointerUp, true);
root.removeEventListener('mouseup', handler, true);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileSetKey]);

Expand Down Expand Up @@ -1970,7 +2004,16 @@ export const AllFilesCodeView: React.FC<AllFilesCodeViewProps> = ({

const handleGutterUtilityClick = useStableCallback(
(range: SelectedLineRange, item: CodeViewItem<DiffAnnotationMetadata>) => {
handleLineSelectionInteraction('gutter-comment-action', range, item);
const snapshot = selectionSnapshotRef.current;
selectionSnapshotRef.current = null;
let effectiveRange = range;
if (snapshot) {
const snapshotItemId = snapshot.host ? nodeToItemIdRef.current.get(snapshot.host) : undefined;
if (!snapshot.host || !snapshotItemId || snapshotItemId === item.id) {
effectiveRange = { start: snapshot.start, end: snapshot.end, side: snapshot.side };
}
}
handleLineSelectionInteraction('gutter-comment-action', effectiveRange, item);
},
);

Expand Down Expand Up @@ -2241,6 +2284,41 @@ export const AllFilesCodeView: React.FC<AllFilesCodeViewProps> = ({
return () => cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollTargetAnnotation, filePathToItemId]);
const jumpHunk = useStableCallback((direction: 'next' | 'prev') => {
const container = scrollRef.current;
if (!container) return;

const hunkTops: Array<{ top: number; el: HTMLElement; itemId: string }> = [];
for (const item of identity.items) {
if (item.type !== 'diff' || !item.fileDiff?.hunks || isItemCollapsed(item.id)) continue;
for (const hunk of item.fileDiff.hunks as HunkLike[]) {
const target = getHunkTargetLine(hunk);
const el = findHunkLineElement(container, target);
if (el) {
hunkTops.push({
top: getElementScrollTop(container, el),
el,
itemId: item.id,
});
}
}
}

if (hunkTops.length === 0) return;

hunkTops.sort((a, b) => a.top - b.top);

const targetIdx = resolveTargetHunkIndex(
hunkTops.map((h) => h.top),
container.scrollTop,
direction,
);

if (targetIdx != null) {
scrollToHunkElement(hunkTops[targetIdx].el);
}
});


useEffect(() => {
if (!isActive || readOnly) return;
Expand Down Expand Up @@ -2316,6 +2394,18 @@ export const AllFilesCodeView: React.FC<AllFilesCodeViewProps> = ({
onStage?.(currentPath);
return;
}
// n / p — jump to next / previous changed hunk.
if (e.key === 'n' || e.key === 'N') {
e.preventDefault();
jumpHunk('next');
return;
}
if (e.key === 'p' || e.key === 'P') {
e.preventDefault();
jumpHunk('prev');
return;
}


if (e.key !== '[' && e.key !== ']') return;
e.preventDefault();
Expand Down Expand Up @@ -2346,6 +2436,7 @@ export const AllFilesCodeView: React.FC<AllFilesCodeViewProps> = ({
canStageFiles,
canStagePath,
onStage,
jumpHunk,
]);

// --- Custom header render slot (the full Plannotator FileHeader) -----------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,4 +250,69 @@ describe.if(hasDom)('DiffViewer compact-touch line selection (DOM)', () => {
// the controlled-repaint handler.
expect('onLineSelectionChange' in pierre().options).toBe(false);
});

test('multi-line selection + gutter click on the middle line publishes the full range (#991)', async () => {
await mount(false);

const line2 = document.createElement('div');
line2.setAttribute('data-line', '2');
line2.setAttribute('data-additions', '');
const line4 = document.createElement('div');
line4.setAttribute('data-line', '4');
line4.setAttribute('data-additions', '');
host!.appendChild(line2);
host!.appendChild(line4);

const originalGetSelection = window.getSelection;
window.getSelection = () => ({
isCollapsed: false,
toString: () => 'line 2\nline 3\nline 4',
anchorNode: line2,
focusNode: line4,
removeAllRanges: () => {},
} as unknown as Selection);

try {
const diffContainer = host!.querySelector('.p-4') as HTMLElement;
diffContainer.dispatchEvent(new Event('pointerdown', { bubbles: true }));

const middleLineRange: SelectedLineRange = { start: 3, end: 3, side: 'additions' };
await act(async () => {
pierre().options.onGutterUtilityClick?.(middleLineRange);
});

expect(toolbarSelections).toEqual([{ start: 2, end: 4, side: 'additions' }]);
} finally {
window.getSelection = originalGetSelection;
line2.remove();
line4.remove();
}
});

test('no-selection gutter click preserves the single-line fallback (#991)', async () => {
await mount(false);

const originalGetSelection = window.getSelection;
window.getSelection = () => ({
isCollapsed: true,
toString: () => '',
anchorNode: null,
focusNode: null,
removeAllRanges: () => {},
} as unknown as Selection);

try {
const diffContainer = host!.querySelector('.p-4') as HTMLElement;
diffContainer.dispatchEvent(new Event('pointerdown', { bubbles: true }));

const singleLineRange: SelectedLineRange = { start: 3, end: 3, side: 'additions' };
await act(async () => {
pierre().options.onGutterUtilityClick?.(singleLineRange);
});

expect(toolbarSelections).toEqual([singleLineRange]);
} finally {
window.getSelection = originalGetSelection;
}
});
});
Loading