@xuhaojun/githunk 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/githunk.js +1234 -367
  2. package/package.json +6 -6
package/dist/githunk.js CHANGED
@@ -12,7 +12,7 @@ import { Command, CommanderError } from "commander";
12
12
  // package.json
13
13
  var package_default = {
14
14
  name: "@xuhaojun/githunk",
15
- version: "0.3.0",
15
+ version: "0.3.1",
16
16
  description: "A review-first Git TUI combining lazygit's everyday Git workflow with focused hunk review.",
17
17
  type: "module",
18
18
  bin: {
@@ -15961,9 +15961,8 @@ function isWorkerAvailable() {
15961
15961
  }
15962
15962
 
15963
15963
  // src/ui/review-workspace/ReviewWorkspaceApp.tsx
15964
- import { StyledText as StyledText8, parseColor as parseColor3 } from "@opentui/core";
15965
- import { useKeyboard, useTerminalDimensions } from "@opentui/react";
15966
- import { useCallback, useEffect as useEffect3, useLayoutEffect as useLayoutEffect2, useMemo as useMemo4, useRef as useRef2, useState as useState3, useSyncExternalStore } from "react";
15964
+ import { useKeyboard as useKeyboard2, useTerminalDimensions } from "@opentui/react";
15965
+ import { useCallback, useEffect as useEffect3, useLayoutEffect as useLayoutEffect3, useMemo as useMemo5, useRef as useRef3, useState as useState4, useSyncExternalStore } from "react";
15967
15966
 
15968
15967
  // src/review/core/selectors.ts
15969
15968
  function resolveViewedRecord(file, viewed) {
@@ -16124,16 +16123,23 @@ function reviewHeaderLines(state, width) {
16124
16123
  totalDeletions = null;
16125
16124
  const additionsText = totalAdditions === null ? "—" : `+${totalAdditions}`;
16126
16125
  const deletionsText = totalDeletions === null ? "—" : `−${totalDeletions}`;
16127
- const projectionLabel = "Aggregate";
16128
- const line1Raw = `${headLabel} → ${baseLabel} · ${commits} commits · ${files} files · ${additionsText} ${deletionsText} [${projectionLabel}]`;
16129
- const line1 = truncateCell(line1Raw, w);
16126
+ const projectionLabel = state.projection.kind === "since-last-review" ? "Since last review" : state.projection.kind === "commit" ? `Commit ${state.projection.oid.slice(0, 7)}` : "Aggregate";
16127
+ const baseBudget = Math.min(cellWidth(baseLabel), Math.max(1, Math.floor(w / 2)));
16128
+ const headPrefix = w > baseBudget + 3 ? `${truncateCell(headLabel, w - baseBudget - 3)} → ` : "";
16129
+ const baseText = truncateCell(baseLabel, Math.max(0, w - cellWidth(headPrefix)));
16130
+ const suffix = ` · ${commits} commits · ${files} files · ${additionsText} ${deletionsText} [${projectionLabel}]`;
16131
+ const suffixText = truncateCell(suffix, Math.max(0, w - cellWidth(headPrefix) - cellWidth(baseText)));
16130
16132
  const reviewedLabel = `Reviewed ${progress.viewed}/${progress.total}`;
16131
16133
  const changedPart = progress.changed > 0 ? ` · ${progress.changed} changed` : "";
16132
16134
  const pendingPart = progress.pending > 0 ? ` · ${progress.pending} pending` : progress.pending === 0 ? " · 0 pending" : "";
16133
16135
  const reviewingPart = progress.reviewing > 0 ? ` · ${progress.reviewing} reviewing` : "";
16134
16136
  const line2Raw = `${reviewedLabel}${changedPart}${reviewingPart}${pendingPart}`;
16135
16137
  const line2 = truncateCell(line2Raw, w);
16136
- const line1Spans = [{ text: line1, style: "strong" }];
16138
+ const line1Spans = [
16139
+ { text: headPrefix, style: "strong" },
16140
+ { text: baseText, style: "strong", action: "choose-base" },
16141
+ { text: suffixText, style: "strong" }
16142
+ ];
16137
16143
  const line2Spans = [{ text: line2, style: "dim" }];
16138
16144
  return [line1Spans, line2Spans];
16139
16145
  }
@@ -16368,6 +16374,22 @@ var REVIEW_COMMANDS = [
16368
16374
  available: always,
16369
16375
  hint: "help"
16370
16376
  },
16377
+ {
16378
+ id: "review.toggleSinceLastReview",
16379
+ title: "Review only what changed since the last review",
16380
+ keys: ["s"],
16381
+ focus: ["any"],
16382
+ available: (state) => state.projection.kind !== "aggregate" || state.lastSubmission !== null,
16383
+ hint: "since"
16384
+ },
16385
+ {
16386
+ id: "review.chooseBase",
16387
+ title: "Change base branch",
16388
+ keys: ["B"],
16389
+ focus: ["any"],
16390
+ available: always,
16391
+ hint: "base"
16392
+ },
16371
16393
  {
16372
16394
  id: "review.close",
16373
16395
  title: "Close overlay or workspace",
@@ -16383,7 +16405,7 @@ for (const cmd of REVIEW_COMMANDS) {
16383
16405
  if (!keyToCommand.has(k))
16384
16406
  keyToCommand.set(k, cmd);
16385
16407
  const lower = k.toLowerCase();
16386
- if (lower !== k && !keyToCommand.has(lower))
16408
+ if (k.length > 1 && lower !== k && !keyToCommand.has(lower))
16387
16409
  keyToCommand.set(lower, cmd);
16388
16410
  }
16389
16411
  }
@@ -16406,7 +16428,8 @@ function reviewHelp(focus, state) {
16406
16428
  "review.focusDiff": 0,
16407
16429
  "review.focusFiles": 1,
16408
16430
  "review.toggleFocus": 2,
16409
- "review.layoutCycle": 3
16431
+ "review.layoutCycle": 3,
16432
+ "review.chooseBase": 4
16410
16433
  };
16411
16434
  const ordered = [...available].sort((a, b) => (panelPriority[a.id] ?? 99) - (panelPriority[b.id] ?? 99));
16412
16435
  const lines = ordered.map((c) => `${c.keys.map((key) => key === "tab" ? "Tab" : key).join("/")} ${c.title}`);
@@ -16770,7 +16793,7 @@ function hunkHeaderText(file, index) {
16770
16793
  const hunk = file.metadata.hunks[index];
16771
16794
  if (!hunk)
16772
16795
  return "@@";
16773
- return hunk.hunkSpecs ?? `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`;
16796
+ return hunk.hunkSpecs?.replace(/\r?\n$/u, "") ?? `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`;
16774
16797
  }
16775
16798
  function hunkGapBefore(file, hunkIndex) {
16776
16799
  if (hunkIndex <= 0)
@@ -17377,8 +17400,114 @@ function ReviewDiffSection({
17377
17400
  });
17378
17401
  }
17379
17402
 
17403
+ // src/ui/review-workspace/components/ReviewStickyHeader.tsx
17404
+ import { StyledText as StyledText8, parseColor as parseColor3 } from "@opentui/core";
17405
+ import { jsx as jsx3 } from "@opentui/react/jsx-runtime";
17406
+ var STICKY_BACKGROUND = "#2b3138";
17407
+ var PATH_FOREGROUND = "#e0e2e4";
17408
+ var HUNK_FOREGROUND = "#7aa6da";
17409
+ var colorCache3 = new Map;
17410
+ function color2(value) {
17411
+ const cached = colorCache3.get(value);
17412
+ if (cached)
17413
+ return cached;
17414
+ const parsed = parseColor3(value);
17415
+ colorCache3.set(value, parsed);
17416
+ return parsed;
17417
+ }
17418
+ function chunk2(text, fg5) {
17419
+ return { __isChunk: true, text, fg: color2(fg5), bg: color2(STICKY_BACKGROUND) };
17420
+ }
17421
+ function fit(text, width) {
17422
+ if (width <= 0)
17423
+ return { text: "", used: 0 };
17424
+ let out = "";
17425
+ let used = 0;
17426
+ for (const character of text) {
17427
+ const characterWidth = cellWidth(character);
17428
+ if (used + characterWidth > width)
17429
+ break;
17430
+ out += character;
17431
+ used += characterWidth;
17432
+ }
17433
+ return { text: out, used };
17434
+ }
17435
+ function stickyHeaderChunks(sticky, width) {
17436
+ if (width <= 0)
17437
+ return [];
17438
+ const chunks = [];
17439
+ const hunkText2 = sticky.hunkText ?? "";
17440
+ const hunkWidth = hunkText2.length === 0 ? 0 : Math.min(width, cellWidth(hunkText2) + 2);
17441
+ const path = fit(sticky.filePath, Math.max(0, width - hunkWidth));
17442
+ if (path.text.length > 0)
17443
+ chunks.push(chunk2(path.text, PATH_FOREGROUND));
17444
+ let used = path.used;
17445
+ if (hunkText2.length > 0 && used < width) {
17446
+ const separator = fit(" ", width - used);
17447
+ if (separator.text.length > 0) {
17448
+ chunks.push(chunk2(separator.text, PATH_FOREGROUND));
17449
+ used += separator.used;
17450
+ }
17451
+ const hunk = fit(hunkText2, width - used);
17452
+ if (hunk.text.length > 0) {
17453
+ chunks.push(chunk2(hunk.text, HUNK_FOREGROUND));
17454
+ used += hunk.used;
17455
+ }
17456
+ }
17457
+ if (used < width)
17458
+ chunks.push(chunk2(" ".repeat(width - used), PATH_FOREGROUND));
17459
+ return chunks;
17460
+ }
17461
+ function ReviewStickyHeader({ sticky, width }) {
17462
+ return /* @__PURE__ */ jsx3("box", {
17463
+ id: "review-sticky-header",
17464
+ style: { width: "100%", height: 1, flexShrink: 0, backgroundColor: STICKY_BACKGROUND },
17465
+ children: /* @__PURE__ */ jsx3("text", {
17466
+ content: new StyledText8([...stickyHeaderChunks(sticky, Math.max(0, width))]),
17467
+ wrapMode: "none",
17468
+ truncate: true
17469
+ })
17470
+ });
17471
+ }
17472
+
17473
+ // src/ui/review-workspace/sticky-header.ts
17474
+ function headerTopOf(sectionOffsets, index) {
17475
+ return (sectionOffsets[index] ?? 0) + (index > 0 ? 1 : 0);
17476
+ }
17477
+ function sectionIndexAt(sectionOffsets, fileCount, top) {
17478
+ let index = 0;
17479
+ while (index < fileCount - 1 && headerTopOf(sectionOffsets, index + 1) < top)
17480
+ index += 1;
17481
+ return index;
17482
+ }
17483
+ function resolveStickyDiffHeader(request) {
17484
+ const { files, state, layout, sectionOffsets, expandedSourceByGap } = request;
17485
+ if (files.length === 0)
17486
+ return;
17487
+ const top = Math.max(0, Math.floor(request.scrollTop));
17488
+ const fileIndex = sectionIndexAt(sectionOffsets, files.length, top);
17489
+ const file = files[fileIndex];
17490
+ if (!file)
17491
+ return;
17492
+ const localRow = top - (sectionOffsets[fileIndex] ?? 0);
17493
+ const showDivider = fileIndex > 0;
17494
+ let hunkIndex = -1;
17495
+ for (let candidate = 0;candidate < file.metadata.hunks.length; candidate += 1) {
17496
+ const headerRow = hunkSectionRowOffset(file, layout, candidate, state, expandedSourceByGap, showDivider);
17497
+ if (headerRow > localRow)
17498
+ break;
17499
+ hunkIndex = candidate;
17500
+ }
17501
+ return {
17502
+ fileKey: file.id,
17503
+ filePath: file.path,
17504
+ hunkIndex,
17505
+ ...hunkIndex < 0 ? {} : { hunkText: hunkHeaderText(file, hunkIndex) }
17506
+ };
17507
+ }
17508
+
17380
17509
  // src/ui/review-workspace/components/ReviewDiffPane.tsx
17381
- import { jsx as jsx3, jsxs as jsxs3 } from "@opentui/react/jsx-runtime";
17510
+ import { jsx as jsx4, jsxs as jsxs3 } from "@opentui/react/jsx-runtime";
17382
17511
  function sectionWindow(files, state, layout, scrollTop, viewportHeight, overscan, expandedSourceByGap) {
17383
17512
  const heights = [];
17384
17513
  const offsets = [0];
@@ -17412,6 +17541,7 @@ function ReviewDiffPane({
17412
17541
  selectedHunkIndex,
17413
17542
  showLineNumbers = true,
17414
17543
  wrapLines = false,
17544
+ showStickyHeader = true,
17415
17545
  overscan = Math.max(10, height * 2),
17416
17546
  highlightByFileKey,
17417
17547
  expandedSourceByGap,
@@ -17430,12 +17560,14 @@ function ReviewDiffPane({
17430
17560
  const ownedScrollRef = useRef(null);
17431
17561
  const scrollRef = externalScrollRef ?? ownedScrollRef;
17432
17562
  const [scrollTop, setScrollTop] = useState(0);
17563
+ const stickyRows = showStickyHeader && files.length > 0 ? 1 : 0;
17564
+ const viewportHeight = Math.max(1, height - stickyRows);
17433
17565
  const previousFileRevealTokenRef = useRef(selectedFileRevealToken);
17434
17566
  const previousSelectionRef = useRef(null);
17435
17567
  const previousHunkRevealTokenRef = useRef(undefined);
17436
17568
  const pendingSelectionRevealRequestRef = useRef(null);
17437
17569
  const pendingSelectionRevealTimersRef = useRef([]);
17438
- const window = useMemo2(() => sectionWindow(files, state, layout, scrollTop, height, overscan, expandedSourceByGap), [expandedSourceByGap, files, height, layout, overscan, scrollTop, state.expandedGaps, state.feedback]);
17570
+ const window = useMemo2(() => sectionWindow(files, state, layout, scrollTop, viewportHeight, overscan, expandedSourceByGap), [expandedSourceByGap, files, layout, overscan, scrollTop, state.expandedGaps, state.feedback, viewportHeight]);
17439
17571
  useEffect(() => {
17440
17572
  if (!onVisibleFileKeysChange)
17441
17573
  return;
@@ -17474,13 +17606,13 @@ function ReviewDiffPane({
17474
17606
  if (state.reveal.scrollToFeedback)
17475
17607
  return;
17476
17608
  const sectionTop = window.offsets[index] ?? 0;
17477
- const target = Math.min(Math.max(0, sectionTop), Math.max(0, window.total - height));
17609
+ const target = Math.min(Math.max(0, sectionTop), Math.max(0, window.total - viewportHeight));
17478
17610
  const scrollBox = scrollRef.current;
17479
17611
  if (scrollBox)
17480
17612
  scrollBox.scrollTop = target;
17481
17613
  setScrollTop(target);
17482
17614
  onViewportChange?.(target);
17483
- }, [files, height, onViewportChange, scrollRef, selectedFileKey, selectedFileRevealToken, state.reveal.scrollToFeedback, window]);
17615
+ }, [files, onViewportChange, scrollRef, selectedFileKey, selectedFileRevealToken, state.reveal.scrollToFeedback, viewportHeight, window]);
17484
17616
  useLayoutEffect(() => {
17485
17617
  const currentSelection = { fileKey: selectedFileKey, hunkIndex: selectedHunkIndex };
17486
17618
  const previousSelection = previousSelectionRef.current;
@@ -17540,11 +17672,11 @@ function ReviewDiffPane({
17540
17672
  const scrollBox = scrollRef.current;
17541
17673
  if (!scrollBox)
17542
17674
  return;
17543
- const viewportHeight = Math.max(1, Math.floor(scrollBox.viewport.height || height));
17675
+ const measuredHeight = Math.max(1, Math.floor(scrollBox.viewport.height || viewportHeight));
17544
17676
  const currentTop = Math.max(0, Math.floor(scrollBox.scrollTop));
17545
- const currentEnd = currentTop + viewportHeight;
17677
+ const currentEnd = currentTop + measuredHeight;
17546
17678
  if (target < currentTop || target + 1 > currentEnd) {
17547
- const nextTop = Math.min(Math.max(0, target), Math.max(0, window.total - viewportHeight));
17679
+ const nextTop = Math.min(Math.max(0, target), Math.max(0, window.total - measuredHeight));
17548
17680
  scrollBox.scrollTop = nextTop;
17549
17681
  setScrollTop(nextTop);
17550
17682
  onViewportChange?.(nextTop);
@@ -17563,73 +17695,323 @@ function ReviewDiffPane({
17563
17695
  }
17564
17696
  }, delay));
17565
17697
  return clearPendingTimers;
17566
- }, [expandedSourceByGap, files, height, layout, onViewportChange, selectedFileKey, selectedFileRevealToken, selectedHunkIndex, selectedHunkRevealToken, state]);
17698
+ }, [expandedSourceByGap, files, layout, onViewportChange, selectedFileKey, selectedFileRevealToken, selectedHunkIndex, selectedHunkRevealToken, state, viewportHeight]);
17699
+ const sticky = useMemo2(() => stickyRows === 0 ? undefined : resolveStickyDiffHeader({
17700
+ files,
17701
+ state,
17702
+ layout,
17703
+ scrollTop,
17704
+ sectionOffsets: window.offsets,
17705
+ ...expandedSourceByGap ? { expandedSourceByGap } : {}
17706
+ }), [expandedSourceByGap, files, layout, scrollTop, state, stickyRows, window.offsets]);
17567
17707
  const leadingSpacer = window.offsets[window.first] ?? 0;
17568
17708
  const trailingSpacer = window.total - (window.offsets[window.last + 1] ?? window.total);
17569
- return /* @__PURE__ */ jsx3("scrollbox", {
17570
- id: "review-diff-scrollbox",
17571
- ref: scrollRef,
17572
- ...focused === undefined ? {} : { focused },
17573
- width: "100%",
17574
- height: "100%",
17575
- scrollY: true,
17576
- viewportCulling: true,
17577
- verticalScrollbarOptions: { visible: false },
17578
- onMouseScroll: (event) => {
17579
- const direction = event.scroll?.direction;
17580
- const delta = Math.max(1, Math.floor(event.scroll?.delta ?? 1));
17581
- const target = event.currentTarget;
17582
- const scrollBox = target && typeof target.scrollBy === "function" ? target : scrollRef.current;
17583
- if (direction === "down")
17584
- scrollBox?.scrollBy(delta);
17585
- else if (direction === "up")
17586
- scrollBox?.scrollBy(-delta);
17587
- },
17588
- children: /* @__PURE__ */ jsxs3("box", {
17589
- id: "review-diff-content",
17590
- style: { width: "100%", flexDirection: "column" },
17709
+ return /* @__PURE__ */ jsxs3("box", {
17710
+ id: "review-diff-pane",
17711
+ style: { width: "100%", height: "100%", flexDirection: "column" },
17712
+ children: [
17713
+ sticky ? /* @__PURE__ */ jsx4(ReviewStickyHeader, {
17714
+ sticky,
17715
+ width
17716
+ }) : null,
17717
+ /* @__PURE__ */ jsx4("scrollbox", {
17718
+ id: "review-diff-scrollbox",
17719
+ ref: scrollRef,
17720
+ ...focused === undefined ? {} : { focused },
17721
+ width: "100%",
17722
+ flexGrow: 1,
17723
+ minHeight: 0,
17724
+ scrollY: true,
17725
+ viewportCulling: true,
17726
+ verticalScrollbarOptions: { visible: false },
17727
+ onMouseScroll: (event) => {
17728
+ const direction = event.scroll?.direction;
17729
+ const delta = Math.max(1, Math.floor(event.scroll?.delta ?? 1));
17730
+ const target = event.currentTarget;
17731
+ const scrollBox = target && typeof target.scrollBy === "function" ? target : scrollRef.current;
17732
+ if (direction === "down")
17733
+ scrollBox?.scrollBy(delta);
17734
+ else if (direction === "up")
17735
+ scrollBox?.scrollBy(-delta);
17736
+ },
17737
+ children: /* @__PURE__ */ jsxs3("box", {
17738
+ id: "review-diff-content",
17739
+ style: { width: "100%", flexDirection: "column" },
17740
+ children: [
17741
+ leadingSpacer > 0 ? /* @__PURE__ */ jsx4("box", {
17742
+ style: { width: "100%", height: leadingSpacer }
17743
+ }, "review-leading-spacer") : null,
17744
+ window.first <= window.last ? files.slice(window.first, window.last + 1).map((file, offset) => {
17745
+ const fileIndex = window.first + offset;
17746
+ const sectionTop = window.offsets[fileIndex] ?? 0;
17747
+ const sectionHeight = window.heights[fileIndex] ?? 0;
17748
+ const rowStart = Math.max(0, Math.floor(scrollTop - sectionTop - overscan));
17749
+ const rowEnd = Math.min(sectionHeight, Math.ceil(scrollTop + viewportHeight + overscan - sectionTop));
17750
+ const highlight = highlightByFileKey?.get(file.id);
17751
+ const select = onSelectFile ? () => onSelectFile(file.id) : undefined;
17752
+ return /* @__PURE__ */ jsx4(ReviewDiffSection, {
17753
+ file,
17754
+ state,
17755
+ layout,
17756
+ width,
17757
+ selectedHunkIndex: file.id === selectedFileKey ? selectedHunkIndex : -1,
17758
+ showDivider: fileIndex > 0,
17759
+ showLineNumbers,
17760
+ wrapLines,
17761
+ rowStart,
17762
+ rowEnd: Math.max(rowStart, rowEnd),
17763
+ ...highlight ? { highlight } : {},
17764
+ ...expandedSourceByGap ? { expandedSourceByGap } : {},
17765
+ ...select ? { onSelect: select } : {},
17766
+ ...onSelectFeedback ? { onSelectFeedback } : {},
17767
+ ...onSelectDiffAddress ? { onSelectDiffAddress } : {},
17768
+ ...selectedFeedbackId !== undefined ? { selectedFeedbackId } : {},
17769
+ ...onToggleGap ? { onToggleGap: (gapId) => onToggleGap(file.id, gapId) } : {}
17770
+ }, file.id);
17771
+ }) : null,
17772
+ trailingSpacer > 0 ? /* @__PURE__ */ jsx4("box", {
17773
+ style: { width: "100%", height: trailingSpacer }
17774
+ }, "review-trailing-spacer") : null
17775
+ ]
17776
+ })
17777
+ })
17778
+ ]
17779
+ });
17780
+ }
17781
+
17782
+ // src/ui/review-workspace/components/ReviewBasePicker.tsx
17783
+ import { useKeyboard } from "@opentui/react";
17784
+ import { useLayoutEffect as useLayoutEffect2, useMemo as useMemo3, useRef as useRef2, useState as useState2 } from "react";
17785
+ import { jsx as jsx5, jsxs as jsxs4 } from "@opentui/react/jsx-runtime";
17786
+ function consume(event) {
17787
+ event.preventDefault();
17788
+ event.stopPropagation();
17789
+ }
17790
+ function ReviewBasePicker({ selection, width, height, active: active2, warning, onChoose, onCancel, onRetry }) {
17791
+ const [query, setQuery] = useState2("");
17792
+ const [selectedIndex, setSelectedIndex] = useState2(0);
17793
+ const inputRef = useRef2(null);
17794
+ const scrollRef = useRef2(null);
17795
+ const busy = selection.loading || selection.selecting;
17796
+ const candidates = useMemo3(() => {
17797
+ const filter = query.trim().toLowerCase();
17798
+ return filter ? selection.candidates.filter((candidate) => candidate.label.toLowerCase().includes(filter) || candidate.ref.toLowerCase().includes(filter)) : selection.candidates;
17799
+ }, [query, selection.candidates]);
17800
+ const index = Math.min(selectedIndex, Math.max(0, candidates.length - 1));
17801
+ const selected = candidates[index];
17802
+ const dialogWidth = Math.max(1, Math.min(90, width - (width > 20 ? 4 : 0)));
17803
+ const dialogHeight = Math.max(1, Math.min(20, height - (height > 10 ? 2 : 0)));
17804
+ const border = dialogWidth >= 4 && dialogHeight >= 5;
17805
+ const showDetail = dialogHeight >= 9;
17806
+ const contentWidth = Math.max(1, dialogWidth - (border ? 2 : 0));
17807
+ const warningHeight = warning && dialogHeight >= 9 ? 1 : 0;
17808
+ const listHeight = Math.max(1, dialogHeight - (border ? 2 : 0) - 3 - (showDetail ? 1 : 0) - warningHeight);
17809
+ const reveal = (next) => {
17810
+ const scroll = scrollRef.current;
17811
+ if (!scroll)
17812
+ return;
17813
+ const viewportHeight = Math.max(1, Math.floor(scroll.viewport.height || listHeight));
17814
+ if (next < scroll.scrollTop)
17815
+ scroll.scrollTop = next;
17816
+ else if (next >= scroll.scrollTop + viewportHeight)
17817
+ scroll.scrollTop = next - viewportHeight + 1;
17818
+ };
17819
+ useLayoutEffect2(() => {
17820
+ reveal(index);
17821
+ }, [index, candidates, listHeight]);
17822
+ useLayoutEffect2(() => {
17823
+ inputRef.current?.blur();
17824
+ }, [active2, busy]);
17825
+ const move = (direction) => {
17826
+ if (busy)
17827
+ return;
17828
+ const next = Math.max(0, Math.min(candidates.length - 1, index + (direction === "down" ? 1 : -1)));
17829
+ setSelectedIndex(next);
17830
+ reveal(next);
17831
+ };
17832
+ const submit = () => {
17833
+ if (busy)
17834
+ return;
17835
+ if (selected)
17836
+ onChoose(selected.ref);
17837
+ else if (selection.error)
17838
+ onRetry();
17839
+ };
17840
+ const handlePickerKey = (event) => {
17841
+ const name = event.name.toLowerCase();
17842
+ if (busy) {
17843
+ consume(event);
17844
+ return true;
17845
+ }
17846
+ if (name === "escape") {
17847
+ consume(event);
17848
+ onCancel();
17849
+ return true;
17850
+ }
17851
+ if (name === "up" || name === "down") {
17852
+ move(name);
17853
+ consume(event);
17854
+ return true;
17855
+ }
17856
+ if (name === "return" || name === "enter") {
17857
+ consume(event);
17858
+ submit();
17859
+ return true;
17860
+ }
17861
+ if (event.ctrl && name === "r") {
17862
+ consume(event);
17863
+ onRetry();
17864
+ return true;
17865
+ }
17866
+ if (name === "tab") {
17867
+ consume(event);
17868
+ return true;
17869
+ }
17870
+ if (name === "backspace") {
17871
+ consume(event);
17872
+ setQuery((previous) => previous.length > 0 ? previous.slice(0, -1) : previous);
17873
+ setSelectedIndex(0);
17874
+ if (scrollRef.current)
17875
+ scrollRef.current.scrollTop = 0;
17876
+ return true;
17877
+ }
17878
+ if (!event.ctrl && !event.meta) {
17879
+ const char = name === "space" ? " " : name;
17880
+ if ([...char].length === 1) {
17881
+ const code = char.codePointAt(0) ?? 0;
17882
+ if (code >= 32 && code !== 127) {
17883
+ consume(event);
17884
+ setQuery((previous) => `${previous}${char}`);
17885
+ setSelectedIndex(0);
17886
+ if (scrollRef.current)
17887
+ scrollRef.current.scrollTop = 0;
17888
+ return true;
17889
+ }
17890
+ }
17891
+ }
17892
+ return false;
17893
+ };
17894
+ useKeyboard((event) => {
17895
+ if (!active2)
17896
+ return;
17897
+ handlePickerKey(event);
17898
+ });
17899
+ const status = selection.loading ? "Loading branches…" : selection.selecting ? "Loading review…" : selection.error ? `Error: ${selection.error}` : selection.candidates.length === 0 ? "No branches available. Ctrl-R retry." : candidates.length === 0 ? "No matching branches." : `${candidates.length} branches · recommendations first`;
17900
+ return /* @__PURE__ */ jsx5("box", {
17901
+ id: "review-base-backdrop",
17902
+ onMouse: consume,
17903
+ style: { position: "absolute", left: 0, top: 0, width, height, zIndex: 100, backgroundColor: "#151515" },
17904
+ children: /* @__PURE__ */ jsxs4("box", {
17905
+ id: "review-base-picker",
17906
+ style: { position: "absolute", left: Math.floor((width - dialogWidth) / 2), top: Math.floor((height - dialogHeight) / 2), width: dialogWidth, height: dialogHeight, border, borderColor: "#b9ca4a", flexDirection: "column", backgroundColor: "#202020", overflow: "hidden" },
17591
17907
  children: [
17592
- leadingSpacer > 0 ? /* @__PURE__ */ jsx3("box", {
17593
- style: { width: "100%", height: leadingSpacer }
17594
- }, "review-leading-spacer") : null,
17595
- window.first <= window.last ? files.slice(window.first, window.last + 1).map((file, offset) => {
17596
- const fileIndex = window.first + offset;
17597
- const sectionTop = window.offsets[fileIndex] ?? 0;
17598
- const sectionHeight = window.heights[fileIndex] ?? 0;
17599
- const rowStart = Math.max(0, Math.floor(scrollTop - sectionTop - overscan));
17600
- const rowEnd = Math.min(sectionHeight, Math.ceil(scrollTop + Math.max(1, height) + overscan - sectionTop));
17601
- const highlight = highlightByFileKey?.get(file.id);
17602
- const select = onSelectFile ? () => onSelectFile(file.id) : undefined;
17603
- return /* @__PURE__ */ jsx3(ReviewDiffSection, {
17604
- file,
17605
- state,
17606
- layout,
17607
- width,
17608
- selectedHunkIndex: file.id === selectedFileKey ? selectedHunkIndex : -1,
17609
- showDivider: fileIndex > 0,
17610
- showLineNumbers,
17611
- wrapLines,
17612
- rowStart,
17613
- rowEnd: Math.max(rowStart, rowEnd),
17614
- ...highlight ? { highlight } : {},
17615
- ...expandedSourceByGap ? { expandedSourceByGap } : {},
17616
- ...select ? { onSelect: select } : {},
17617
- ...onSelectFeedback ? { onSelectFeedback } : {},
17618
- ...onSelectDiffAddress ? { onSelectDiffAddress } : {},
17619
- ...selectedFeedbackId !== undefined ? { selectedFeedbackId } : {},
17620
- ...onToggleGap ? { onToggleGap: (gapId) => onToggleGap(file.id, gapId) } : {}
17621
- }, file.id);
17908
+ /* @__PURE__ */ jsx5("text", {
17909
+ content: "Choose base branch",
17910
+ wrapMode: "none",
17911
+ truncate: true
17912
+ }),
17913
+ /* @__PURE__ */ jsx5("input", {
17914
+ id: "review-base-filter",
17915
+ ref: inputRef,
17916
+ width: contentWidth,
17917
+ value: query,
17918
+ placeholder: "Filter branches…",
17919
+ focused: false,
17920
+ onInput: (value) => {
17921
+ if (busy)
17922
+ return;
17923
+ setQuery(value);
17924
+ setSelectedIndex(0);
17925
+ if (scrollRef.current)
17926
+ scrollRef.current.scrollTop = 0;
17927
+ },
17928
+ onKeyDown: (event) => {
17929
+ if (handlePickerKey(event))
17930
+ return;
17931
+ },
17932
+ onSubmit: () => {
17933
+ submit();
17934
+ }
17935
+ }),
17936
+ /* @__PURE__ */ jsx5("scrollbox", {
17937
+ id: "review-base-list",
17938
+ ref: scrollRef,
17939
+ width: "100%",
17940
+ height: listHeight,
17941
+ flexShrink: 0,
17942
+ scrollY: true,
17943
+ viewportCulling: true,
17944
+ verticalScrollbarOptions: { visible: false },
17945
+ children: /* @__PURE__ */ jsxs4("box", {
17946
+ style: { width: "100%", flexDirection: "column" },
17947
+ children: [
17948
+ candidates.length === 0 ? /* @__PURE__ */ jsx5("text", {
17949
+ content: status,
17950
+ wrapMode: "none",
17951
+ truncate: true
17952
+ }) : null,
17953
+ candidates.map((candidate, candidateIndex) => /* @__PURE__ */ jsx5("box", {
17954
+ id: `review-base-row:${candidate.ref}`,
17955
+ style: { width: "100%", height: 1, flexShrink: 0, backgroundColor: index === candidateIndex ? "#365f8a" : "#202020" },
17956
+ onMouseUp: (event) => {
17957
+ consume(event);
17958
+ if (busy)
17959
+ return;
17960
+ setSelectedIndex(candidateIndex);
17961
+ onChoose(candidate.ref);
17962
+ },
17963
+ children: /* @__PURE__ */ jsx5("text", {
17964
+ content: `${index === candidateIndex ? ">" : " "} ${candidate.label}${candidate.reason ? ` — ${candidate.reason}` : ""}`,
17965
+ selectable: false,
17966
+ wrapMode: "none",
17967
+ truncate: true
17968
+ })
17969
+ }, candidate.ref))
17970
+ ]
17971
+ })
17972
+ }),
17973
+ showDetail ? /* @__PURE__ */ jsx5("text", {
17974
+ id: "review-base-status",
17975
+ content: status,
17976
+ fg: selection.error ? "#f0c674" : "#b4b4b4",
17977
+ wrapMode: "none",
17978
+ truncate: true
17622
17979
  }) : null,
17623
- trailingSpacer > 0 ? /* @__PURE__ */ jsx3("box", {
17624
- style: { width: "100%", height: trailingSpacer }
17625
- }, "review-trailing-spacer") : null
17980
+ warningHeight > 0 && warning ? /* @__PURE__ */ jsx5("text", {
17981
+ id: "review-base-warning",
17982
+ content: warning,
17983
+ fg: "#f0c674",
17984
+ wrapMode: "none",
17985
+ truncate: true
17986
+ }) : null,
17987
+ /* @__PURE__ */ jsxs4("box", {
17988
+ style: { width: "100%", height: 1, flexShrink: 0, flexDirection: "row" },
17989
+ children: [
17990
+ selection.error || selection.candidates.length === 0 ? /* @__PURE__ */ jsx5("box", {
17991
+ id: "review-base-retry",
17992
+ onMouseUp: (event) => {
17993
+ consume(event);
17994
+ if (!busy)
17995
+ onRetry();
17996
+ },
17997
+ children: /* @__PURE__ */ jsx5("text", {
17998
+ content: "[Ctrl-R retry] "
17999
+ })
18000
+ }) : null,
18001
+ /* @__PURE__ */ jsx5("text", {
18002
+ content: selection.error !== undefined || busy ? status : "↑↓ choose · Enter select · Esc cancel",
18003
+ wrapMode: "none",
18004
+ truncate: true
18005
+ })
18006
+ ]
18007
+ })
17626
18008
  ]
17627
18009
  })
17628
18010
  });
17629
18011
  }
17630
18012
 
17631
18013
  // src/ui/review-workspace/hooks/useReviewHighlights.ts
17632
- import { useEffect as useEffect2, useMemo as useMemo3, useState as useState2 } from "react";
18014
+ import { useEffect as useEffect2, useMemo as useMemo4, useState as useState3 } from "react";
17633
18015
 
17634
18016
  // src/review/git/highlight/highlight-cache.ts
17635
18017
  class HighlightCache {
@@ -18237,7 +18619,7 @@ function findPatchChunk(metadata, chunks, index) {
18237
18619
  const byIndex = chunks[index];
18238
18620
  if (byIndex)
18239
18621
  return byIndex;
18240
- return chunks.find((chunk2) => [metadata.name, metadata.prevName].map((v) => normalizeDiffPath2(v)).filter((v) => Boolean(v)).map(stripPrefixes).some((path) => chunk2.includes(`a/${path}`) || chunk2.includes(`b/${path}`) || chunk2.includes(path))) ?? "";
18622
+ return chunks.find((chunk3) => [metadata.name, metadata.prevName].map((v) => normalizeDiffPath2(v)).filter((v) => Boolean(v)).map(stripPrefixes).some((path) => chunk3.includes(`a/${path}`) || chunk3.includes(`b/${path}`) || chunk3.includes(path))) ?? "";
18241
18623
  }
18242
18624
  function parseHunkHeader(line) {
18243
18625
  const m = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
@@ -18249,8 +18631,8 @@ function parseHunkHeader(line) {
18249
18631
  const newCount = m[4] !== undefined ? Number.parseInt(m[4], 10) : 1;
18250
18632
  return { oldStart, oldCount, newStart, newCount };
18251
18633
  }
18252
- function extractHunksFromChunk(chunk2) {
18253
- const lines = chunk2.split(`
18634
+ function extractHunksFromChunk(chunk3) {
18635
+ const lines = chunk3.split(`
18254
18636
  `);
18255
18637
  const hunks = [];
18256
18638
  let currentHunk = null;
@@ -18400,10 +18782,10 @@ function collectHastHighlightRuns(node, appearance) {
18400
18782
  }
18401
18783
  const props = cur.properties ?? {};
18402
18784
  const style = parseStyleValue(props["style"]);
18403
- const color2 = style.get("color");
18785
+ const color3 = style.get("color");
18404
18786
  const bg3 = style.get("background-color");
18405
18787
  const isWordDiff = bg3 !== undefined && bg3 !== "transparent";
18406
- const nextFg = color2 ?? fg5;
18788
+ const nextFg = color3 ?? fg5;
18407
18789
  const nextWordDiff = wordDiff || isWordDiff;
18408
18790
  const pushFg = nextFg;
18409
18791
  const children = cur.children ?? [];
@@ -18590,9 +18972,9 @@ function useReviewHighlights(options) {
18590
18972
  } = options;
18591
18973
  const reviewId = options.reviewId ?? state?.document.identity.id ?? "review";
18592
18974
  const generationId = options.generationId ?? state?.document.generation.id ?? "generation";
18593
- const requestedKey = useMemo3(() => requestedFileKeys?.join("\x00") ?? "", [requestedFileKeys]);
18594
- const [highlights, setHighlights] = useState2(new Map);
18595
- const [loading, setLoading] = useState2(false);
18975
+ const requestedKey = useMemo4(() => requestedFileKeys?.join("\x00") ?? "", [requestedFileKeys]);
18976
+ const [highlights, setHighlights] = useState3(new Map);
18977
+ const [loading, setLoading] = useState3(false);
18596
18978
  useEffect2(() => {
18597
18979
  let cancelled = false;
18598
18980
  if (!enabled || files.length === 0) {
@@ -19153,7 +19535,7 @@ function planReviewIntent(state, intent) {
19153
19535
  }
19154
19536
 
19155
19537
  // src/ui/review-workspace/ReviewWorkspaceApp.tsx
19156
- import { jsx as jsx4, jsxs as jsxs4, Fragment } from "@opentui/react/jsx-runtime";
19538
+ import { jsx as jsx6, jsxs as jsxs5, Fragment } from "@opentui/react/jsx-runtime";
19157
19539
  var REVIEW_SIDEBAR_DEFAULT_WIDTH = 30;
19158
19540
  var REVIEW_SIDEBAR_MIN_WIDTH = 20;
19159
19541
  var REVIEW_DIFF_MIN_CONTENT_WIDTH = 40;
@@ -19168,30 +19550,6 @@ var COLORS2 = {
19168
19550
  changed: "#f0c674",
19169
19551
  feedback: "#c397d8"
19170
19552
  };
19171
- var colorCache3 = new Map;
19172
- function textChunk(text, style) {
19173
- let fg5 = colorCache3.get(COLORS2[style]);
19174
- if (!fg5) {
19175
- fg5 = parseColor3(COLORS2[style]);
19176
- colorCache3.set(COLORS2[style], fg5);
19177
- }
19178
- return { __isChunk: true, text, fg: fg5 };
19179
- }
19180
- function headerText(state, width, error) {
19181
- const lines = [...reviewHeaderLines(state, width)];
19182
- if (error)
19183
- lines.push([{ text: `! ${error.title}: ${error.detail}`, style: "dim" }]);
19184
- const chunks = [];
19185
- for (const [index, line] of lines.entries()) {
19186
- if (index > 0)
19187
- chunks.push(textChunk(`
19188
- `, "plain"));
19189
- for (const span of line) {
19190
- chunks.push(textChunk(span.text, span.style === "strong" ? "strong" : span.style === "dim" ? "dim" : "plain"));
19191
- }
19192
- }
19193
- return new StyledText8(chunks);
19194
- }
19195
19553
  function fitText(text, width, overflowMarker = ".") {
19196
19554
  if (cellWidth(text) <= width)
19197
19555
  return text;
@@ -19216,7 +19574,14 @@ function padText(text, width) {
19216
19574
  return text;
19217
19575
  return text + " ".repeat(width - w);
19218
19576
  }
19219
- function reviewFooter(state, layout, focus) {
19577
+ var PROJECTION_NOTICES = {
19578
+ "no-previous-review": "Since last review needs a finished review to measure from",
19579
+ "history-rewritten": "History was rewritten since the last review — showing the full range",
19580
+ "already-projected": "A projection is already open",
19581
+ stale: "The review moved on while loading — try again",
19582
+ unavailable: "Not available right now"
19583
+ };
19584
+ function reviewFooter(state, layout, focus, notice) {
19220
19585
  const selected = state.selection.fileKey ?? "none";
19221
19586
  const entryFor = (id) => REVIEW_COMMANDS.find((candidate) => candidate.id === id);
19222
19587
  const keyFor = (id) => {
@@ -19231,6 +19596,7 @@ function reviewFooter(state, layout, focus) {
19231
19596
  command("review.focusFiles", true),
19232
19597
  command("review.toggleFocus", true),
19233
19598
  `${command("review.layoutCycle", true)}(${layout})`,
19599
+ command("review.chooseBase"),
19234
19600
  pair("review.moveDown", "review.moveUp"),
19235
19601
  pair("review.nextHunk", "review.prevHunk"),
19236
19602
  pair("review.nextFile", "review.prevFile"),
@@ -19249,7 +19615,8 @@ function reviewFooter(state, layout, focus) {
19249
19615
  command("review.help"),
19250
19616
  command("review.close")
19251
19617
  ];
19252
- return `${hints.join(" | ")} | ${focus} — ${selected}`;
19618
+ const trailer = `${focus} — ${selected}`;
19619
+ return notice ? `${notice} | ${hints.join(" | ")} | ${trailer}` : `${hints.join(" | ")} | ${trailer}`;
19253
19620
  }
19254
19621
  function feedbackDraftText(state) {
19255
19622
  const draft = state.draft;
@@ -19293,7 +19660,7 @@ function isPrintableFilterKey(name) {
19293
19660
  const codePoint = value.codePointAt(0) ?? 0;
19294
19661
  return codePoint >= 32 && codePoint !== 127;
19295
19662
  }
19296
- function consume(event) {
19663
+ function consume2(event) {
19297
19664
  try {
19298
19665
  event.preventDefault?.();
19299
19666
  } catch {}
@@ -19379,37 +19746,53 @@ function ReviewWorkspaceApp({ session }) {
19379
19746
  const active2 = session.active;
19380
19747
  const onClose = session.onClose;
19381
19748
  const finishDialog = session.finishDialog;
19382
- const [publishedState, setPublishedState] = useState3(() => controller.state);
19749
+ const [publishedState] = useState4(() => controller.state);
19750
+ useEffect3(() => {
19751
+ let lastSelection = controller.baseSelection;
19752
+ let hadState = controller.state !== undefined;
19753
+ return controller.subscribe(() => {
19754
+ const nextSelection = controller.baseSelection;
19755
+ const hasState = controller.state !== undefined;
19756
+ if (nextSelection !== lastSelection || hasState !== hadState) {
19757
+ lastSelection = nextSelection;
19758
+ hadState = hasState;
19759
+ session.invalidate();
19760
+ }
19761
+ });
19762
+ }, [controller, session]);
19383
19763
  const state = controller.state ?? publishedState;
19384
- const [layoutMode, setLayoutMode] = useState3("auto");
19385
- const [sidebarWidthPreference, setSidebarWidthPreference] = useState3(REVIEW_SIDEBAR_DEFAULT_WIDTH);
19386
- const [resizeBarHovered, setResizeBarHovered] = useState3(false);
19387
- const [resizingSidebar, setResizingSidebar] = useState3(false);
19388
- const [visibleFileKeys, setVisibleFileKeys] = useState3([]);
19389
- const [focus, setFocus] = useState3("stream");
19390
- const [rangeStart, setRangeStart] = useState3(null);
19391
- const [pendingRangeAnchor, setPendingRangeAnchor] = useState3(null);
19392
- const localRangeIdentityRef = useRef2(null);
19393
- const [selectedFeedbackId, setSelectedFeedbackId] = useState3(null);
19394
- const [editingFeedbackId, setEditingFeedbackId] = useState3(null);
19395
- const [pendingDeleteFeedbackId, setPendingDeleteFeedbackId] = useState3(null);
19396
- const [composerFocus, setComposerFocus] = useState3("body");
19397
- const [composerControlIndex, setComposerControlIndex] = useState3(0);
19398
- const [reanchorFeedbackId, setReanchorFeedbackId] = useState3(null);
19399
- const [feedbackMessage, setFeedbackMessage] = useState3(null);
19400
- const [helpOpen, setHelpOpen] = useState3(false);
19401
- const diffScrollRef = useRef2(null);
19402
- const resizingSidebarRef = useRef2(false);
19403
- const resizeDraggedRef = useRef2(false);
19404
- const resizeReleaseSuppressionRef = useRef2(false);
19405
- const resizeReleaseCleanupTokenRef = useRef2(0);
19406
- const filterInputRef = useRef2(null);
19407
- const pendingDeleteFeedbackRef = useRef2(null);
19408
- const composerBodyRef = useRef2(null);
19409
- const replacementRef = useRef2(null);
19410
- const finishSummaryRef = useRef2(null);
19411
- const finishSubmitRef = useRef2(false);
19412
- const expandedSourceRef = useRef2(new Map);
19764
+ const baseSelection = controller.baseSelection;
19765
+ const [layoutMode, setLayoutMode] = useState4("auto");
19766
+ const [projectionNotice, setProjectionNotice] = useState4(null);
19767
+ const [sidebarWidthPreference, setSidebarWidthPreference] = useState4(REVIEW_SIDEBAR_DEFAULT_WIDTH);
19768
+ const [resizeBarHovered, setResizeBarHovered] = useState4(false);
19769
+ const [resizingSidebar, setResizingSidebar] = useState4(false);
19770
+ const [visibleFileKeys, setVisibleFileKeys] = useState4([]);
19771
+ const [focus, setFocus] = useState4("stream");
19772
+ const [rangeStart, setRangeStart] = useState4(null);
19773
+ const [pendingRangeAnchor, setPendingRangeAnchor] = useState4(null);
19774
+ const localRangeIdentityRef = useRef3(null);
19775
+ const [selectedFeedbackId, setSelectedFeedbackId] = useState4(null);
19776
+ const [editingFeedbackId, setEditingFeedbackId] = useState4(null);
19777
+ const [pendingDeleteFeedbackId, setPendingDeleteFeedbackId] = useState4(null);
19778
+ const [composerFocus, setComposerFocus] = useState4("body");
19779
+ const [composerControlIndex, setComposerControlIndex] = useState4(0);
19780
+ const [reanchorFeedbackId, setReanchorFeedbackId] = useState4(null);
19781
+ const [feedbackMessage, setFeedbackMessage] = useState4(null);
19782
+ const [helpOpen, setHelpOpen] = useState4(false);
19783
+ const diffScrollRef = useRef3(null);
19784
+ const resizingSidebarRef = useRef3(false);
19785
+ const resizeDraggedRef = useRef3(false);
19786
+ const resizeReleaseSuppressionRef = useRef3(false);
19787
+ const resizeReleaseCleanupTokenRef = useRef3(0);
19788
+ const filterInputRef = useRef3(null);
19789
+ const pendingDeleteFeedbackRef = useRef3(null);
19790
+ const composerBodyRef = useRef3(null);
19791
+ const replacementRef = useRef3(null);
19792
+ const finishSummaryRef = useRef3(null);
19793
+ const finishSubmitRef = useRef3(false);
19794
+ const localReviewIdentityRef = useRef3(state?.document.identity.id);
19795
+ const expandedSourceRef = useRef3(new Map);
19413
19796
  const dimensions2 = { width: Math.max(1, terminal.width), height: Math.max(1, terminal.height) };
19414
19797
  const maxSidebarWidth = Math.max(REVIEW_SIDEBAR_MIN_WIDTH, dimensions2.width - REVIEW_RESIZE_BAR_WIDTH - REVIEW_DIFF_BORDER_WIDTH - REVIEW_DIFF_MIN_CONTENT_WIDTH);
19415
19798
  const sidebarWidth = dimensions2.width >= REVIEW_SIDEBAR_VISIBILITY_WIDTH ? Math.min(Math.max(sidebarWidthPreference, REVIEW_SIDEBAR_MIN_WIDTH), maxSidebarWidth) : 0;
@@ -19420,12 +19803,12 @@ function ReviewWorkspaceApp({ session }) {
19420
19803
  const resizeBarHeight = Math.max(1, dimensions2.height - 4 - composerHeight);
19421
19804
  const sidebarFocused = focus === "sidebar" || focus === "filter";
19422
19805
  const diffFocused = focus === "stream";
19423
- const files = useMemo4(() => state ? toHunkReviewFiles(visibleReviewFiles(state)) : [], [state?.document, state?.feedback, state?.filter, state?.viewed]);
19424
- const sidebarEntries = useMemo4(() => state ? buildReviewSidebarEntries(state) : [], [state]);
19425
- const sidebarFileEntries = useMemo4(() => sidebarEntries.filter((entry) => entry.kind === "file"), [sidebarEntries]);
19426
- const sidebarStatsWidth = useMemo4(() => Math.max(0, ...sidebarFileEntries.map((entry) => sidebarEntryStatsWidth(entry))), [sidebarFileEntries]);
19806
+ const files = useMemo5(() => state ? toHunkReviewFiles(visibleReviewFiles(state)) : [], [state?.document, state?.feedback, state?.filter, state?.viewed]);
19807
+ const sidebarEntries = useMemo5(() => state ? buildReviewSidebarEntries(state) : [], [state]);
19808
+ const sidebarFileEntries = useMemo5(() => sidebarEntries.filter((entry) => entry.kind === "file"), [sidebarEntries]);
19809
+ const sidebarStatsWidth = useMemo5(() => Math.max(0, ...sidebarFileEntries.map((entry) => sidebarEntryStatsWidth(entry))), [sidebarFileEntries]);
19427
19810
  const sidebarTextWidth = Math.max(8, sidebarWidth - 2);
19428
- const expandedSourceByGap = useMemo4(() => {
19811
+ const expandedSourceByGap = useMemo5(() => {
19429
19812
  const next = typeof controller.getExpandedSourceByGap === "function" ? controller.getExpandedSourceByGap() : new Map;
19430
19813
  const previous = expandedSourceRef.current;
19431
19814
  if (previous.size === next.size && [...next].every(([key, lines]) => previous.get(key) === lines))
@@ -19506,9 +19889,7 @@ function ReviewWorkspaceApp({ session }) {
19506
19889
  setResizingSidebar(false);
19507
19890
  setResizeBarHovered(false);
19508
19891
  }, []);
19509
- useLayoutEffect2(() => {
19510
- if (active2)
19511
- return;
19892
+ const resetLocalReviewUi = useCallback(() => {
19512
19893
  setRangeStart(null);
19513
19894
  setPendingRangeAnchor(null);
19514
19895
  setSelectedFeedbackId(null);
@@ -19522,8 +19903,33 @@ function ReviewWorkspaceApp({ session }) {
19522
19903
  setHelpOpen(false);
19523
19904
  setFocus("stream");
19524
19905
  resetSidebarResize();
19525
- }, [active2, resetSidebarResize]);
19526
- useLayoutEffect2(() => {
19906
+ setVisibleFileKeys([]);
19907
+ localRangeIdentityRef.current = null;
19908
+ if (diffScrollRef.current)
19909
+ diffScrollRef.current.scrollTop = 0;
19910
+ session.setViewportStart(0);
19911
+ finishDialog.close();
19912
+ }, [finishDialog, resetSidebarResize, session]);
19913
+ useLayoutEffect3(() => {
19914
+ if (!active2)
19915
+ resetLocalReviewUi();
19916
+ }, [active2, resetLocalReviewUi]);
19917
+ useLayoutEffect3(() => {
19918
+ const identity = state?.document.identity.id;
19919
+ if (identity === localReviewIdentityRef.current)
19920
+ return;
19921
+ localReviewIdentityRef.current = identity;
19922
+ resetLocalReviewUi();
19923
+ }, [resetLocalReviewUi, state?.document.identity.id]);
19924
+ useLayoutEffect3(() => {
19925
+ if (!baseSelection)
19926
+ return;
19927
+ filterInputRef.current?.blur();
19928
+ composerBodyRef.current?.blur();
19929
+ replacementRef.current?.blur();
19930
+ resetSidebarResize();
19931
+ }, [baseSelection !== undefined, resetSidebarResize]);
19932
+ useLayoutEffect3(() => {
19527
19933
  if (sidebarWidth > 0)
19528
19934
  return;
19529
19935
  resetSidebarResize();
@@ -19532,8 +19938,8 @@ function ReviewWorkspaceApp({ session }) {
19532
19938
  if (sidebarWidth === 0 && focus !== "stream")
19533
19939
  setFocus("stream");
19534
19940
  }, [focus, sidebarWidth]);
19535
- useLayoutEffect2(() => {
19536
- if (!state?.draft)
19941
+ useLayoutEffect3(() => {
19942
+ if (!state?.draft || baseSelection)
19537
19943
  return;
19538
19944
  if (composerFocus === "body") {
19539
19945
  composerBodyRef.current?.focus();
@@ -19545,7 +19951,7 @@ function ReviewWorkspaceApp({ session }) {
19545
19951
  composerBodyRef.current?.blur();
19546
19952
  replacementRef.current?.blur();
19547
19953
  }
19548
- }, [composerFocus, state?.draft]);
19954
+ }, [baseSelection !== undefined, composerFocus, state?.draft]);
19549
19955
  useEffect3(() => {
19550
19956
  const pending = pendingRangeAnchor;
19551
19957
  const start = rangeStart;
@@ -19794,7 +20200,48 @@ function ReviewWorkspaceApp({ session }) {
19794
20200
  session.invalidate();
19795
20201
  } catch {}
19796
20202
  }, [controller, rangeStart, session]);
20203
+ const requestBaseSelection = useCallback(() => {
20204
+ if (controller.baseSelection || finishDialog.isOpen() || helpOpen)
20205
+ return;
20206
+ controller.requestBaseSelection();
20207
+ }, [controller, finishDialog, helpOpen]);
20208
+ const cancelBaseSelection = useCallback(() => {
20209
+ if (controller.baseSelection?.loading || controller.baseSelection?.selecting)
20210
+ return;
20211
+ controller.cancelBaseSelection();
20212
+ if (!controller.state)
20213
+ onClose();
20214
+ }, [controller, onClose]);
20215
+ const chooseBase = useCallback((ref) => {
20216
+ controller.chooseBase(ref);
20217
+ }, [controller]);
20218
+ const retryBaseSelection = useCallback(() => {
20219
+ controller.requestBaseSelection();
20220
+ }, [controller]);
19797
20221
  const executeCommand = useCallback((commandId, payload) => {
20222
+ if (controller.baseSelection)
20223
+ return false;
20224
+ if (commandId === "review.chooseBase") {
20225
+ requestBaseSelection();
20226
+ return true;
20227
+ }
20228
+ if (commandId === "review.toggleSinceLastReview") {
20229
+ if (controller.exitProjection()) {
20230
+ setProjectionNotice(null);
20231
+ session.invalidate();
20232
+ return true;
20233
+ }
20234
+ setProjectionNotice(null);
20235
+ controller.enterSinceLastReview().then((result) => {
20236
+ if (result.ok) {
20237
+ setProjectionNotice(result.fileCount === 0 ? "Nothing changed since the last review" : null);
20238
+ } else {
20239
+ setProjectionNotice(PROJECTION_NOTICES[result.reason] ?? result.message ?? "Could not open the projection");
20240
+ }
20241
+ session.invalidate();
20242
+ });
20243
+ return true;
20244
+ }
19798
20245
  const current = controller.state;
19799
20246
  if (commandId === "review.focusDiff") {
19800
20247
  setFocus("stream");
@@ -20037,10 +20484,17 @@ function ReviewWorkspaceApp({ session }) {
20037
20484
  return true;
20038
20485
  }
20039
20486
  return false;
20040
- }, [controller, deleteFeedback, diffWidth, editFeedback, finishDialog, focus, onClose, pendingRangeAnchor, rangeStart, reanchorFeedback, selectedFeedbackId, selectFeedback, selectDiffAddress, session, sidebarWidth, toggleGap]);
20487
+ }, [controller, deleteFeedback, diffWidth, editFeedback, finishDialog, focus, onClose, pendingRangeAnchor, rangeStart, reanchorFeedback, requestBaseSelection, selectedFeedbackId, selectFeedback, selectDiffAddress, session, sidebarWidth, toggleGap]);
20041
20488
  const handleKey = useCallback((event) => {
20042
20489
  if (!active2)
20043
20490
  return;
20491
+ if (baseSelection || controller.baseSelection) {
20492
+ if (keyName(event) === "escape") {
20493
+ consume2(event);
20494
+ cancelBaseSelection();
20495
+ }
20496
+ return;
20497
+ }
20044
20498
  const name = keyName(event);
20045
20499
  const current = controller.state;
20046
20500
  if (name === "escape") {
@@ -20052,25 +20506,25 @@ function ReviewWorkspaceApp({ session }) {
20052
20506
  setComposerFocus("body");
20053
20507
  setComposerControlIndex(0);
20054
20508
  session.invalidate();
20055
- consume(event);
20509
+ consume2(event);
20056
20510
  return;
20057
20511
  }
20058
20512
  if (finishDialog.isOpen()) {
20059
20513
  finishDialog.close();
20060
20514
  session.invalidate();
20061
- consume(event);
20515
+ consume2(event);
20062
20516
  return;
20063
20517
  }
20064
20518
  if (helpOpen) {
20065
20519
  setHelpOpen(false);
20066
- consume(event);
20520
+ consume2(event);
20067
20521
  return;
20068
20522
  }
20069
20523
  if (pendingDeleteFeedbackId) {
20070
20524
  pendingDeleteFeedbackRef.current = null;
20071
20525
  setPendingDeleteFeedbackId(null);
20072
20526
  session.invalidate();
20073
- consume(event);
20527
+ consume2(event);
20074
20528
  return;
20075
20529
  }
20076
20530
  if (focus === "filter") {
@@ -20083,19 +20537,19 @@ function ReviewWorkspaceApp({ session }) {
20083
20537
  filterInputRef.current?.blur();
20084
20538
  setFocus("stream");
20085
20539
  }
20086
- consume(event);
20540
+ consume2(event);
20087
20541
  return;
20088
20542
  }
20089
20543
  if (reanchorFeedbackId) {
20090
20544
  setReanchorFeedbackId(null);
20091
20545
  setFeedbackMessage(null);
20092
- consume(event);
20546
+ consume2(event);
20093
20547
  return;
20094
20548
  }
20095
20549
  if (rangeStart || pendingRangeAnchor) {
20096
20550
  setRangeStart(null);
20097
20551
  setPendingRangeAnchor(null);
20098
- consume(event);
20552
+ consume2(event);
20099
20553
  return;
20100
20554
  }
20101
20555
  }
@@ -20103,24 +20557,24 @@ function ReviewWorkspaceApp({ session }) {
20103
20557
  if (event.ctrl && (name === "1" || name === "2" || name === "3")) {
20104
20558
  finishDialog.setDecision(name === "1" ? "comment" : name === "2" ? "approve" : "request-changes");
20105
20559
  session.invalidate();
20106
- consume(event);
20560
+ consume2(event);
20107
20561
  return;
20108
20562
  }
20109
20563
  if (event.ctrl && name.toLowerCase() === "s" || name === "enter") {
20110
20564
  submitFinish();
20111
- consume(event);
20565
+ consume2(event);
20112
20566
  return;
20113
20567
  }
20114
20568
  return;
20115
20569
  }
20116
20570
  if (helpOpen) {
20117
- consume(event);
20571
+ consume2(event);
20118
20572
  return;
20119
20573
  }
20120
20574
  if (current?.draft) {
20121
20575
  if (event.ctrl && name.toLowerCase() === "s") {
20122
20576
  saveDraft();
20123
- consume(event);
20577
+ consume2(event);
20124
20578
  } else if (name === "tab") {
20125
20579
  const hasReplacement = canShowReplacementDraft(current);
20126
20580
  const backwards = event.shift === true;
@@ -20150,14 +20604,14 @@ function ReviewWorkspaceApp({ session }) {
20150
20604
  setComposerControlIndex(0);
20151
20605
  }
20152
20606
  }
20153
- consume(event);
20607
+ consume2(event);
20154
20608
  }
20155
20609
  return;
20156
20610
  }
20157
20611
  if (focus === "filter" && name === "enter") {
20158
20612
  filterInputRef.current?.blur();
20159
20613
  setFocus("stream");
20160
- consume(event);
20614
+ consume2(event);
20161
20615
  return;
20162
20616
  }
20163
20617
  if (focus === "filter" && !event.ctrl && !event.meta && !event.option && isPrintableFilterKey(name))
@@ -20167,17 +20621,30 @@ function ReviewWorkspaceApp({ session }) {
20167
20621
  if (!command || current && !command.available(current))
20168
20622
  return;
20169
20623
  if (executeCommand(command.id))
20170
- consume(event);
20171
- }, [active2, composerControlIndex, composerFocus, controller, executeCommand, finishDialog, focus, helpOpen, onClose, pendingDeleteFeedbackId, pendingRangeAnchor, rangeStart, reanchorFeedbackId, saveDraft, session, submitFinish]);
20172
- useKeyboard(handleKey);
20624
+ consume2(event);
20625
+ }, [active2, baseSelection, cancelBaseSelection, composerControlIndex, composerFocus, controller, executeCommand, finishDialog, focus, helpOpen, onClose, pendingDeleteFeedbackId, pendingRangeAnchor, rangeStart, reanchorFeedbackId, saveDraft, session, submitFinish]);
20626
+ useKeyboard2(handleKey);
20627
+ const basePicker = baseSelection ? /* @__PURE__ */ jsx6(ReviewBasePicker, {
20628
+ selection: baseSelection,
20629
+ width: dimensions2.width,
20630
+ height: dimensions2.height,
20631
+ active: active2,
20632
+ ...controller.error ? { warning: `! ${controller.error.title}: ${controller.error.detail}` } : {},
20633
+ onChoose: chooseBase,
20634
+ onCancel: cancelBaseSelection,
20635
+ onRetry: retryBaseSelection
20636
+ }) : null;
20173
20637
  if (!state) {
20174
- return /* @__PURE__ */ jsx4("box", {
20638
+ return /* @__PURE__ */ jsxs5("box", {
20175
20639
  id: "react-review-workspace",
20176
20640
  visible: active2,
20177
- style: { width: "100%", height: "100%" },
20178
- children: /* @__PURE__ */ jsx4("text", {
20179
- content: "Loading branch review…"
20180
- })
20641
+ style: { position: "relative", width: "100%", height: "100%", overflow: "hidden" },
20642
+ children: [
20643
+ /* @__PURE__ */ jsx6("text", {
20644
+ content: "Choose a base branch to start reviewing."
20645
+ }),
20646
+ basePicker
20647
+ ]
20181
20648
  });
20182
20649
  }
20183
20650
  function selectFile(fileKey, nextFocus2 = "stream") {
@@ -20199,28 +20666,55 @@ function ReviewWorkspaceApp({ session }) {
20199
20666
  const suggestionAllowed = state.draft !== null && state.draft.anchor.kind === "range" && state.draft.anchor.side === "new" && state.document.files.some((file) => file.key === state.draft?.anchor.fileKey && file.source !== "binary" && file.source !== "too-large");
20200
20667
  const replacementInvalid = suggestionReplacementInvalid(state);
20201
20668
  const orphanedFeedback = state.feedback.filter((feedback) => !state.document.files.some((file) => file.key === feedback.anchor.fileKey));
20202
- return /* @__PURE__ */ jsxs4("box", {
20669
+ return /* @__PURE__ */ jsxs5("box", {
20203
20670
  id: "react-review-workspace",
20204
20671
  visible: active2,
20205
20672
  onMouse: handleSidebarResizeMouse,
20206
20673
  style: { position: "relative", width: "100%", height: "100%", flexDirection: "column", overflow: "hidden" },
20207
20674
  children: [
20208
- /* @__PURE__ */ jsx4("box", {
20675
+ /* @__PURE__ */ jsxs5("box", {
20209
20676
  id: "react-review-header",
20210
- style: { width: "100%", height: 3, flexShrink: 0 },
20211
- children: /* @__PURE__ */ jsx4("text", {
20212
- content: headerText(state, dimensions2.width, controller.error),
20213
- wrapMode: "none",
20214
- truncate: true
20215
- })
20677
+ style: { width: "100%", height: 3, flexShrink: 0, flexDirection: "column" },
20678
+ children: [
20679
+ reviewHeaderLines(state, dimensions2.width).map((line, lineIndex) => /* @__PURE__ */ jsx6("box", {
20680
+ style: { width: "100%", height: 1, flexShrink: 0, flexDirection: "row" },
20681
+ children: line.map((span, spanIndex) => span.action === "choose-base" ? /* @__PURE__ */ jsx6("box", {
20682
+ id: "review-base-selector",
20683
+ style: { width: cellWidth(span.text), height: 1, flexShrink: 0 },
20684
+ onMouseUp: (event) => {
20685
+ event.preventDefault();
20686
+ event.stopPropagation();
20687
+ requestBaseSelection();
20688
+ },
20689
+ children: /* @__PURE__ */ jsx6("text", {
20690
+ content: span.text,
20691
+ fg: COLORS2.strong,
20692
+ selectable: false,
20693
+ wrapMode: "none",
20694
+ truncate: true
20695
+ })
20696
+ }, spanIndex) : /* @__PURE__ */ jsx6("text", {
20697
+ content: span.text,
20698
+ fg: span.style === "dim" ? COLORS2.dim : COLORS2.strong,
20699
+ wrapMode: "none",
20700
+ truncate: true
20701
+ }, spanIndex))
20702
+ }, lineIndex)),
20703
+ controller.error ? /* @__PURE__ */ jsx6("text", {
20704
+ content: `! ${controller.error.title}: ${controller.error.detail}`,
20705
+ fg: COLORS2.dim,
20706
+ wrapMode: "none",
20707
+ truncate: true
20708
+ }) : null
20709
+ ]
20216
20710
  }),
20217
- /* @__PURE__ */ jsxs4("box", {
20711
+ /* @__PURE__ */ jsxs5("box", {
20218
20712
  id: "react-review-body",
20219
20713
  style: { width: "100%", flexGrow: 1, flexDirection: "row", overflow: "hidden" },
20220
20714
  children: [
20221
- sidebarWidth > 0 ? /* @__PURE__ */ jsxs4(Fragment, {
20715
+ sidebarWidth > 0 ? /* @__PURE__ */ jsxs5(Fragment, {
20222
20716
  children: [
20223
- /* @__PURE__ */ jsxs4("box", {
20717
+ /* @__PURE__ */ jsxs5("box", {
20224
20718
  id: "react-review-sidebar",
20225
20719
  borderColor: sidebarFocused ? ANSI_GREEN : DEFAULT_FOREGROUND,
20226
20720
  title: `[1] Files ${sidebarFileEntries.length}/${state.document.files.length}`,
@@ -20228,21 +20722,21 @@ function ReviewWorkspaceApp({ session }) {
20228
20722
  style: { width: sidebarWidth, height: "100%", flexShrink: 0, border: true, flexDirection: "column" },
20229
20723
  onMouseDown: () => setFocus("sidebar"),
20230
20724
  children: [
20231
- /* @__PURE__ */ jsxs4("box", {
20725
+ /* @__PURE__ */ jsxs5("box", {
20232
20726
  id: "review-file-filter",
20233
20727
  style: { width: "100%", height: 1, flexShrink: 0, flexDirection: "row" },
20234
20728
  children: [
20235
- /* @__PURE__ */ jsx4("text", {
20729
+ /* @__PURE__ */ jsx6("text", {
20236
20730
  content: "/ ",
20237
20731
  fg: COLORS2.dim
20238
20732
  }),
20239
- /* @__PURE__ */ jsx4("input", {
20733
+ /* @__PURE__ */ jsx6("input", {
20240
20734
  id: "review-file-filter-input",
20241
20735
  ref: filterInputRef,
20242
20736
  width: Math.max(4, sidebarWidth - 4),
20243
20737
  value: state.filter.query,
20244
20738
  placeholder: "filter files",
20245
- focused: focus === "filter",
20739
+ focused: focus === "filter" && !baseSelection,
20246
20740
  onMouseUp: () => {
20247
20741
  if (resizingSidebarRef.current || resizeReleaseSuppressionRef.current)
20248
20742
  return;
@@ -20269,28 +20763,28 @@ function ReviewWorkspaceApp({ session }) {
20269
20763
  filterInputRef.current?.blur();
20270
20764
  setFocus("stream");
20271
20765
  }
20272
- consume(event);
20766
+ consume2(event);
20273
20767
  }
20274
20768
  })
20275
20769
  ]
20276
20770
  }),
20277
- /* @__PURE__ */ jsx4("scrollbox", {
20771
+ /* @__PURE__ */ jsx6("scrollbox", {
20278
20772
  id: "react-review-sidebar-scrollbox",
20279
- focused: focus === "sidebar",
20773
+ focused: focus === "sidebar" && !baseSelection,
20280
20774
  width: "100%",
20281
20775
  flexGrow: 1,
20282
20776
  scrollY: true,
20283
20777
  viewportCulling: true,
20284
20778
  verticalScrollbarOptions: { visible: false },
20285
20779
  onMouseDown: () => setFocus("sidebar"),
20286
- children: /* @__PURE__ */ jsx4("box", {
20780
+ children: /* @__PURE__ */ jsx6("box", {
20287
20781
  style: { width: "100%", flexDirection: "column" },
20288
20782
  children: sidebarEntries.map((entry) => {
20289
20783
  if (entry.kind === "group") {
20290
- return /* @__PURE__ */ jsx4("box", {
20784
+ return /* @__PURE__ */ jsx6("box", {
20291
20785
  id: `review-file-group:${entry.label}`,
20292
20786
  style: { width: "100%", height: 1, backgroundColor: REVIEW_SIDEBAR_THEME.panel, paddingLeft: 1 },
20293
- children: /* @__PURE__ */ jsx4("text", {
20787
+ children: /* @__PURE__ */ jsx6("text", {
20294
20788
  fg: REVIEW_SIDEBAR_THEME.muted,
20295
20789
  children: fitText(entry.label, sidebarTextWidth)
20296
20790
  })
@@ -20299,11 +20793,11 @@ function ReviewWorkspaceApp({ session }) {
20299
20793
  const selected = entry.id === state.selection.fileKey;
20300
20794
  const rowBackground = selected ? REVIEW_SIDEBAR_THEME.panelAlt : REVIEW_SIDEBAR_THEME.panel;
20301
20795
  const stats = sidebarEntryStats(entry);
20302
- const { icon, color: color2 } = getFileStateIcon(entry);
20796
+ const { icon, color: color3 } = getFileStateIcon(entry);
20303
20797
  const iconWidth = icon ? 2 : 0;
20304
20798
  const statsSectionWidth = sidebarStatsWidth > 0 ? sidebarStatsWidth + 1 : 0;
20305
20799
  const nameWidth = Math.max(1, sidebarTextWidth - 1 - iconWidth - statsSectionWidth);
20306
- return /* @__PURE__ */ jsxs4("box", {
20800
+ return /* @__PURE__ */ jsxs5("box", {
20307
20801
  id: `review-file-row:${entry.id}`,
20308
20802
  style: { width: "100%", height: 1, backgroundColor: rowBackground, flexDirection: "row" },
20309
20803
  onMouseDown: () => setFocus("sidebar"),
@@ -20311,30 +20805,30 @@ function ReviewWorkspaceApp({ session }) {
20311
20805
  executeCommand("review.selectFile", { fileKey: entry.id, nextFocus: "sidebar" });
20312
20806
  },
20313
20807
  children: [
20314
- /* @__PURE__ */ jsx4("box", {
20808
+ /* @__PURE__ */ jsx6("box", {
20315
20809
  style: { width: 1, height: 1, backgroundColor: selected ? REVIEW_SIDEBAR_THEME.accent : rowBackground }
20316
20810
  }),
20317
- /* @__PURE__ */ jsxs4("box", {
20811
+ /* @__PURE__ */ jsxs5("box", {
20318
20812
  style: { flexGrow: 1, height: 1, paddingLeft: 0, flexDirection: "row", backgroundColor: rowBackground },
20319
20813
  children: [
20320
- icon ? /* @__PURE__ */ jsx4("text", {
20321
- fg: color2,
20814
+ icon ? /* @__PURE__ */ jsx6("text", {
20815
+ fg: color3,
20322
20816
  children: `${icon} `
20323
20817
  }) : null,
20324
- /* @__PURE__ */ jsx4("text", {
20818
+ /* @__PURE__ */ jsx6("text", {
20325
20819
  fg: REVIEW_SIDEBAR_THEME.text,
20326
20820
  children: padText(fitText(entry.name, nameWidth), nameWidth)
20327
20821
  }),
20328
- statsSectionWidth > 0 ? /* @__PURE__ */ jsx4("box", {
20822
+ statsSectionWidth > 0 ? /* @__PURE__ */ jsx6("box", {
20329
20823
  style: { width: statsSectionWidth, height: 1, flexDirection: "row", justifyContent: "flex-end", backgroundColor: rowBackground },
20330
- children: stats.map((stat3, index) => /* @__PURE__ */ jsxs4("box", {
20824
+ children: stats.map((stat3, index) => /* @__PURE__ */ jsxs5("box", {
20331
20825
  style: { height: 1, flexDirection: "row", backgroundColor: rowBackground },
20332
20826
  children: [
20333
- index > 0 ? /* @__PURE__ */ jsx4("text", {
20827
+ index > 0 ? /* @__PURE__ */ jsx6("text", {
20334
20828
  fg: selected ? REVIEW_SIDEBAR_THEME.text : REVIEW_SIDEBAR_THEME.muted,
20335
20829
  children: " "
20336
20830
  }) : null,
20337
- /* @__PURE__ */ jsx4("text", {
20831
+ /* @__PURE__ */ jsx6("text", {
20338
20832
  fg: stat3.kind === "agent-comment" ? REVIEW_SIDEBAR_THEME.noteBorder : stat3.kind === "addition" ? REVIEW_SIDEBAR_THEME.badgeAdded : REVIEW_SIDEBAR_THEME.badgeRemoved,
20339
20833
  children: stat3.text
20340
20834
  })
@@ -20350,7 +20844,7 @@ function ReviewWorkspaceApp({ session }) {
20350
20844
  })
20351
20845
  ]
20352
20846
  }),
20353
- /* @__PURE__ */ jsx4("box", {
20847
+ /* @__PURE__ */ jsx6("box", {
20354
20848
  id: "review-pane-resize-bar",
20355
20849
  style: { width: REVIEW_RESIZE_BAR_WIDTH, height: "100%", flexShrink: 0 },
20356
20850
  onMouseOver: () => setResizeBarHovered(true),
@@ -20383,7 +20877,7 @@ function ReviewWorkspaceApp({ session }) {
20383
20877
  event.preventDefault();
20384
20878
  event.stopPropagation();
20385
20879
  },
20386
- children: /* @__PURE__ */ jsx4("text", {
20880
+ children: /* @__PURE__ */ jsx6("text", {
20387
20881
  id: "review-pane-resize-bar-glyphs",
20388
20882
  selectable: false,
20389
20883
  content: splitterGlyphs("vertical", REVIEW_RESIZE_BAR_WIDTH, resizeBarHeight, resizeBarHovered || resizingSidebar),
@@ -20396,20 +20890,20 @@ function ReviewWorkspaceApp({ session }) {
20396
20890
  })
20397
20891
  ]
20398
20892
  }) : null,
20399
- /* @__PURE__ */ jsx4("box", {
20893
+ /* @__PURE__ */ jsx6("box", {
20400
20894
  id: "react-review-diff",
20401
20895
  borderColor: diffFocused ? ANSI_GREEN : DEFAULT_FOREGROUND,
20402
20896
  title: `[0] Diff — ${layout}`,
20403
20897
  titleColor: diffFocused ? ANSI_GREEN : DEFAULT_FOREGROUND,
20404
20898
  style: { width: "100%", height: "100%", flexGrow: 1, border: true, minWidth: 0 },
20405
20899
  onMouseDown: () => setFocus("stream"),
20406
- children: /* @__PURE__ */ jsx4(ReviewDiffPane, {
20900
+ children: /* @__PURE__ */ jsx6(ReviewDiffPane, {
20407
20901
  files,
20408
20902
  state,
20409
20903
  layout,
20410
20904
  width: diffWidth,
20411
20905
  height: diffHeight,
20412
- focused: focus === "stream",
20906
+ focused: focus === "stream" && !baseSelection,
20413
20907
  scrollRef: diffScrollRef,
20414
20908
  selectedFileKey: state.selection.fileKey,
20415
20909
  selectedHunkIndex: state.selection.hunkIndex,
@@ -20430,14 +20924,14 @@ function ReviewWorkspaceApp({ session }) {
20430
20924
  },
20431
20925
  selectedFeedbackId,
20432
20926
  onViewportChange: session.setViewportStart
20433
- })
20927
+ }, state.document.identity.id)
20434
20928
  })
20435
20929
  ]
20436
20930
  }),
20437
- orphanedFeedback.length > 0 ? /* @__PURE__ */ jsx4("box", {
20931
+ orphanedFeedback.length > 0 ? /* @__PURE__ */ jsx6("box", {
20438
20932
  id: "review-orphaned-feedback",
20439
20933
  style: { position: "absolute", left: 1, bottom: 1, width: Math.max(20, dimensions2.width - 2), height: Math.min(4, orphanedFeedback.length), zIndex: 50, border: true, flexDirection: "column", backgroundColor: "#202020" },
20440
- children: orphanedFeedback.slice(0, 4).map((feedback) => /* @__PURE__ */ jsxs4("box", {
20934
+ children: orphanedFeedback.slice(0, 4).map((feedback) => /* @__PURE__ */ jsxs5("box", {
20441
20935
  style: { width: "100%", height: 1, flexDirection: "row" },
20442
20936
  onMouseUp: () => {
20443
20937
  if (resizingSidebarRef.current || resizeReleaseSuppressionRef.current)
@@ -20445,32 +20939,32 @@ function ReviewWorkspaceApp({ session }) {
20445
20939
  executeCommand("review.selectFeedback", feedback.id);
20446
20940
  },
20447
20941
  children: [
20448
- /* @__PURE__ */ jsx4("text", {
20942
+ /* @__PURE__ */ jsx6("text", {
20449
20943
  content: `${feedback.resolution} feedback ${feedback.id} — ${feedback.anchor.kind === "range" ? `${feedback.anchor.side}:${feedback.anchor.startLine === feedback.anchor.endLine ? feedback.anchor.startLine : `${feedback.anchor.startLine}-${feedback.anchor.endLine}`}` : "file"} — [a]nchor`,
20450
20944
  wrapMode: "none",
20451
20945
  truncate: true
20452
20946
  }),
20453
- /* @__PURE__ */ jsx4("box", {
20947
+ /* @__PURE__ */ jsx6("box", {
20454
20948
  id: `review-delete-feedback:${feedback.id}`,
20455
20949
  onMouseUp: () => {
20456
20950
  if (resizingSidebarRef.current || resizeReleaseSuppressionRef.current)
20457
20951
  return;
20458
20952
  deleteFeedback(feedback.id);
20459
20953
  },
20460
- children: /* @__PURE__ */ jsx4("text", {
20954
+ children: /* @__PURE__ */ jsx6("text", {
20461
20955
  content: pendingDeleteFeedbackId === feedback.id ? "[delete again]" : "[delete]",
20462
20956
  wrapMode: "none",
20463
20957
  truncate: true
20464
20958
  })
20465
20959
  }),
20466
- /* @__PURE__ */ jsx4("box", {
20960
+ /* @__PURE__ */ jsx6("box", {
20467
20961
  id: `review-reanchor-feedback:${feedback.id}`,
20468
20962
  onMouseUp: () => {
20469
20963
  if (resizingSidebarRef.current || resizeReleaseSuppressionRef.current)
20470
20964
  return;
20471
20965
  reanchorFeedback(feedback.id);
20472
20966
  },
20473
- children: /* @__PURE__ */ jsx4("text", {
20967
+ children: /* @__PURE__ */ jsx6("text", {
20474
20968
  content: "[re-anchor]",
20475
20969
  wrapMode: "none",
20476
20970
  truncate: true
@@ -20479,29 +20973,29 @@ function ReviewWorkspaceApp({ session }) {
20479
20973
  ]
20480
20974
  }, feedback.id))
20481
20975
  }) : null,
20482
- feedbackMessage ? /* @__PURE__ */ jsx4("box", {
20976
+ feedbackMessage ? /* @__PURE__ */ jsx6("box", {
20483
20977
  id: "review-feedback-message",
20484
20978
  style: { position: "absolute", left: 1, bottom: orphanedFeedback.length > 0 ? Math.min(5, orphanedFeedback.length + 1) : 1, width: Math.max(20, dimensions2.width - 2), height: 1, zIndex: 55, backgroundColor: "#202020" },
20485
- children: /* @__PURE__ */ jsx4("text", {
20979
+ children: /* @__PURE__ */ jsx6("text", {
20486
20980
  content: feedbackMessage,
20487
20981
  wrapMode: "none",
20488
20982
  truncate: true
20489
20983
  })
20490
20984
  }) : null,
20491
- state.draft ? /* @__PURE__ */ jsxs4("box", {
20985
+ state.draft ? /* @__PURE__ */ jsxs5("box", {
20492
20986
  id: "review-feedback-composer",
20493
20987
  style: { width: "100%", height: composerHeight, flexShrink: 0, border: true, flexDirection: "column" },
20494
20988
  children: [
20495
- /* @__PURE__ */ jsx4("text", {
20989
+ /* @__PURE__ */ jsx6("text", {
20496
20990
  content: feedbackDraftText(state),
20497
20991
  wrapMode: "none",
20498
20992
  truncate: true
20499
20993
  }),
20500
- /* @__PURE__ */ jsxs4("box", {
20994
+ /* @__PURE__ */ jsxs5("box", {
20501
20995
  id: "review-feedback-controls",
20502
20996
  style: { width: "100%", height: 1, flexDirection: "row" },
20503
20997
  children: [
20504
- /* @__PURE__ */ jsx4("box", {
20998
+ /* @__PURE__ */ jsx6("box", {
20505
20999
  id: "review-feedback-kind-note",
20506
21000
  style: composerFocus === "controls" && composerControlIndex === 0 ? { backgroundColor: "#365f8a" } : {},
20507
21001
  onMouseUp: () => {
@@ -20517,11 +21011,11 @@ function ReviewWorkspaceApp({ session }) {
20517
21011
  session.invalidate();
20518
21012
  } catch {}
20519
21013
  },
20520
- children: /* @__PURE__ */ jsx4("text", {
21014
+ children: /* @__PURE__ */ jsx6("text", {
20521
21015
  content: state.draft.kind === "note" ? "[Note]" : " Note "
20522
21016
  })
20523
21017
  }),
20524
- /* @__PURE__ */ jsx4("box", {
21018
+ /* @__PURE__ */ jsx6("box", {
20525
21019
  id: "review-feedback-kind-suggestion",
20526
21020
  style: composerFocus === "controls" && composerControlIndex === 1 ? { backgroundColor: "#365f8a" } : {},
20527
21021
  onMouseUp: () => {
@@ -20538,11 +21032,11 @@ function ReviewWorkspaceApp({ session }) {
20538
21032
  session.invalidate();
20539
21033
  } catch {}
20540
21034
  },
20541
- children: /* @__PURE__ */ jsx4("text", {
21035
+ children: /* @__PURE__ */ jsx6("text", {
20542
21036
  content: state.draft.kind === "suggestion" ? "[Suggestion]" : " Suggestion "
20543
21037
  })
20544
21038
  }),
20545
- /* @__PURE__ */ jsx4("box", {
21039
+ /* @__PURE__ */ jsx6("box", {
20546
21040
  id: "review-feedback-severity-comment",
20547
21041
  style: composerFocus === "controls" && composerControlIndex === 2 ? { backgroundColor: "#365f8a" } : {},
20548
21042
  onMouseUp: () => {
@@ -20558,11 +21052,11 @@ function ReviewWorkspaceApp({ session }) {
20558
21052
  session.invalidate();
20559
21053
  } catch {}
20560
21054
  },
20561
- children: /* @__PURE__ */ jsx4("text", {
21055
+ children: /* @__PURE__ */ jsx6("text", {
20562
21056
  content: state.draft.severity === "comment" ? "[Comment]" : " Comment "
20563
21057
  })
20564
21058
  }),
20565
- /* @__PURE__ */ jsx4("box", {
21059
+ /* @__PURE__ */ jsx6("box", {
20566
21060
  id: "review-feedback-severity-blocking",
20567
21061
  style: composerFocus === "controls" && composerControlIndex === 3 ? { backgroundColor: "#365f8a" } : {},
20568
21062
  onMouseUp: () => {
@@ -20578,11 +21072,11 @@ function ReviewWorkspaceApp({ session }) {
20578
21072
  session.invalidate();
20579
21073
  } catch {}
20580
21074
  },
20581
- children: /* @__PURE__ */ jsx4("text", {
21075
+ children: /* @__PURE__ */ jsx6("text", {
20582
21076
  content: state.draft.severity === "blocking" ? "[Blocking]" : " Blocking "
20583
21077
  })
20584
21078
  }),
20585
- /* @__PURE__ */ jsx4("box", {
21079
+ /* @__PURE__ */ jsx6("box", {
20586
21080
  id: "review-feedback-save",
20587
21081
  style: composerFocus === "controls" && composerControlIndex === 4 ? { backgroundColor: "#365f8a" } : {},
20588
21082
  onMouseUp: () => {
@@ -20592,11 +21086,11 @@ function ReviewWorkspaceApp({ session }) {
20592
21086
  setComposerControlIndex(4);
20593
21087
  saveDraft();
20594
21088
  },
20595
- children: /* @__PURE__ */ jsx4("text", {
21089
+ children: /* @__PURE__ */ jsx6("text", {
20596
21090
  content: replacementInvalid ? "[Save disabled]" : " [Save] "
20597
21091
  })
20598
21092
  }),
20599
- /* @__PURE__ */ jsx4("box", {
21093
+ /* @__PURE__ */ jsx6("box", {
20600
21094
  id: "review-feedback-cancel",
20601
21095
  style: composerFocus === "controls" && composerControlIndex === 5 ? { backgroundColor: "#365f8a" } : {},
20602
21096
  onMouseUp: () => {
@@ -20614,19 +21108,19 @@ function ReviewWorkspaceApp({ session }) {
20614
21108
  session.invalidate();
20615
21109
  } catch {}
20616
21110
  },
20617
- children: /* @__PURE__ */ jsx4("text", {
21111
+ children: /* @__PURE__ */ jsx6("text", {
20618
21112
  content: " [Cancel] "
20619
21113
  })
20620
21114
  })
20621
21115
  ]
20622
21116
  }),
20623
- /* @__PURE__ */ jsx4("textarea", {
21117
+ /* @__PURE__ */ jsx6("textarea", {
20624
21118
  id: "review-feedback-body",
20625
21119
  ref: composerBodyRef,
20626
21120
  width: "100%",
20627
21121
  height: 2,
20628
21122
  initialValue: state.draft.body,
20629
- focused: composerFocus === "body",
21123
+ focused: composerFocus === "body" && !baseSelection,
20630
21124
  keyBindings: [{ name: "escape", action: "submit" }],
20631
21125
  onSubmit: () => {
20632
21126
  const latest = controller.state;
@@ -20651,10 +21145,10 @@ function ReviewWorkspaceApp({ session }) {
20651
21145
  setComposerFocus("body");
20652
21146
  setComposerControlIndex(0);
20653
21147
  session.invalidate();
20654
- consume(event);
21148
+ consume2(event);
20655
21149
  } else if (event.ctrl && name.toLowerCase() === "s" && latest?.draft) {
20656
21150
  saveDraft();
20657
- consume(event);
21151
+ consume2(event);
20658
21152
  }
20659
21153
  },
20660
21154
  onContentChange: () => {
@@ -20666,16 +21160,16 @@ function ReviewWorkspaceApp({ session }) {
20666
21160
  controller.dispatch(planReviewIntent(latest, { type: "feedback/update-draft", body }));
20667
21161
  } catch {}
20668
21162
  }
20669
- }),
20670
- canShowReplacementDraft(state) ? /* @__PURE__ */ jsxs4(Fragment, {
21163
+ }, state.document.identity.id),
21164
+ canShowReplacementDraft(state) ? /* @__PURE__ */ jsxs5(Fragment, {
20671
21165
  children: [
20672
- /* @__PURE__ */ jsx4("textarea", {
21166
+ /* @__PURE__ */ jsx6("textarea", {
20673
21167
  id: "review-feedback-replacement",
20674
21168
  ref: replacementRef,
20675
21169
  width: "100%",
20676
21170
  height: 2,
20677
21171
  initialValue: state.draft.replacement ?? "",
20678
- focused: composerFocus === "replacement",
21172
+ focused: composerFocus === "replacement" && !baseSelection,
20679
21173
  wrapMode: "char",
20680
21174
  placeholder: "Replacement text",
20681
21175
  onContentChange: () => {
@@ -20688,8 +21182,8 @@ function ReviewWorkspaceApp({ session }) {
20688
21182
  session.invalidate();
20689
21183
  } catch {}
20690
21184
  }
20691
- }),
20692
- replacementInvalid ? /* @__PURE__ */ jsx4("text", {
21185
+ }, state.document.identity.id),
21186
+ replacementInvalid ? /* @__PURE__ */ jsx6("text", {
20693
21187
  id: "review-feedback-replacement-error",
20694
21188
  content: "Invalid replacement: enter non-whitespace text.",
20695
21189
  wrapMode: "none",
@@ -20699,10 +21193,10 @@ function ReviewWorkspaceApp({ session }) {
20699
21193
  }) : null
20700
21194
  ]
20701
21195
  }) : null,
20702
- helpOpen ? /* @__PURE__ */ jsx4("box", {
21196
+ helpOpen ? /* @__PURE__ */ jsx6("box", {
20703
21197
  id: "review-help-dialog",
20704
- style: { position: "absolute", left: Math.max(1, Math.floor(dimensions2.width / 10)), top: 2, width: Math.max(50, Math.floor(dimensions2.width * 4 / 5)), height: Math.min(26, Math.max(14, dimensions2.height - 4)), zIndex: 70, border: true, flexDirection: "column", backgroundColor: "#202020" },
20705
- children: /* @__PURE__ */ jsx4("text", {
21198
+ style: { position: "absolute", left: Math.max(1, Math.floor(dimensions2.width / 10)), top: 2, width: Math.max(50, Math.floor(dimensions2.width * 4 / 5)), height: Math.min(27, Math.max(14, dimensions2.height - 3)), zIndex: 70, border: true, flexDirection: "column", backgroundColor: "#202020" },
21199
+ children: /* @__PURE__ */ jsx6("text", {
20706
21200
  content: `Review commands
20707
21201
  ${reviewHelp(focus, state)}
20708
21202
  Esc close this help`,
@@ -20710,16 +21204,16 @@ Esc close this help`,
20710
21204
  truncate: true
20711
21205
  })
20712
21206
  }) : null,
20713
- finishDialog.isOpen() ? /* @__PURE__ */ jsxs4("box", {
21207
+ finishDialog.isOpen() ? /* @__PURE__ */ jsxs5("box", {
20714
21208
  id: "review-finish-dialog",
20715
21209
  style: { position: "absolute", left: Math.max(1, Math.floor(dimensions2.width / 8)), top: 3, width: Math.max(40, Math.floor(dimensions2.width * 3 / 4)), height: 10, zIndex: 60, border: true, flexDirection: "column", backgroundColor: "#202020" },
20716
21210
  children: [
20717
- /* @__PURE__ */ jsx4("text", {
21211
+ /* @__PURE__ */ jsx6("text", {
20718
21212
  content: `Finish review — ${finishDialog.getDecision()}`,
20719
21213
  wrapMode: "none",
20720
21214
  truncate: true
20721
21215
  }),
20722
- /* @__PURE__ */ jsx4("textarea", {
21216
+ /* @__PURE__ */ jsx6("textarea", {
20723
21217
  id: "review-finish-summary",
20724
21218
  ref: finishSummaryRef,
20725
21219
  width: "100%",
@@ -20733,10 +21227,10 @@ Esc close this help`,
20733
21227
  if (name === "escape") {
20734
21228
  finishDialog.close();
20735
21229
  session.invalidate();
20736
- consume(event);
21230
+ consume2(event);
20737
21231
  } else if (event.ctrl && name.toLowerCase() === "s") {
20738
21232
  submitFinish();
20739
- consume(event);
21233
+ consume2(event);
20740
21234
  }
20741
21235
  },
20742
21236
  keyBindings: [{ name: "enter", action: "submit" }],
@@ -20746,15 +21240,15 @@ Esc close this help`,
20746
21240
  session.invalidate();
20747
21241
  }
20748
21242
  }),
20749
- /* @__PURE__ */ jsx4("text", {
21243
+ /* @__PURE__ */ jsx6("text", {
20750
21244
  content: finishDialog.getValidationMessage(),
20751
21245
  wrapMode: "none",
20752
21246
  truncate: true
20753
21247
  }),
20754
- /* @__PURE__ */ jsxs4("box", {
21248
+ /* @__PURE__ */ jsxs5("box", {
20755
21249
  style: { flexDirection: "row", height: 1 },
20756
21250
  children: [
20757
- /* @__PURE__ */ jsx4("box", {
21251
+ /* @__PURE__ */ jsx6("box", {
20758
21252
  id: "review-finish-comment",
20759
21253
  onMouseUp: () => {
20760
21254
  if (resizingSidebarRef.current || resizeReleaseSuppressionRef.current)
@@ -20762,11 +21256,11 @@ Esc close this help`,
20762
21256
  finishDialog.setDecision("comment");
20763
21257
  session.invalidate();
20764
21258
  },
20765
- children: /* @__PURE__ */ jsx4("text", {
21259
+ children: /* @__PURE__ */ jsx6("text", {
20766
21260
  content: finishDialog.getDecision() === "comment" ? "[Comment]" : " Comment "
20767
21261
  })
20768
21262
  }),
20769
- /* @__PURE__ */ jsx4("box", {
21263
+ /* @__PURE__ */ jsx6("box", {
20770
21264
  id: "review-finish-approve",
20771
21265
  onMouseUp: () => {
20772
21266
  if (resizingSidebarRef.current || resizeReleaseSuppressionRef.current)
@@ -20774,11 +21268,11 @@ Esc close this help`,
20774
21268
  finishDialog.setDecision("approve");
20775
21269
  session.invalidate();
20776
21270
  },
20777
- children: /* @__PURE__ */ jsx4("text", {
21271
+ children: /* @__PURE__ */ jsx6("text", {
20778
21272
  content: finishDialog.getDecision() === "approve" ? "[Approve]" : " Approve "
20779
21273
  })
20780
21274
  }),
20781
- /* @__PURE__ */ jsx4("box", {
21275
+ /* @__PURE__ */ jsx6("box", {
20782
21276
  id: "review-finish-request-changes",
20783
21277
  onMouseUp: () => {
20784
21278
  if (resizingSidebarRef.current || resizeReleaseSuppressionRef.current)
@@ -20786,39 +21280,40 @@ Esc close this help`,
20786
21280
  finishDialog.setDecision("request-changes");
20787
21281
  session.invalidate();
20788
21282
  },
20789
- children: /* @__PURE__ */ jsx4("text", {
21283
+ children: /* @__PURE__ */ jsx6("text", {
20790
21284
  content: finishDialog.getDecision() === "request-changes" ? "[Request Changes]" : " Request Changes "
20791
21285
  })
20792
21286
  })
20793
21287
  ]
20794
21288
  }),
20795
- /* @__PURE__ */ jsx4("box", {
21289
+ /* @__PURE__ */ jsx6("box", {
20796
21290
  id: "review-finish-submit",
20797
21291
  onMouseUp: () => {
20798
21292
  if (resizingSidebarRef.current || resizeReleaseSuppressionRef.current)
20799
21293
  return;
20800
21294
  submitFinish();
20801
21295
  },
20802
- children: /* @__PURE__ */ jsx4("text", {
21296
+ children: /* @__PURE__ */ jsx6("text", {
20803
21297
  content: " Submit "
20804
21298
  })
20805
21299
  }),
20806
- /* @__PURE__ */ jsx4("text", {
21300
+ /* @__PURE__ */ jsx6("text", {
20807
21301
  content: "Ctrl-1 comment · Ctrl-2 approve · Ctrl-3 request changes · Enter/Ctrl-S submit · Esc cancel",
20808
21302
  wrapMode: "none",
20809
21303
  truncate: true
20810
21304
  })
20811
21305
  ]
20812
21306
  }) : null,
20813
- /* @__PURE__ */ jsx4("box", {
21307
+ /* @__PURE__ */ jsx6("box", {
20814
21308
  id: "react-review-footer",
20815
21309
  style: { width: "100%", height: 1, flexShrink: 0 },
20816
- children: /* @__PURE__ */ jsx4("text", {
20817
- content: reviewFooter(state, layout, focus),
21310
+ children: /* @__PURE__ */ jsx6("text", {
21311
+ content: reviewFooter(state, layout, focus, projectionNotice),
20818
21312
  wrapMode: "none",
20819
21313
  truncate: true
20820
21314
  })
20821
- })
21315
+ }),
21316
+ basePicker
20822
21317
  ]
20823
21318
  });
20824
21319
  }
@@ -21144,7 +21639,7 @@ var persistedReviewStateSchema = z.object({
21144
21639
  lastSubmission: submittedReviewRefSchema.nullable(),
21145
21640
  submissionInProgress: submissionInProgressSchema.nullable().optional()
21146
21641
  }).strict();
21147
- var baseByHeadSchema = z.record(z.string(), z.object({ baseRef: z.string().min(1) }).strict()).superRefine((val, ctx) => {
21642
+ var baseByHeadSchema = z.record(z.string(), z.object({ baseRef: z.string().min(1), confirmed: z.boolean().optional() }).strict()).superRefine((val, ctx) => {
21148
21643
  for (const key of Object.keys(val)) {
21149
21644
  if (!isValidBaseByHeadKey(key)) {
21150
21645
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: `invalid baseByHead key: ${key}`, path: [key] });
@@ -21298,8 +21793,9 @@ function toDatabase(raw) {
21298
21793
  for (const [k, v] of Object.entries(raw.reviews))
21299
21794
  reviews[k] = toPersistedReviewState(v);
21300
21795
  const baseByHead = {};
21301
- for (const [k, v] of Object.entries(raw.baseByHead))
21302
- baseByHead[k] = { baseRef: v.baseRef };
21796
+ for (const [k, v] of Object.entries(raw.baseByHead)) {
21797
+ baseByHead[k] = { baseRef: v.baseRef, ...v.confirmed === undefined ? {} : { confirmed: v.confirmed } };
21798
+ }
21303
21799
  return { version: 2, baseByHead, reviews };
21304
21800
  }
21305
21801
  function toIdentity(raw) {
@@ -21665,7 +22161,7 @@ class ReactReviewSession {
21665
22161
  }
21666
22162
 
21667
22163
  // src/ui/review-workspace/react-review-host.tsx
21668
- import { jsx as jsx5 } from "@opentui/react/jsx-runtime";
22164
+ import { jsx as jsx7 } from "@opentui/react/jsx-runtime";
21669
22165
  var rootsByRenderer = new WeakMap;
21670
22166
 
21671
22167
  class ReactReviewHost {
@@ -21688,7 +22184,7 @@ class ReactReviewHost {
21688
22184
  if (!existing.mounted) {
21689
22185
  existing.mounted = true;
21690
22186
  flushSync(() => {
21691
- this.reactRoot.render(/* @__PURE__ */ jsx5(ReviewWorkspaceApp, {
22187
+ this.reactRoot.render(/* @__PURE__ */ jsx7(ReviewWorkspaceApp, {
21692
22188
  session: this.session
21693
22189
  }));
21694
22190
  });
@@ -21700,7 +22196,7 @@ class ReactReviewHost {
21700
22196
  this.reactRoot = createRoot(renderer);
21701
22197
  rootsByRenderer.set(renderer, { root: this.reactRoot, session: this.session, mounted: true });
21702
22198
  flushSync(() => {
21703
- this.reactRoot.render(/* @__PURE__ */ jsx5(ReviewWorkspaceApp, {
22199
+ this.reactRoot.render(/* @__PURE__ */ jsx7(ReviewWorkspaceApp, {
21704
22200
  session: this.session
21705
22201
  }));
21706
22202
  });
@@ -22281,6 +22777,24 @@ function reduceReviewState(state, action) {
22281
22777
  revision: state.revision + 1
22282
22778
  };
22283
22779
  }
22780
+ case "projection/apply": {
22781
+ const firstFile = action.document.files[0] ?? null;
22782
+ return {
22783
+ ...state,
22784
+ document: action.document,
22785
+ projection: action.projection,
22786
+ selection: { fileKey: firstFile?.key ?? null, hunkIndex: 0 },
22787
+ lineSelection: null,
22788
+ expandedGaps: [],
22789
+ reveal: {
22790
+ fileTopToken: state.reveal.fileTopToken + 1,
22791
+ fileTopRequestToken: state.reveal.fileTopRequestToken + 1,
22792
+ hunkToken: state.reveal.hunkToken + 1,
22793
+ scrollToFeedback: false
22794
+ },
22795
+ revision: state.revision + 1
22796
+ };
22797
+ }
22284
22798
  case "projection/set": {
22285
22799
  if (projectionsEqual(state.projection, action.projection))
22286
22800
  return state;
@@ -22860,65 +23374,97 @@ async function currentBranchRef(runner) {
22860
23374
  const ref = (await output(runner, ["symbolic-ref", "--quiet", "HEAD"]))?.trim();
22861
23375
  return ref?.startsWith("refs/heads/") ? ref : undefined;
22862
23376
  }
22863
- async function symbolicDefault(runner, remote) {
22864
- const symbolic = (await output(runner, ["symbolic-ref", "--quiet", `refs/remotes/${remote}/HEAD`]))?.trim();
22865
- if (symbolic === undefined || !symbolic.startsWith(`refs/remotes/${remote}/`))
22866
- return;
22867
- const oid = await resolveRefOid(runner, symbolic);
22868
- return oid === undefined ? undefined : { ref: symbolic, oid };
22869
- }
22870
- async function remotes(runner) {
22871
- const raw = await output(runner, ["remote"]);
22872
- return (raw ?? "").split(/\r?\n/).map((remote) => remote.trim()).filter(Boolean);
22873
- }
22874
- async function reviewBaseCandidates(runner) {
22875
- const localRaw = await output(runner, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]);
22876
- const remoteRaw = await output(runner, ["for-each-ref", "--format=%(refname)", "refs/remotes"]);
22877
- const configuredRemotes = await remotes(runner);
22878
- const values = [
22879
- ...(localRaw ?? "").split(/\r?\n/),
22880
- ...(remoteRaw ?? "").split(/\r?\n/)
22881
- ].map((ref) => ref.trim()).filter((ref) => {
22882
- if (ref.length === 0)
22883
- return false;
22884
- if (!ref.startsWith("refs/remotes/"))
22885
- return true;
22886
- const remoteRef = ref.slice("refs/remotes/".length);
22887
- return !configuredRemotes.some((remote) => remoteRef === `${remote}/HEAD`);
23377
+ async function inferReviewBase(runner, preferredRef) {
23378
+ const [refResult, branchResult, headResult, remoteResult] = await Promise.all([
23379
+ runner.run([
23380
+ "for-each-ref",
23381
+ "--format=%(refname)%09%(objectname)%09%(objecttype)%09%(symref)%09%(upstream:remotename)",
23382
+ "refs/heads",
23383
+ "refs/remotes"
23384
+ ], { readOnly: true }),
23385
+ runner.run(["symbolic-ref", "--quiet", "HEAD"], { readOnly: true, acceptedExitCodes: [0, 1] }),
23386
+ runner.run(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], { readOnly: true, acceptedExitCodes: [0, 1] }),
23387
+ runner.run(["remote"], { readOnly: true })
23388
+ ]);
23389
+ const branchRef = branchResult.exitCode === 0 ? branchResult.stdout.trim() : undefined;
23390
+ const headOid = headResult.exitCode === 0 ? headResult.stdout.trim() : undefined;
23391
+ const branchName = branchRef?.startsWith("refs/heads/") ? branchRef.slice("refs/heads/".length) : undefined;
23392
+ const refs = [];
23393
+ for (const line of refResult.stdout.split(/\r?\n/)) {
23394
+ const [ref, oid, objectType, symbolic = "", upstreamRemote2 = ""] = line.split("\t");
23395
+ if (ref === undefined || oid === undefined || objectType !== "commit")
23396
+ continue;
23397
+ refs.push({ ref, oid, symbolic, upstreamRemote: upstreamRemote2 });
23398
+ }
23399
+ const upstreamRemote = refs.find(({ ref }) => ref === branchRef)?.upstreamRemote;
23400
+ const remoteNames = remoteResult.stdout.split(/\r?\n/).filter(Boolean).sort((left2, right2) => right2.length - left2.length || (left2 < right2 ? -1 : left2 > right2 ? 1 : 0));
23401
+ const remoteOrder = (remote) => remote === upstreamRemote ? 0 : remote === "origin" ? 1 : 2;
23402
+ const defaults = refs.filter(({ ref, symbolic }) => ref.startsWith("refs/remotes/") && ref.endsWith("/HEAD") && symbolic !== "").sort((left2, right2) => {
23403
+ const leftRemote = left2.ref.slice("refs/remotes/".length, -"/HEAD".length);
23404
+ const rightRemote = right2.ref.slice("refs/remotes/".length, -"/HEAD".length);
23405
+ return remoteOrder(leftRemote) - remoteOrder(rightRemote) || (left2.ref < right2.ref ? -1 : left2.ref > right2.ref ? 1 : 0);
22888
23406
  });
22889
- return [...new Set(values)].sort();
22890
- }
22891
- async function candidates(runner) {
22892
- return reviewBaseCandidates(runner);
22893
- }
22894
- async function inferReviewBase(runner) {
22895
- const branchRef = await currentBranchRef(runner);
22896
- const allCandidates = await candidates(runner);
22897
- if (branchRef === undefined || await resolveRefOid(runner, "HEAD") === undefined) {
22898
- return { kind: "choose", candidates: allCandidates, reason: branchRef === undefined ? "HEAD is detached" : "HEAD has no commit" };
22899
- }
22900
- const remoteNames = await remotes(runner);
22901
- const upstream = (await output(runner, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]))?.trim();
22902
- const upstreamRemote = remoteNames.filter((remote) => upstream !== undefined && upstream.startsWith(`${remote}/`)).sort((left2, right2) => right2.length - left2.length)[0];
22903
- if (upstreamRemote !== undefined) {
22904
- const preferred = await symbolicDefault(runner, upstreamRemote);
22905
- if (preferred !== undefined) {
22906
- return { kind: "confident", ref: preferred.ref, oid: preferred.oid, reason: `remote default for upstream ${upstreamRemote}` };
22907
- }
22908
- }
22909
- if (remoteNames.includes("origin")) {
22910
- const preferred = await symbolicDefault(runner, "origin");
22911
- if (preferred !== undefined) {
22912
- return { kind: "confident", ref: preferred.ref, oid: preferred.oid, reason: "origin symbolic default" };
22913
- }
22914
- }
22915
- if (remoteNames.length === 1) {
22916
- const preferred = await symbolicDefault(runner, remoteNames[0]);
22917
- if (preferred !== undefined) {
22918
- return { kind: "confident", ref: preferred.ref, oid: preferred.oid, reason: "sole remote symbolic default" };
23407
+ const defaultRanks = new Map(defaults.map(({ symbolic }, index) => [symbolic, index]));
23408
+ const defaultRemotes = new Map(defaults.map(({ ref, symbolic }) => [symbolic, ref.slice("refs/remotes/".length, -"/HEAD".length)]));
23409
+ const branches = refs.filter(({ ref, symbolic }) => ref !== branchRef && symbolic === "" && !(ref.startsWith("refs/remotes/") && ref.endsWith("/HEAD")));
23410
+ const firstParentDistances = new Map;
23411
+ if (headOid !== undefined && branches.length > 0) {
23412
+ const history = await runner.run(["rev-list", "--first-parent", "--max-count=2049", headOid, "--"], { readOnly: true });
23413
+ let distance = 0;
23414
+ for (const oid of history.stdout.trim().split(/\r?\n/)) {
23415
+ if (oid !== "")
23416
+ firstParentDistances.set(oid, distance++);
23417
+ }
23418
+ }
23419
+ const ranked = branches.map(({ ref, oid }) => {
23420
+ const local = ref.startsWith("refs/heads/");
23421
+ const label = ref.slice(local ? "refs/heads/".length : "refs/remotes/".length);
23422
+ const remote = local ? undefined : remoteNames.find((name) => label.startsWith(`${name}/`));
23423
+ const shortName = local ? label : remote === undefined ? undefined : label.slice(remote.length + 1);
23424
+ const trackingHead = !local && branchName !== undefined && shortName === branchName;
23425
+ const sameHead = headOid !== undefined && oid === headOid;
23426
+ const distance = firstParentDistances.get(oid);
23427
+ const defaultRank = defaultRanks.get(ref);
23428
+ const defaultRemote = defaultRemotes.get(ref);
23429
+ const conventionalRank = shortName === "main" ? 0 : shortName === "master" ? 1 : shortName === "develop" ? 2 : 3;
23430
+ const defaultReason = defaultRemote === undefined ? undefined : `Default branch of ${defaultRemote === upstreamRemote ? "upstream remote " : "remote "}${defaultRemote}`;
23431
+ let priority;
23432
+ let reason;
23433
+ if (ref === preferredRef) {
23434
+ priority = 0;
23435
+ reason = "Previously selected review base";
23436
+ } else if (trackingHead || sameHead) {
23437
+ priority = 5;
23438
+ reason = trackingHead ? "Same-name remote branch for current HEAD" : "Points at current HEAD";
23439
+ } else if (distance !== undefined && distance > 0) {
23440
+ priority = 1;
23441
+ reason = `Branch tip ${distance} first-parent ${distance === 1 ? "commit" : "commits"} behind HEAD`;
23442
+ if (defaultReason !== undefined)
23443
+ reason += `; ${defaultReason}`;
23444
+ } else if (defaultRank !== undefined) {
23445
+ priority = 2;
23446
+ reason = defaultReason;
23447
+ } else if (conventionalRank < 3) {
23448
+ priority = 3;
23449
+ reason = `Conventional ${shortName} base branch`;
23450
+ } else {
23451
+ priority = 4;
23452
+ reason = local ? "Local branch" : "Remote branch";
22919
23453
  }
22920
- }
22921
- return { kind: "choose", candidates: allCandidates, reason: "no authoritative remote default" };
23454
+ return {
23455
+ candidate: { ref, label, reason },
23456
+ priority,
23457
+ distance: priority === 1 ? distance : 0,
23458
+ defaultRank: priority === 1 || priority === 2 ? defaultRank ?? defaults.length : 0,
23459
+ conventionalRank: priority === 1 || priority === 3 ? conventionalRank : 0
23460
+ };
23461
+ });
23462
+ ranked.sort((left2, right2) => left2.priority - right2.priority || left2.distance - right2.distance || left2.defaultRank - right2.defaultRank || left2.conventionalRank - right2.conventionalRank || (left2.candidate.ref < right2.candidate.ref ? -1 : left2.candidate.ref > right2.candidate.ref ? 1 : 0));
23463
+ return {
23464
+ kind: "choose",
23465
+ candidates: ranked.map(({ candidate }) => candidate),
23466
+ reason: headOid === undefined ? "HEAD has no commit" : branchRef === undefined ? "HEAD is detached; choose a review base" : "Choose a review base; likely branches are listed first"
23467
+ };
22922
23468
  }
22923
23469
 
22924
23470
  // src/review/git/load-review-document.ts
@@ -23105,6 +23651,187 @@ async function loadReviewDocument(runner, baseRef) {
23105
23651
  return createReviewDocument({ identity, generation, commits: commitsForDoc, files });
23106
23652
  }
23107
23653
 
23654
+ // src/review/git/load-review-projection.ts
23655
+ function normalizePathForJoin2(p) {
23656
+ return p.replace(/[\r\n]+$/u, "");
23657
+ }
23658
+ function kindFromRaw2(rawStatus, isBinary) {
23659
+ if (isBinary)
23660
+ return "binary";
23661
+ const letter = rawStatus[0] ?? "";
23662
+ if (letter === "R")
23663
+ return "renamed";
23664
+ if (letter === "C")
23665
+ return "copied";
23666
+ if (letter === "A")
23667
+ return "added";
23668
+ if (letter === "D")
23669
+ return "deleted";
23670
+ return "modified";
23671
+ }
23672
+ function sourceFromKind2(kind, isBinary) {
23673
+ if (isBinary || kind === "binary")
23674
+ return "binary";
23675
+ return "available";
23676
+ }
23677
+ async function buildFilesForRange(runner, rangeArgs) {
23678
+ const patchResult = await runner.run(["diff", "--no-ext-diff", "--no-color", "--find-renames", "--binary", "--src-prefix=a/", "--dst-prefix=b/", ...rangeArgs, "--"], { readOnly: true });
23679
+ const rawResult = await runner.run(["diff", "--no-ext-diff", "--no-color", "--find-renames", "--raw", "-z", ...rangeArgs, "--"], { readOnly: true });
23680
+ const numstatResult = await runner.run(["diff", "--no-ext-diff", "--no-color", "--find-renames", "--numstat", "-z", ...rangeArgs, "--"], { readOnly: true });
23681
+ const patchText = patchResult.stdout;
23682
+ const rawText = rawResult.stdout;
23683
+ const numstatText = numstatResult.stdout;
23684
+ const parsedPatches = parseReviewPatch(patchText);
23685
+ const rawEntries = parseRawDiffZ(rawText);
23686
+ const numstatEntries = parseNumstatZ(numstatText);
23687
+ const rawByKey = new Map;
23688
+ for (const entry of rawEntries) {
23689
+ const key = `${normalizePathForJoin2(entry.path)}|${entry.previousPath ? normalizePathForJoin2(entry.previousPath) : ""}`;
23690
+ const list = rawByKey.get(key);
23691
+ if (list)
23692
+ list.push(entry);
23693
+ else
23694
+ rawByKey.set(key, [entry]);
23695
+ }
23696
+ const numstatByKey = new Map;
23697
+ for (const entry of numstatEntries) {
23698
+ const key = `${normalizePathForJoin2(entry.path)}|${entry.previousPath ? normalizePathForJoin2(entry.previousPath) : ""}`;
23699
+ const list = numstatByKey.get(key);
23700
+ if (list)
23701
+ list.push(entry);
23702
+ else
23703
+ numstatByKey.set(key, [entry]);
23704
+ }
23705
+ const seenPatchKeys = new Set;
23706
+ for (const pf of parsedPatches) {
23707
+ const key = `${normalizePathForJoin2(pf.path)}|${pf.previousPath ? normalizePathForJoin2(pf.previousPath) : ""}`;
23708
+ if (seenPatchKeys.has(key))
23709
+ throw new Error(`ambiguous patch join: duplicate path ${key}`);
23710
+ seenPatchKeys.add(key);
23711
+ }
23712
+ if (parsedPatches.length === 0) {
23713
+ if (rawEntries.length !== 0)
23714
+ throw new Error(`missing patch for raw entries: ${rawEntries.map((r) => r.path).join(",")}`);
23715
+ if (numstatEntries.length !== 0)
23716
+ throw new Error(`missing patch for numstat entries: ${numstatEntries.map((r) => r.path).join(",")}`);
23717
+ return [];
23718
+ }
23719
+ const files = [];
23720
+ const matchedRawKeys = new Set;
23721
+ const matchedNumstatKeys = new Set;
23722
+ for (const pf of parsedPatches) {
23723
+ const key = `${normalizePathForJoin2(pf.path)}|${pf.previousPath ? normalizePathForJoin2(pf.previousPath) : ""}`;
23724
+ const rawList = rawByKey.get(key);
23725
+ if (!rawList || rawList.length === 0) {
23726
+ throw new Error(`missing raw entry for patch file ${pf.path}${pf.previousPath ? ` (from ${pf.previousPath})` : ""}`);
23727
+ }
23728
+ if (rawList.length > 1)
23729
+ throw new Error(`ambiguous raw join for ${pf.path}`);
23730
+ const raw = rawList[0];
23731
+ matchedRawKeys.add(key);
23732
+ const numstatList = numstatByKey.get(key);
23733
+ let numstat = numstatList?.[0];
23734
+ if (!numstat) {
23735
+ throw new Error(`missing numstat entry for patch file ${pf.path}`);
23736
+ }
23737
+ if (numstatList && numstatList.length > 1)
23738
+ throw new Error(`ambiguous numstat join for ${pf.path}`);
23739
+ matchedNumstatKeys.add(key);
23740
+ const patchPrevNorm = pf.previousPath ? normalizePathForJoin2(pf.previousPath) : undefined;
23741
+ const numstatPrevNorm = numstat.previousPath ? normalizePathForJoin2(numstat.previousPath) : undefined;
23742
+ if (patchPrevNorm !== numstatPrevNorm) {
23743
+ if (patchPrevNorm !== undefined || numstatPrevNorm !== undefined) {
23744
+ throw new Error(`mismatched previousPath for ${pf.path}: patch ${patchPrevNorm} vs numstat ${numstatPrevNorm}`);
23745
+ }
23746
+ }
23747
+ const kind = kindFromRaw2(raw.status, pf.isBinary);
23748
+ const source = sourceFromKind2(kind, pf.isBinary);
23749
+ const normalizedHunkBody = pf.normalizedHunkBody;
23750
+ const contentId = sha256Tuple2([raw.oldBlobOid ?? "", raw.newBlobOid ?? "", raw.oldMode ?? "", raw.newMode ?? "", normalizedHunkBody]);
23751
+ const patchDigest = pf.patchDigest;
23752
+ const stats = { additions: numstat.additions, deletions: numstat.deletions };
23753
+ const hunks = pf.isBinary ? [] : pf.hunks;
23754
+ const file = pf.previousPath ? {
23755
+ key: pf.path,
23756
+ path: pf.path,
23757
+ previousPath: pf.previousPath,
23758
+ kind,
23759
+ oldBlobOid: raw.oldBlobOid,
23760
+ newBlobOid: raw.newBlobOid,
23761
+ oldMode: raw.oldMode,
23762
+ newMode: raw.newMode,
23763
+ contentId,
23764
+ patchDigest,
23765
+ stats,
23766
+ hunks,
23767
+ source
23768
+ } : {
23769
+ key: pf.path,
23770
+ path: pf.path,
23771
+ kind,
23772
+ oldBlobOid: raw.oldBlobOid,
23773
+ newBlobOid: raw.newBlobOid,
23774
+ oldMode: raw.oldMode,
23775
+ newMode: raw.newMode,
23776
+ contentId,
23777
+ patchDigest,
23778
+ stats,
23779
+ hunks,
23780
+ source
23781
+ };
23782
+ files.push(file);
23783
+ }
23784
+ for (const [key, list] of rawByKey) {
23785
+ if (!matchedRawKeys.has(key))
23786
+ throw new Error(`missing patch for raw entries: ${key} (${list.length})`);
23787
+ }
23788
+ for (const [key] of numstatByKey) {
23789
+ if (!matchedNumstatKeys.has(key))
23790
+ throw new Error(`missing patch for numstat entries: ${key}`);
23791
+ }
23792
+ return files;
23793
+ }
23794
+ async function isAncestor(runner, ancestorOid, descendantOid) {
23795
+ if (!ancestorOid || !descendantOid)
23796
+ return false;
23797
+ if (ancestorOid === descendantOid)
23798
+ return true;
23799
+ try {
23800
+ const result = await runner.run(["merge-base", "--is-ancestor", ancestorOid, descendantOid], {
23801
+ readOnly: true,
23802
+ acceptedExitCodes: [0, 1]
23803
+ });
23804
+ return result.exitCode === 0;
23805
+ } catch {
23806
+ return false;
23807
+ }
23808
+ }
23809
+ async function loadSinceLastReviewProjection(runner, aggregateDocument, lastHeadOid) {
23810
+ if (!lastHeadOid || lastHeadOid.trim() === "") {
23811
+ throw new Error("lastHeadOid must be non-empty");
23812
+ }
23813
+ const headOid = aggregateDocument.generation.headOid;
23814
+ const ancestor = await isAncestor(runner, lastHeadOid, headOid);
23815
+ if (!ancestor) {
23816
+ return {
23817
+ kind: "history-rewritten",
23818
+ lastHeadOid,
23819
+ headOid,
23820
+ reason: "history rewritten: last submission head is not an ancestor of current HEAD"
23821
+ };
23822
+ }
23823
+ const range = `${lastHeadOid}..${headOid}`;
23824
+ const files = await buildFilesForRange(runner, [range]);
23825
+ const projection = { kind: "since-last-review", fromHeadOid: lastHeadOid };
23826
+ const doc = {
23827
+ reviewId: aggregateDocument.identity.id,
23828
+ generationId: aggregateDocument.generation.id,
23829
+ projection,
23830
+ files
23831
+ };
23832
+ return { kind: "ok", document: doc };
23833
+ }
23834
+
23108
23835
  // src/review/git/load-source-context.ts
23109
23836
  var DEFAULT_MAX_BYTES = 1e6;
23110
23837
  function isZeroOid(oid) {
@@ -23691,6 +24418,7 @@ class ReviewWorkspaceController {
23691
24418
  stateStore;
23692
24419
  artifactStore;
23693
24420
  loadDocumentImpl;
24421
+ loadSinceLastReviewImpl;
23694
24422
  loadSourceContextImpl;
23695
24423
  nowImpl;
23696
24424
  randomIdImpl;
@@ -23703,14 +24431,18 @@ class ReviewWorkspaceController {
23703
24431
  activeReviewId;
23704
24432
  activeGenerationId;
23705
24433
  baseRef;
24434
+ _baseSelection;
24435
+ baseSelectionRequestId = 0;
23706
24436
  sourceContextCache = new Map;
23707
24437
  pendingGapRequests = new Map;
23708
24438
  gapRequestCounter = 0;
24439
+ aggregateDocument;
23709
24440
  constructor(options) {
23710
24441
  this.runner = options.runner;
23711
24442
  this.stateStore = options.stateStore;
23712
24443
  this.artifactStore = options.artifactStore;
23713
24444
  this.loadDocumentImpl = options.loadDocument ?? ((baseRef) => loadReviewDocument(options.runner, baseRef));
24445
+ this.loadSinceLastReviewImpl = options.loadSinceLastReview ?? ((aggregate, fromHeadOid) => loadSinceLastReviewProjection(options.runner, aggregate, fromHeadOid));
23714
24446
  this.loadSourceContextImpl = options.loadSourceContextImpl;
23715
24447
  this.nowImpl = options.now ?? (() => new Date().toISOString());
23716
24448
  this.randomIdImpl = options.randomId ?? (() => {
@@ -23736,6 +24468,78 @@ class ReviewWorkspaceController {
23736
24468
  get base() {
23737
24469
  return this.baseRef;
23738
24470
  }
24471
+ get baseSelection() {
24472
+ return this._baseSelection;
24473
+ }
24474
+ async requestBaseSelection() {
24475
+ if (this.destroyed || this._baseSelection?.selecting)
24476
+ return;
24477
+ const token = ++this.baseSelectionRequestId;
24478
+ this.requestId++;
24479
+ this._baseSelection = { candidates: [], loading: true, selecting: false };
24480
+ this.publish();
24481
+ try {
24482
+ const remembered = this.baseRef === undefined ? await this.rememberedBase() : undefined;
24483
+ const inferred = await inferReviewBase(this.runner, this.baseRef ?? remembered?.baseRef);
24484
+ if (this.destroyed || token !== this.baseSelectionRequestId)
24485
+ return;
24486
+ this._baseSelection = { candidates: inferred.candidates, loading: false, selecting: false };
24487
+ } catch (err) {
24488
+ if (this.destroyed || token !== this.baseSelectionRequestId)
24489
+ return;
24490
+ this._baseSelection = {
24491
+ candidates: [],
24492
+ loading: false,
24493
+ selecting: false,
24494
+ error: err instanceof Error ? err.message : String(err)
24495
+ };
24496
+ }
24497
+ this.publish();
24498
+ }
24499
+ cancelBaseSelection() {
24500
+ if (this._baseSelection?.selecting)
24501
+ return;
24502
+ this.baseSelectionRequestId++;
24503
+ this._baseSelection = undefined;
24504
+ this.publish();
24505
+ }
24506
+ async chooseBase(ref) {
24507
+ const picker = this._baseSelection;
24508
+ if (this.destroyed || !picker || picker.loading || picker.selecting || !picker.candidates.some((candidate) => candidate.ref === ref))
24509
+ return false;
24510
+ const token = ++this.baseSelectionRequestId;
24511
+ this.requestId++;
24512
+ this._baseSelection = { candidates: picker.candidates, loading: false, selecting: true };
24513
+ this.publish();
24514
+ return this.reviewOperationQueue.run(async () => {
24515
+ if (this.destroyed || token !== this.baseSelectionRequestId)
24516
+ return false;
24517
+ try {
24518
+ if (this._state !== undefined)
24519
+ await this.persistState();
24520
+ await this.stateStore?.flush();
24521
+ if (this.destroyed || token !== this.baseSelectionRequestId)
24522
+ return false;
24523
+ await this.open(ref);
24524
+ if (this.destroyed || token !== this.baseSelectionRequestId)
24525
+ return false;
24526
+ this._baseSelection = undefined;
24527
+ this.publish();
24528
+ return true;
24529
+ } catch (err) {
24530
+ if (this.destroyed || token !== this.baseSelectionRequestId)
24531
+ return false;
24532
+ this._baseSelection = {
24533
+ candidates: picker.candidates,
24534
+ loading: false,
24535
+ selecting: false,
24536
+ error: err instanceof Error ? err.message : String(err)
24537
+ };
24538
+ this.publish();
24539
+ return false;
24540
+ }
24541
+ });
24542
+ }
23739
24543
  clearError() {
23740
24544
  if (this._error === undefined)
23741
24545
  return;
@@ -23744,7 +24548,7 @@ class ReviewWorkspaceController {
23744
24548
  }
23745
24549
  get refreshGeneration() {
23746
24550
  return async () => {
23747
- if (this.baseRef === undefined || this.destroyed)
24551
+ if (this.baseRef === undefined || this.destroyed || this._baseSelection !== undefined)
23748
24552
  return;
23749
24553
  const token = ++this.requestId;
23750
24554
  const capturedGeneration = this.activeGenerationId;
@@ -23788,10 +24592,12 @@ class ReviewWorkspaceController {
23788
24592
  this.sourceContextCache.clear();
23789
24593
  this.pendingGapRequests.clear();
23790
24594
  const currentState = this._state;
23791
- const nextState = currentState === undefined ? createInitialReviewState(doc) : reconcileReviewState(currentState, doc);
24595
+ const reconcileFrom = currentState !== undefined && currentState.projection.kind !== "aggregate" ? { ...currentState, projection: { kind: "aggregate" } } : currentState;
24596
+ const nextState = reconcileFrom === undefined ? createInitialReviewState(doc) : reconcileReviewState(reconcileFrom, doc);
23792
24597
  if (!ownsRequest())
23793
24598
  return;
23794
24599
  this._state = nextState;
24600
+ this.aggregateDocument = undefined;
23795
24601
  this.activeReviewId = doc.identity.id;
23796
24602
  this.activeGenerationId = doc.generation.id;
23797
24603
  this._error = undefined;
@@ -23812,10 +24618,21 @@ class ReviewWorkspaceController {
23812
24618
  throw new Error("controller destroyed");
23813
24619
  const token = ++this.requestId;
23814
24620
  let resolvedBase = baseRef;
23815
- let corruptError;
24621
+ if (resolvedBase !== undefined && this._baseSelection !== undefined && !this._baseSelection.selecting) {
24622
+ this.baseSelectionRequestId++;
24623
+ this._baseSelection = undefined;
24624
+ this.publish();
24625
+ }
24626
+ let corruptError = this._error?.kind === "corrupt-state" || this._error?.kind === "storage" ? this._error : undefined;
23816
24627
  if (resolvedBase === undefined) {
23817
24628
  try {
23818
- resolvedBase = await this.resolveBase();
24629
+ const remembered = await this.rememberedBase();
24630
+ if (remembered?.confirmed === true && await resolveRefOid(this.runner, remembered.baseRef) !== undefined) {
24631
+ resolvedBase = remembered.baseRef;
24632
+ } else {
24633
+ await this.requestBaseSelection();
24634
+ return;
24635
+ }
23819
24636
  const warning = this.stateStore?.quarantineWarning;
23820
24637
  if (warning) {
23821
24638
  const pathMatch = warning.match(/moved to (\S+)/);
@@ -23831,6 +24648,8 @@ class ReviewWorkspaceController {
23831
24648
  }
23832
24649
  if (this.destroyed || token !== this.requestId)
23833
24650
  throw new Error("open cancelled");
24651
+ if (resolvedBase === undefined)
24652
+ throw new Error("base selection required");
23834
24653
  let doc;
23835
24654
  try {
23836
24655
  doc = await this.loadDocumentImpl(resolvedBase);
@@ -23855,7 +24674,20 @@ class ReviewWorkspaceController {
23855
24674
  const qPath = pathMatch?.[1] ?? warning;
23856
24675
  corruptError = createCorruptStateError(qPath, warning);
23857
24676
  }
23858
- const persisted = db.reviews[doc.identity.id];
24677
+ let persisted = db.reviews[doc.identity.id];
24678
+ if (persisted === undefined) {
24679
+ const headKey = doc.identity.headRef ?? `detached:${doc.identity.detachedHeadOid}`;
24680
+ const previousBase = db.baseByHead[headKey]?.baseRef;
24681
+ if (previousBase && previousBase !== resolvedBase && !previousBase.startsWith("refs/")) {
24682
+ const previousId = sha256Tuple2(["branch-review-v2", headKey, previousBase]);
24683
+ const previous = db.reviews[previousId];
24684
+ if (previous !== undefined) {
24685
+ const canonical = await this.runner.run(["rev-parse", "--symbolic-full-name", "--verify", "--end-of-options", previousBase], { readOnly: true, acceptedExitCodes: [0, 1, 128] });
24686
+ if (canonical.stdout.trim() === resolvedBase)
24687
+ persisted = previous;
24688
+ }
24689
+ }
24690
+ }
23859
24691
  if (persisted !== undefined) {
23860
24692
  const initial = createInitialReviewState(doc);
23861
24693
  const reconstructed = {
@@ -23900,7 +24732,7 @@ class ReviewWorkspaceController {
23900
24732
  }
23901
24733
  dispatch(action) {
23902
24734
  const current = this._state;
23903
- if (current === undefined)
24735
+ if (current === undefined || this._baseSelection !== undefined)
23904
24736
  return;
23905
24737
  const next = reduceReviewState(current, action);
23906
24738
  if (next === current)
@@ -23918,7 +24750,7 @@ class ReviewWorkspaceController {
23918
24750
  }
23919
24751
  dispatchIntent(intent) {
23920
24752
  const current = this._state;
23921
- if (current === undefined)
24753
+ if (current === undefined || this._baseSelection !== undefined)
23922
24754
  return false;
23923
24755
  try {
23924
24756
  const action = planReviewIntent(current, intent);
@@ -23928,6 +24760,50 @@ class ReviewWorkspaceController {
23928
24760
  return false;
23929
24761
  }
23930
24762
  }
24763
+ async enterSinceLastReview() {
24764
+ const current = this._state;
24765
+ if (current === undefined || this._baseSelection !== undefined)
24766
+ return { ok: false, reason: "unavailable" };
24767
+ if (current.projection.kind !== "aggregate")
24768
+ return { ok: false, reason: "already-projected" };
24769
+ const fromHeadOid = current.lastSubmission?.headOid;
24770
+ if (fromHeadOid === undefined || fromHeadOid.trim() === "")
24771
+ return { ok: false, reason: "no-previous-review" };
24772
+ const aggregate = current.document;
24773
+ let result;
24774
+ try {
24775
+ result = await this.loadSinceLastReviewImpl(aggregate, fromHeadOid);
24776
+ } catch (err) {
24777
+ return { ok: false, reason: "load-failed", message: err instanceof Error ? err.message : String(err) };
24778
+ }
24779
+ if (result.kind === "history-rewritten")
24780
+ return { ok: false, reason: "history-rewritten", message: result.reason };
24781
+ const latest = this._state;
24782
+ if (latest === undefined || latest.document !== aggregate || latest.projection.kind !== "aggregate") {
24783
+ return { ok: false, reason: "stale" };
24784
+ }
24785
+ this.aggregateDocument = aggregate;
24786
+ this.dispatch({
24787
+ type: "projection/apply",
24788
+ projection: result.document.projection,
24789
+ document: createReviewDocument({
24790
+ identity: aggregate.identity,
24791
+ generation: aggregate.generation,
24792
+ commits: aggregate.commits,
24793
+ files: result.document.files
24794
+ })
24795
+ });
24796
+ return { ok: true, fileCount: result.document.files.length };
24797
+ }
24798
+ exitProjection() {
24799
+ const current = this._state;
24800
+ const aggregate = this.aggregateDocument;
24801
+ if (current === undefined || aggregate === undefined || current.projection.kind === "aggregate")
24802
+ return false;
24803
+ this.aggregateDocument = undefined;
24804
+ this.dispatch({ type: "projection/apply", projection: { kind: "aggregate" }, document: aggregate });
24805
+ return true;
24806
+ }
23931
24807
  async flushDrafts() {
23932
24808
  if (!this.stateStore)
23933
24809
  return;
@@ -24176,6 +25052,8 @@ class ReviewWorkspaceController {
24176
25052
  this.destroyed = true;
24177
25053
  this.listeners.clear();
24178
25054
  this.requestId++;
25055
+ this.baseSelectionRequestId++;
25056
+ this._baseSelection = undefined;
24179
25057
  this.pendingGapRequests.clear();
24180
25058
  this.sourceContextCache.clear();
24181
25059
  if (this.activeReviewId && this.stateStore) {
@@ -24208,7 +25086,7 @@ class ReviewWorkspaceController {
24208
25086
  const submissionInProgress = db.reviews[reviewId]?.submissionInProgress ?? null;
24209
25087
  return {
24210
25088
  ...db,
24211
- baseByHead: { ...db.baseByHead, [headKey]: { baseRef: snapshot.document.identity.baseRef } },
25089
+ baseByHead: { ...db.baseByHead, [headKey]: { baseRef: snapshot.document.identity.baseRef, confirmed: true } },
24212
25090
  reviews: { ...db.reviews, [reviewId]: { ...persisted, submissionInProgress } }
24213
25091
  };
24214
25092
  });
@@ -24221,37 +25099,26 @@ class ReviewWorkspaceController {
24221
25099
  throw err;
24222
25100
  }
24223
25101
  }
24224
- async resolveBase() {
25102
+ async rememberedBase() {
24225
25103
  const headRef = await currentBranchRef(this.runner);
24226
25104
  const detachedOid = headRef === undefined ? await resolveRefOid(this.runner, "HEAD") : undefined;
24227
25105
  const headKey = headRef ?? (detachedOid ? `detached:${detachedOid}` : undefined);
24228
- if (headKey !== undefined && this.stateStore) {
24229
- try {
24230
- const db = await this.stateStore.load();
24231
- const warning = this.stateStore.quarantineWarning;
24232
- if (warning) {
24233
- const pathMatch = warning.match(/moved to (\S+)/);
24234
- const qPath = pathMatch?.[1] ?? warning;
24235
- this._error = createCorruptStateError(qPath, warning);
24236
- this.publish();
24237
- }
24238
- const remembered = db.baseByHead[headKey]?.baseRef;
24239
- if (remembered && await resolveRefOid(this.runner, remembered) !== undefined) {
24240
- return remembered;
24241
- }
24242
- } catch {}
24243
- }
25106
+ if (headKey === undefined || this.stateStore === undefined)
25107
+ return;
24244
25108
  try {
24245
- const inferred = await inferReviewBase(this.runner);
24246
- if (inferred.kind === "confident")
24247
- return inferred.ref;
24248
- const candidates2 = inferred.kind === "choose" ? inferred.candidates : await reviewBaseCandidates(this.runner);
24249
- if (candidates2.length > 0)
24250
- return candidates2[0];
25109
+ const db = await this.stateStore.load();
25110
+ const warning = this.stateStore.quarantineWarning;
25111
+ if (warning) {
25112
+ const qPath = warning.match(/moved to (\S+)/)?.[1] ?? warning;
25113
+ this._error = createCorruptStateError(qPath, warning);
25114
+ this.publish();
25115
+ }
25116
+ return db.baseByHead[headKey];
24251
25117
  } catch (err) {
24252
- throw classifyLoadError(err);
25118
+ this._error = createStorageError(err instanceof Error ? err.message : String(err));
25119
+ this.publish();
25120
+ return;
24253
25121
  }
24254
- return "refs/heads/main";
24255
25122
  }
24256
25123
  }
24257
25124