Skip to content

Commit 4874e9c

Browse files
committed
Fix line navigation via keyboard
1 parent 22b51ad commit 4874e9c

File tree

1 file changed

+280
-35
lines changed

1 file changed

+280
-35
lines changed

src/browser/contexts/pr-review/index.tsx

Lines changed: 280 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,7 @@ export class PRReviewStore {
806806
};
807807

808808
// Switch between left (old) and right (new) sides in split view
809+
// This uses the same split-pair logic as the UI to navigate like a grid
809810
navigateSide = (direction: "left" | "right") => {
810811
const {
811812
focusedLine,
@@ -826,48 +827,90 @@ export class PRReviewStore {
826827
const diff = selectedFile ? loadedDiffs[selectedFile] : null;
827828
if (!diff?.hunks) return;
828829

829-
// Helper to check if a line matches and handle switching
830-
const trySwitch = (line: DiffLine): boolean => {
831-
// Check if this is the line we're currently focused on
832-
const matchesOld =
833-
focusedLineSide === "old" && line.oldLineNumber === focusedLine;
834-
const matchesNew =
835-
focusedLineSide === "new" && line.newLineNumber === focusedLine;
836-
837-
if (!matchesOld && !matchesNew) return false;
838-
839-
// Only context/merged lines (type "normal") can be on both sides
840-
// Delete lines only exist on old side, insert lines only on new side
841-
if (line.type !== "normal") return true; // Found but can't switch
842-
843-
// Get the line number for the target side
844-
const targetLineNum =
845-
targetSide === "old" ? line.oldLineNumber : line.newLineNumber;
846-
if (targetLineNum !== undefined) {
847-
this.setFocusedLine(targetLineNum, targetSide);
848-
this.setSelectionAnchor(null, null);
830+
// Collect all lines from hunks, substituting expanded skip blocks
831+
const allLines: DiffLine[] = [];
832+
let skipIndex = 0;
833+
for (const hunk of diff.hunks) {
834+
if (hunk.type === "skip") {
835+
const key = `${selectedFile}:${skipIndex}`;
836+
const expandedLines = expandedSkipBlocks[key];
837+
if (expandedLines) {
838+
allLines.push(...expandedLines);
839+
}
840+
skipIndex++;
841+
} else if (hunk.type === "hunk") {
842+
allLines.push(...hunk.lines);
849843
}
850-
return true;
844+
}
845+
846+
// Convert to split pairs (same logic as convertToSplitPairs in pr-review.tsx)
847+
type SplitPair = {
848+
left: DiffLine | null;
849+
right: DiffLine | null;
851850
};
851+
const pairs: SplitPair[] = [];
852+
let i = 0;
853+
854+
while (i < allLines.length) {
855+
const line = allLines[i];
856+
857+
if (line.type === "normal") {
858+
// Context line - show on both sides
859+
pairs.push({ left: line, right: line });
860+
i++;
861+
} else if (line.type === "delete") {
862+
// Collect consecutive deletes
863+
const deletes: DiffLine[] = [];
864+
while (i < allLines.length && allLines[i].type === "delete") {
865+
deletes.push(allLines[i]);
866+
i++;
867+
}
852868

853-
// Search in regular hunks
854-
for (const hunk of diff.hunks) {
855-
if (hunk.type !== "hunk") continue;
856-
for (const line of hunk.lines) {
857-
if (trySwitch(line)) return;
869+
// Collect consecutive inserts that follow
870+
const inserts: DiffLine[] = [];
871+
while (i < allLines.length && allLines[i].type === "insert") {
872+
inserts.push(allLines[i]);
873+
i++;
874+
}
875+
876+
// Pair them up
877+
const maxLen = Math.max(deletes.length, inserts.length);
878+
for (let j = 0; j < maxLen; j++) {
879+
pairs.push({
880+
left: deletes[j] || null,
881+
right: inserts[j] || null,
882+
});
883+
}
884+
} else if (line.type === "insert") {
885+
// Standalone insert (no preceding delete)
886+
pairs.push({ left: null, right: line });
887+
i++;
858888
}
859889
}
860890

861-
// Also search in expanded skip blocks
862-
if (selectedFile) {
863-
for (const key of Object.keys(expandedSkipBlocks)) {
864-
if (!key.startsWith(`${selectedFile}:`)) continue;
865-
const expandedLines = expandedSkipBlocks[key];
866-
if (expandedLines) {
867-
for (const line of expandedLines) {
868-
if (trySwitch(line)) return;
891+
// Find the pair containing our currently focused line
892+
for (const pair of pairs) {
893+
const matchesLeft =
894+
focusedLineSide === "old" && pair.left?.oldLineNumber === focusedLine;
895+
const matchesRight =
896+
focusedLineSide === "new" && pair.right?.newLineNumber === focusedLine;
897+
898+
if (matchesLeft || matchesRight) {
899+
// Found the pair - switch to the other side if it exists
900+
if (direction === "left" && pair.left) {
901+
const targetLineNum = pair.left.oldLineNumber;
902+
if (targetLineNum !== undefined) {
903+
this.setFocusedLine(targetLineNum, "old");
904+
this.setSelectionAnchor(null, null);
905+
}
906+
} else if (direction === "right" && pair.right) {
907+
const targetLineNum = pair.right.newLineNumber;
908+
if (targetLineNum !== undefined) {
909+
this.setFocusedLine(targetLineNum, "new");
910+
this.setSelectionAnchor(null, null);
869911
}
870912
}
913+
return;
871914
}
872915
}
873916
};
@@ -1188,7 +1231,209 @@ export class PRReviewStore {
11881231
}
11891232
}
11901233

1191-
// Normal line/skip navigation - find current position in navigableItems
1234+
// In split view, navigate through visual rows while staying on the same side
1235+
const { diffViewMode } = this.state;
1236+
if (diffViewMode === "split" && focusedLine !== null && focusedLineSide) {
1237+
// Collect all lines for split pair computation
1238+
const allLines: DiffLine[] = [];
1239+
let skipIdx = 0;
1240+
const skipBlockIndices: { pairIdx: number; skipIndex: number }[] = [];
1241+
1242+
for (const hunk of diff.hunks) {
1243+
if (hunk.type === "skip") {
1244+
const key = `${selectedFile}:${skipIdx}`;
1245+
const expandedLines = expandedSkipBlocks[key];
1246+
if (expandedLines) {
1247+
allLines.push(...expandedLines);
1248+
} else {
1249+
// Mark where skip block would appear in pairs
1250+
skipBlockIndices.push({
1251+
pairIdx: allLines.length, // Will be adjusted after pair conversion
1252+
skipIndex: skipIdx,
1253+
});
1254+
}
1255+
skipIdx++;
1256+
} else if (hunk.type === "hunk") {
1257+
allLines.push(...hunk.lines);
1258+
}
1259+
}
1260+
1261+
// Convert to split pairs
1262+
type SplitPair = {
1263+
type: "pair";
1264+
left: DiffLine | null;
1265+
right: DiffLine | null;
1266+
};
1267+
type SplitSkip = { type: "skip"; skipIndex: number };
1268+
type SplitItem = SplitPair | SplitSkip;
1269+
1270+
const pairs: SplitItem[] = [];
1271+
let i = 0;
1272+
let lineIdx = 0;
1273+
1274+
// Insert skip blocks at correct positions
1275+
const getSkipAtLineIdx = (idx: number) =>
1276+
skipBlockIndices.find((s) => s.pairIdx === idx);
1277+
1278+
while (i < allLines.length) {
1279+
// Check if there's a skip block before this line
1280+
const skipHere = getSkipAtLineIdx(lineIdx);
1281+
if (skipHere) {
1282+
pairs.push({ type: "skip", skipIndex: skipHere.skipIndex });
1283+
// Remove from tracking
1284+
skipBlockIndices.splice(skipBlockIndices.indexOf(skipHere), 1);
1285+
}
1286+
1287+
const line = allLines[i];
1288+
lineIdx = i;
1289+
1290+
if (line.type === "normal") {
1291+
pairs.push({ type: "pair", left: line, right: line });
1292+
i++;
1293+
} else if (line.type === "delete") {
1294+
const deletes: DiffLine[] = [];
1295+
while (i < allLines.length && allLines[i].type === "delete") {
1296+
deletes.push(allLines[i]);
1297+
i++;
1298+
}
1299+
const inserts: DiffLine[] = [];
1300+
while (i < allLines.length && allLines[i].type === "insert") {
1301+
inserts.push(allLines[i]);
1302+
i++;
1303+
}
1304+
const maxLen = Math.max(deletes.length, inserts.length);
1305+
for (let j = 0; j < maxLen; j++) {
1306+
pairs.push({
1307+
type: "pair",
1308+
left: deletes[j] || null,
1309+
right: inserts[j] || null,
1310+
});
1311+
}
1312+
} else if (line.type === "insert") {
1313+
pairs.push({ type: "pair", left: null, right: line });
1314+
i++;
1315+
}
1316+
}
1317+
1318+
// Add any remaining skip blocks at the end
1319+
for (const skip of skipBlockIndices) {
1320+
pairs.push({ type: "skip", skipIndex: skip.skipIndex });
1321+
}
1322+
1323+
// Find current pair index
1324+
const currentPairIdx = pairs.findIndex((item) => {
1325+
if (item.type === "skip") return false;
1326+
if (focusedLineSide === "old") {
1327+
return item.left?.oldLineNumber === focusedLine;
1328+
} else {
1329+
return item.right?.newLineNumber === focusedLine;
1330+
}
1331+
});
1332+
1333+
if (currentPairIdx !== -1) {
1334+
let nextPairIdx: number;
1335+
if (direction === "down") {
1336+
nextPairIdx = Math.min(currentPairIdx + jumpCount, pairs.length - 1);
1337+
} else {
1338+
nextPairIdx = Math.max(currentPairIdx - jumpCount, 0);
1339+
}
1340+
1341+
const nextPair = pairs[nextPairIdx];
1342+
1343+
// If next is a skip block, focus it
1344+
if (nextPair.type === "skip") {
1345+
this.set({
1346+
focusedSkipBlockIndex: nextPair.skipIndex,
1347+
focusedLine: null,
1348+
focusedLineSide: null,
1349+
selectionAnchor: null,
1350+
selectionAnchorSide: null,
1351+
focusedCommentId: null,
1352+
focusedPendingCommentId: null,
1353+
});
1354+
return;
1355+
}
1356+
1357+
// Try to stay on the same side
1358+
let nextLine: number | undefined;
1359+
let nextSide = focusedLineSide;
1360+
1361+
if (focusedLineSide === "old" && nextPair.left) {
1362+
nextLine = nextPair.left.oldLineNumber;
1363+
} else if (focusedLineSide === "new" && nextPair.right) {
1364+
nextLine = nextPair.right.newLineNumber;
1365+
} else if (nextPair.left) {
1366+
// Fallback to left side if preferred side not available
1367+
nextLine = nextPair.left.oldLineNumber;
1368+
nextSide = "old";
1369+
} else if (nextPair.right) {
1370+
// Fallback to right side
1371+
nextLine = nextPair.right.newLineNumber;
1372+
nextSide = "new";
1373+
}
1374+
1375+
if (nextLine !== undefined) {
1376+
// Handle up navigation - check for comments
1377+
if (
1378+
direction === "up" &&
1379+
(nextLine !== focusedLine || nextSide !== focusedLineSide)
1380+
) {
1381+
const targetLineComments = getLineComments(nextLine);
1382+
if (targetLineComments.length > 0) {
1383+
this.set({
1384+
focusedCommentId:
1385+
targetLineComments[targetLineComments.length - 1].id,
1386+
focusedLine: null,
1387+
focusedLineSide: null,
1388+
focusedSkipBlockIndex: null,
1389+
selectionAnchor: null,
1390+
selectionAnchorSide: null,
1391+
});
1392+
return;
1393+
}
1394+
const targetLinePending = getLinePendingComments(nextLine);
1395+
if (targetLinePending.length > 0) {
1396+
this.set({
1397+
focusedPendingCommentId:
1398+
targetLinePending[targetLinePending.length - 1].id,
1399+
focusedLine: null,
1400+
focusedLineSide: null,
1401+
focusedSkipBlockIndex: null,
1402+
selectionAnchor: null,
1403+
selectionAnchorSide: null,
1404+
});
1405+
return;
1406+
}
1407+
}
1408+
1409+
if (withShift) {
1410+
this.set({
1411+
focusedLine: nextLine,
1412+
focusedLineSide: nextSide,
1413+
selectionAnchor: selectionAnchor ?? focusedLine ?? nextLine,
1414+
selectionAnchorSide:
1415+
selectionAnchorSide ?? focusedLineSide ?? nextSide,
1416+
focusedSkipBlockIndex: null,
1417+
focusedCommentId: null,
1418+
focusedPendingCommentId: null,
1419+
});
1420+
} else {
1421+
this.set({
1422+
focusedLine: nextLine,
1423+
focusedLineSide: nextSide,
1424+
selectionAnchor: null,
1425+
selectionAnchorSide: null,
1426+
focusedSkipBlockIndex: null,
1427+
focusedCommentId: null,
1428+
focusedPendingCommentId: null,
1429+
});
1430+
}
1431+
return;
1432+
}
1433+
}
1434+
}
1435+
1436+
// Normal line/skip navigation (unified view or fallback)
11921437
const currentIdx =
11931438
focusedLine !== null
11941439
? navigableItems.findIndex(

0 commit comments

Comments
 (0)