@xuhaojun/githunk 0.2.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 (3) hide show
  1. package/README.md +23 -2
  2. package/dist/githunk.js +1425 -400
  3. package/package.json +6 -6
package/dist/githunk.js CHANGED
@@ -1,11 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/cli.ts
4
+ import { spawnSync } from "node:child_process";
5
+ import { chmodSync, cpSync, mkdtempSync, renameSync, rmSync } from "node:fs";
6
+ import { writeFile } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join as join6 } from "node:path";
9
+
3
10
  // src/cli/args.ts
4
11
  import { Command, CommanderError } from "commander";
5
12
  // package.json
6
13
  var package_default = {
7
14
  name: "@xuhaojun/githunk",
8
- version: "0.2.0",
15
+ version: "0.3.1",
9
16
  description: "A review-first Git TUI combining lazygit's everyday Git workflow with focused hunk review.",
10
17
  type: "module",
11
18
  bin: {
@@ -80,8 +87,9 @@ var cliVersion = typeof package_default.version === "string" && package_default.
80
87
  function parseCliArgs(argv) {
81
88
  let stdout = "";
82
89
  let stderr = "";
90
+ let update;
83
91
  const program = new Command;
84
- program.name("githunk").description("A review-first Git TUI combining lazygit's everyday Git workflow with focused hunk review.").version(cliVersion, "-V, --version", "output the version number").option("-p, --path <dir>", "path to the Git repository to open").argument("[path]", "path to the Git repository to open").exitOverride().configureOutput({
92
+ program.name("githunk").description("A review-first Git TUI combining lazygit's everyday Git workflow with focused hunk review.").version(cliVersion, "-V, --version", "output the version number").option("-p, --path <dir>", "path to the Git repository to open").argument("[path]", "path to the Git repository to open").exitOverride().action(() => {}).configureOutput({
85
93
  writeOut: (text) => {
86
94
  stdout += text;
87
95
  },
@@ -89,6 +97,12 @@ function parseCliArgs(argv) {
89
97
  stderr += text;
90
98
  }
91
99
  });
100
+ program.command("update").description("update githunk to the newest (or a given) release").argument("[version]", "version to install; the newest release when omitted").option("--check", "report the installed and available versions without installing").action((version, options2) => {
101
+ update = {
102
+ ...version === undefined ? {} : { version },
103
+ check: options2.check ?? false
104
+ };
105
+ });
92
106
  try {
93
107
  program.parse([...argv], { from: "user" });
94
108
  } catch (error) {
@@ -102,12 +116,91 @@ function parseCliArgs(argv) {
102
116
  }
103
117
  throw error;
104
118
  }
119
+ if (update !== undefined)
120
+ return { kind: "update", ...update };
105
121
  const options = program.opts();
106
122
  const positional = program.args[0];
107
123
  const startDirectory = options.path ?? positional;
108
124
  return startDirectory === undefined ? { kind: "start" } : { kind: "start", startDirectory };
109
125
  }
110
126
 
127
+ // src/cli/update.ts
128
+ import { createHash } from "node:crypto";
129
+ import { basename, join } from "node:path";
130
+ function assetNameFor(platform, arch) {
131
+ const osToken = platform === "linux" ? "linux" : platform === "darwin" ? "darwin" : platform === "win32" ? "windows" : null;
132
+ const archToken = arch === "x64" ? "x64" : arch === "arm64" ? "arm64" : null;
133
+ if (osToken === null || archToken === null)
134
+ return null;
135
+ if (osToken === "windows" && archToken !== "x64")
136
+ return null;
137
+ return `githunk-${osToken}-${archToken}.tar.gz`;
138
+ }
139
+ function normalizeVersion(version) {
140
+ return version.trim().replace(/^v/, "");
141
+ }
142
+ function compareVersions(left, right) {
143
+ const parts = (version) => {
144
+ const [major = "0", minor = "0", patch = "0"] = normalizeVersion(version).split(".");
145
+ return [Number(major) || 0, Number(minor) || 0, Number(patch) || 0];
146
+ };
147
+ const [aMajor, aMinor, aPatch] = parts(left);
148
+ const [bMajor, bMinor, bPatch] = parts(right);
149
+ return aMajor - bMajor || aMinor - bMinor || aPatch - bPatch;
150
+ }
151
+ function isSelfManagedBinary(executablePath) {
152
+ const base = basename(executablePath);
153
+ return base === "githunk" || base === "githunk.exe";
154
+ }
155
+ function verifyChecksum(tarball, checksums, asset) {
156
+ const actual = createHash("sha256").update(tarball).digest("hex");
157
+ const line = checksums.split(`
158
+ `).map((entry) => entry.trim().split(/\s+/)).find((fields) => fields[fields.length - 1] === asset);
159
+ const expected = line?.[0];
160
+ if (expected === undefined || expected === "" || expected.toLowerCase() !== actual) {
161
+ throw new Error(`checksum mismatch for ${asset}`);
162
+ }
163
+ }
164
+ async function applyUpdate(target, asset, env) {
165
+ await env.withTempDir(async (dir) => {
166
+ const { tarball, checksums } = await env.fetchAsset(`v${target}`, asset);
167
+ verifyChecksum(tarball, checksums, asset);
168
+ const archivePath = join(dir, asset);
169
+ await env.writeFile(archivePath, tarball);
170
+ await env.extractTarball(archivePath, dir);
171
+ await env.writeBinary(env.stagedBinary(dir), env.executablePath);
172
+ });
173
+ }
174
+ async function runUpdate(request, env) {
175
+ try {
176
+ if (!isSelfManagedBinary(env.executablePath)) {
177
+ return {
178
+ exitCode: 1,
179
+ message: "githunk was installed via npm — update it with `npm update --global @xuhaojun/githunk`"
180
+ };
181
+ }
182
+ const asset = assetNameFor(env.platform, env.arch);
183
+ if (asset === null) {
184
+ return {
185
+ exitCode: 1,
186
+ message: `no prebuilt githunk binary ships for ${env.platform}-${env.arch} — install with \`npm install -g @xuhaojun/githunk\``
187
+ };
188
+ }
189
+ const current = normalizeVersion(env.installedVersion());
190
+ const target = normalizeVersion(request.version ?? normalizeVersion(await env.fetchReleaseTag()));
191
+ if (compareVersions(target, current) === 0) {
192
+ return { exitCode: 0, message: `githunk ${current} is already up to date` };
193
+ }
194
+ if (request.check) {
195
+ return { exitCode: 0, message: `update available: ${current} -> ${target}` };
196
+ }
197
+ await applyUpdate(target, asset, env);
198
+ return { exitCode: 0, message: `updated githunk ${current} -> ${target}` };
199
+ } catch (error) {
200
+ return { exitCode: 1, message: error instanceof Error ? error.message : String(error) };
201
+ }
202
+ }
203
+
111
204
  // src/main.ts
112
205
  import { resolve as resolve4 } from "node:path";
113
206
  import { createCliRenderer } from "@opentui/core";
@@ -991,12 +1084,12 @@ function reviewStateFor(record, currentFingerprint) {
991
1084
  }
992
1085
 
993
1086
  // src/review/working-tree-fingerprint.ts
994
- import { createHash } from "node:crypto";
1087
+ import { createHash as createHash2 } from "node:crypto";
995
1088
  function utf8(value) {
996
1089
  return new TextEncoder().encode(value);
997
1090
  }
998
1091
  function sha256Tuple(parts) {
999
- const hash = createHash("sha256");
1092
+ const hash = createHash2("sha256");
1000
1093
  for (const part of parts) {
1001
1094
  const bytes = utf8(part);
1002
1095
  const length = Buffer.allocUnsafe(4);
@@ -1029,7 +1122,7 @@ function fingerprintWorkingTreeFile(target, filePatch) {
1029
1122
 
1030
1123
  // src/storage/local-state-file.ts
1031
1124
  import { mkdir, open, rename, stat, unlink, lstat, link, readFile } from "node:fs/promises";
1032
- import { dirname, isAbsolute, join, resolve } from "node:path";
1125
+ import { dirname, isAbsolute, join as join2, resolve } from "node:path";
1033
1126
  import { randomUUID } from "node:crypto";
1034
1127
  async function assertNoSymlinkInPath(path, pathKind) {
1035
1128
  const absolute = resolve(path);
@@ -1059,7 +1152,7 @@ class LocalStateFile {
1059
1152
  this.pathKind = options.pathKind ?? "state";
1060
1153
  }
1061
1154
  get path() {
1062
- return this.resolvedPath ?? join(this.runner.cwd, ".git", this.relativePath);
1155
+ return this.resolvedPath ?? join2(this.runner.cwd, ".git", this.relativePath);
1063
1156
  }
1064
1157
  async resolvePath() {
1065
1158
  if (this.resolvedPath !== undefined)
@@ -1067,7 +1160,7 @@ class LocalStateFile {
1067
1160
  const output = (await this.runner.run(["rev-parse", "--git-path", this.relativePath], { readOnly: true })).stdout.trim();
1068
1161
  if (output.length === 0)
1069
1162
  throw new Error(`git returned an empty path for ${this.relativePath}`);
1070
- this.resolvedPath = isAbsolute(output) ? output : join(this.runner.cwd, output);
1163
+ this.resolvedPath = isAbsolute(output) ? output : join2(this.runner.cwd, output);
1071
1164
  return this.resolvedPath;
1072
1165
  }
1073
1166
  async readText() {
@@ -1976,7 +2069,7 @@ function validateUpstream(upstream) {
1976
2069
  throw new Error("invalid upstream choice");
1977
2070
  }
1978
2071
  }
1979
- async function fetch(runner, remote, options = {}) {
2072
+ async function fetch2(runner, remote, options = {}) {
1980
2073
  await runner.run(remote === undefined ? ["fetch"] : ["fetch", remote], options.background === true ? { dontLog: true } : { streamOutput: true });
1981
2074
  }
1982
2075
  async function pull(runner, options = {}) {
@@ -2185,7 +2278,7 @@ async function listReflog(runner, options = {}) {
2185
2278
 
2186
2279
  // src/git/worktrees.ts
2187
2280
  import { readFile as readFile2, stat as stat2 } from "node:fs/promises";
2188
- import { dirname as dirname2, join as join2 } from "node:path";
2281
+ import { dirname as dirname2, join as join3 } from "node:path";
2189
2282
  function finalizeEntry(entry) {
2190
2283
  return {
2191
2284
  path: entry.path,
@@ -2320,11 +2413,11 @@ async function readTrimmedFile(path) {
2320
2413
  }
2321
2414
  async function inProgressBranch(gitDir) {
2322
2415
  for (const directory of ["rebase-merge", "rebase-apply"]) {
2323
- const headName = await readTrimmedFile(join2(gitDir, directory, "head-name"));
2416
+ const headName = await readTrimmedFile(join3(gitDir, directory, "head-name"));
2324
2417
  if (headName !== undefined)
2325
2418
  return headName.replace(/^refs\/heads\//, "");
2326
2419
  }
2327
- return await readTrimmedFile(join2(gitDir, "BISECT_START"));
2420
+ return await readTrimmedFile(join3(gitDir, "BISECT_START"));
2328
2421
  }
2329
2422
  async function listWorktrees(runner) {
2330
2423
  const repositoryPaths = await resolveRepositoryPaths(runner);
@@ -2391,7 +2484,7 @@ function worktreeRemovalRequiresForce(error) {
2391
2484
 
2392
2485
  // src/git/submodules.ts
2393
2486
  import { readFile as readFile3 } from "node:fs/promises";
2394
- import { join as join3, resolve as resolve2 } from "node:path";
2487
+ import { join as join4, resolve as resolve2 } from "node:path";
2395
2488
 
2396
2489
  // src/domain/submodule.ts
2397
2490
  function submoduleFullName(submodule) {
@@ -2446,7 +2539,7 @@ function parseGitModules(raw) {
2446
2539
  }
2447
2540
  async function readGitModules(directory) {
2448
2541
  try {
2449
- return await readFile3(join3(directory, ".gitmodules"), "utf8");
2542
+ return await readFile3(join4(directory, ".gitmodules"), "utf8");
2450
2543
  } catch (error) {
2451
2544
  if (error instanceof Error && "code" in error) {
2452
2545
  const code = error.code;
@@ -2457,7 +2550,7 @@ async function readGitModules(directory) {
2457
2550
  }
2458
2551
  }
2459
2552
  async function collectSubmodules(worktreePath, parentModule, visited) {
2460
- const directory = parentModule === undefined ? worktreePath : join3(worktreePath, submoduleFullPath(parentModule));
2553
+ const directory = parentModule === undefined ? worktreePath : join4(worktreePath, submoduleFullPath(parentModule));
2461
2554
  const resolved = resolve2(directory);
2462
2555
  if (visited.has(resolved))
2463
2556
  return [];
@@ -2941,7 +3034,7 @@ class AppController {
2941
3034
  return;
2942
3035
  if (options.background !== true)
2943
3036
  this.logAction(LOG_ACTIONS.fetch);
2944
- await this.runMutation(() => this.requireRunnerOperation((runner) => fetch(runner, remote, options)));
3037
+ await this.runMutation(() => this.requireRunnerOperation((runner) => fetch2(runner, remote, options)));
2945
3038
  }
2946
3039
  async pull(options = {}) {
2947
3040
  if (!this.ensureWorkingTreeMutation())
@@ -5935,7 +6028,7 @@ function commitGraphRows(commits, getColor) {
5935
6028
  }
5936
6029
 
5937
6030
  // src/ui/author-style.ts
5938
- import { createHash as createHash2 } from "node:crypto";
6031
+ import { createHash as createHash3 } from "node:crypto";
5939
6032
  var initialsCache = new Map;
5940
6033
  var colorCache = new Map;
5941
6034
  function randInt(bytes, max) {
@@ -5976,7 +6069,7 @@ function authorColor(authorName) {
5976
6069
  const cached = colorCache.get(authorName);
5977
6070
  if (cached !== undefined)
5978
6071
  return cached;
5979
- const hash = new Uint8Array(createHash2("md5").update(authorName).digest());
6072
+ const hash = new Uint8Array(createHash3("md5").update(authorName).digest());
5980
6073
  const color = hslToHex(randFloat(hash.slice(0, 4)) * 360, 0.6 + 0.4 * randFloat(hash.slice(4, 8)), 0.4 + 0.2 * randFloat(hash.slice(8, 12)));
5981
6074
  colorCache.set(authorName, color);
5982
6075
  return color;
@@ -15469,7 +15562,7 @@ async function loadRefsSnapshot(runner) {
15469
15562
  }
15470
15563
 
15471
15564
  // src/git/editor.ts
15472
- import { join as join4, basename } from "node:path";
15565
+ import { join as join5, basename as basename2 } from "node:path";
15473
15566
  function standardTerminalPreset(editor) {
15474
15567
  return {
15475
15568
  edit: `${editor} -- {{filename}}`,
@@ -15562,13 +15655,13 @@ function resolvePlaceholders(template, values) {
15562
15655
  }
15563
15656
  function guessEditorBase(env, gitEditor) {
15564
15657
  if (env.GITHUNK_EDITOR !== undefined && env.GITHUNK_EDITOR.trim().length > 0)
15565
- return basename(env.GITHUNK_EDITOR.split(" ")[0].trim());
15658
+ return basename2(env.GITHUNK_EDITOR.split(" ")[0].trim());
15566
15659
  if (gitEditor !== undefined && gitEditor.trim().length > 0)
15567
- return basename(gitEditor.split(" ")[0].trim());
15660
+ return basename2(gitEditor.split(" ")[0].trim());
15568
15661
  for (const key of ["GIT_EDITOR", "VISUAL", "EDITOR"]) {
15569
15662
  const value = env[key];
15570
15663
  if (value !== undefined && value.trim().length > 0)
15571
- return basename(value.split(" ")[0].trim());
15664
+ return basename2(value.split(" ")[0].trim());
15572
15665
  }
15573
15666
  return "vi";
15574
15667
  }
@@ -15607,7 +15700,7 @@ async function resolveEditCommand(files, options = {}) {
15607
15700
  function absolutePath(repoRoot, relativePath) {
15608
15701
  if (relativePath.startsWith("/"))
15609
15702
  return relativePath;
15610
- return join4(repoRoot, relativePath);
15703
+ return join5(repoRoot, relativePath);
15611
15704
  }
15612
15705
 
15613
15706
  // src/app/create-app.ts
@@ -15615,7 +15708,7 @@ import { isAbsolute as isAbsolute2, resolve as resolve3 } from "node:path";
15615
15708
 
15616
15709
  // src/app/index-watcher.ts
15617
15710
  import { watch, statSync } from "node:fs";
15618
- import { basename as basename2, dirname as dirname3 } from "node:path";
15711
+ import { basename as basename3, dirname as dirname3 } from "node:path";
15619
15712
  var DEFAULT_INDEX_EVENT_DEBOUNCE_MS = 50;
15620
15713
  var BUSY_RETRY_MS = 50;
15621
15714
  function fingerprint(path) {
@@ -15645,7 +15738,7 @@ class IndexWatcher {
15645
15738
  stopped = true;
15646
15739
  constructor(options) {
15647
15740
  this.options = options;
15648
- this.indexName = basename2(options.indexPath);
15741
+ this.indexName = basename3(options.indexPath);
15649
15742
  this.lockName = `${this.indexName}.lock`;
15650
15743
  this.debounceMs = options.debounceMs ?? DEFAULT_INDEX_EVENT_DEBOUNCE_MS;
15651
15744
  this.baseline = undefined;
@@ -15868,9 +15961,8 @@ function isWorkerAvailable() {
15868
15961
  }
15869
15962
 
15870
15963
  // src/ui/review-workspace/ReviewWorkspaceApp.tsx
15871
- import { StyledText as StyledText8, parseColor as parseColor3 } from "@opentui/core";
15872
- import { useKeyboard, useTerminalDimensions } from "@opentui/react";
15873
- 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";
15874
15966
 
15875
15967
  // src/review/core/selectors.ts
15876
15968
  function resolveViewedRecord(file, viewed) {
@@ -16031,16 +16123,23 @@ function reviewHeaderLines(state, width) {
16031
16123
  totalDeletions = null;
16032
16124
  const additionsText = totalAdditions === null ? "—" : `+${totalAdditions}`;
16033
16125
  const deletionsText = totalDeletions === null ? "—" : `−${totalDeletions}`;
16034
- const projectionLabel = "Aggregate";
16035
- const line1Raw = `${headLabel} → ${baseLabel} · ${commits} commits · ${files} files · ${additionsText} ${deletionsText} [${projectionLabel}]`;
16036
- 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)));
16037
16132
  const reviewedLabel = `Reviewed ${progress.viewed}/${progress.total}`;
16038
16133
  const changedPart = progress.changed > 0 ? ` · ${progress.changed} changed` : "";
16039
16134
  const pendingPart = progress.pending > 0 ? ` · ${progress.pending} pending` : progress.pending === 0 ? " · 0 pending" : "";
16040
16135
  const reviewingPart = progress.reviewing > 0 ? ` · ${progress.reviewing} reviewing` : "";
16041
16136
  const line2Raw = `${reviewedLabel}${changedPart}${reviewingPart}${pendingPart}`;
16042
16137
  const line2 = truncateCell(line2Raw, w);
16043
- 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
+ ];
16044
16143
  const line2Spans = [{ text: line2, style: "dim" }];
16045
16144
  return [line1Spans, line2Spans];
16046
16145
  }
@@ -16275,6 +16374,22 @@ var REVIEW_COMMANDS = [
16275
16374
  available: always,
16276
16375
  hint: "help"
16277
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
+ },
16278
16393
  {
16279
16394
  id: "review.close",
16280
16395
  title: "Close overlay or workspace",
@@ -16290,7 +16405,7 @@ for (const cmd of REVIEW_COMMANDS) {
16290
16405
  if (!keyToCommand.has(k))
16291
16406
  keyToCommand.set(k, cmd);
16292
16407
  const lower = k.toLowerCase();
16293
- if (lower !== k && !keyToCommand.has(lower))
16408
+ if (k.length > 1 && lower !== k && !keyToCommand.has(lower))
16294
16409
  keyToCommand.set(lower, cmd);
16295
16410
  }
16296
16411
  }
@@ -16313,7 +16428,8 @@ function reviewHelp(focus, state) {
16313
16428
  "review.focusDiff": 0,
16314
16429
  "review.focusFiles": 1,
16315
16430
  "review.toggleFocus": 2,
16316
- "review.layoutCycle": 3
16431
+ "review.layoutCycle": 3,
16432
+ "review.chooseBase": 4
16317
16433
  };
16318
16434
  const ordered = [...available].sort((a, b) => (panelPriority[a.id] ?? 99) - (panelPriority[b.id] ?? 99));
16319
16435
  const lines = ordered.map((c) => `${c.keys.map((key) => key === "tab" ? "Tab" : key).join("/")} ${c.title}`);
@@ -16322,7 +16438,7 @@ function reviewHelp(focus, state) {
16322
16438
  }
16323
16439
 
16324
16440
  // src/ui/review-workspace/review-sidebar.ts
16325
- import { basename as basename3, dirname as dirname4 } from "node:path/posix";
16441
+ import { basename as basename4, dirname as dirname4 } from "node:path/posix";
16326
16442
  function normalizeDiffPath(p) {
16327
16443
  return p?.replace(/[\r\n]+$/u, "");
16328
16444
  }
@@ -16351,10 +16467,10 @@ function sidebarFileName(file) {
16351
16467
  const path = formatTerminalPath(normalizeDiffPath(file.path) ?? file.path);
16352
16468
  const previousPath = file.previousPath ? formatTerminalPath(normalizeDiffPath(file.previousPath) ?? file.previousPath) : undefined;
16353
16469
  if (previousPath === undefined || previousPath === path) {
16354
- return basename3(path);
16470
+ return basename4(path);
16355
16471
  }
16356
- const previousName = basename3(previousPath);
16357
- const nextName = basename3(path);
16472
+ const previousName = basename4(previousPath);
16473
+ const nextName = basename4(path);
16358
16474
  return previousName === nextName ? nextName : `${previousName} -> ${nextName}`;
16359
16475
  }
16360
16476
  function formatSidebarStat(prefix, value, truncated = false) {
@@ -16677,7 +16793,7 @@ function hunkHeaderText(file, index) {
16677
16793
  const hunk = file.metadata.hunks[index];
16678
16794
  if (!hunk)
16679
16795
  return "@@";
16680
- 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} @@`;
16681
16797
  }
16682
16798
  function hunkGapBefore(file, hunkIndex) {
16683
16799
  if (hunkIndex <= 0)
@@ -17284,8 +17400,114 @@ function ReviewDiffSection({
17284
17400
  });
17285
17401
  }
17286
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
+
17287
17509
  // src/ui/review-workspace/components/ReviewDiffPane.tsx
17288
- 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";
17289
17511
  function sectionWindow(files, state, layout, scrollTop, viewportHeight, overscan, expandedSourceByGap) {
17290
17512
  const heights = [];
17291
17513
  const offsets = [0];
@@ -17319,6 +17541,7 @@ function ReviewDiffPane({
17319
17541
  selectedHunkIndex,
17320
17542
  showLineNumbers = true,
17321
17543
  wrapLines = false,
17544
+ showStickyHeader = true,
17322
17545
  overscan = Math.max(10, height * 2),
17323
17546
  highlightByFileKey,
17324
17547
  expandedSourceByGap,
@@ -17337,12 +17560,14 @@ function ReviewDiffPane({
17337
17560
  const ownedScrollRef = useRef(null);
17338
17561
  const scrollRef = externalScrollRef ?? ownedScrollRef;
17339
17562
  const [scrollTop, setScrollTop] = useState(0);
17563
+ const stickyRows = showStickyHeader && files.length > 0 ? 1 : 0;
17564
+ const viewportHeight = Math.max(1, height - stickyRows);
17340
17565
  const previousFileRevealTokenRef = useRef(selectedFileRevealToken);
17341
17566
  const previousSelectionRef = useRef(null);
17342
17567
  const previousHunkRevealTokenRef = useRef(undefined);
17343
17568
  const pendingSelectionRevealRequestRef = useRef(null);
17344
17569
  const pendingSelectionRevealTimersRef = useRef([]);
17345
- 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]);
17346
17571
  useEffect(() => {
17347
17572
  if (!onVisibleFileKeysChange)
17348
17573
  return;
@@ -17381,13 +17606,13 @@ function ReviewDiffPane({
17381
17606
  if (state.reveal.scrollToFeedback)
17382
17607
  return;
17383
17608
  const sectionTop = window.offsets[index] ?? 0;
17384
- 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));
17385
17610
  const scrollBox = scrollRef.current;
17386
17611
  if (scrollBox)
17387
17612
  scrollBox.scrollTop = target;
17388
17613
  setScrollTop(target);
17389
17614
  onViewportChange?.(target);
17390
- }, [files, height, onViewportChange, scrollRef, selectedFileKey, selectedFileRevealToken, state.reveal.scrollToFeedback, window]);
17615
+ }, [files, onViewportChange, scrollRef, selectedFileKey, selectedFileRevealToken, state.reveal.scrollToFeedback, viewportHeight, window]);
17391
17616
  useLayoutEffect(() => {
17392
17617
  const currentSelection = { fileKey: selectedFileKey, hunkIndex: selectedHunkIndex };
17393
17618
  const previousSelection = previousSelectionRef.current;
@@ -17447,11 +17672,11 @@ function ReviewDiffPane({
17447
17672
  const scrollBox = scrollRef.current;
17448
17673
  if (!scrollBox)
17449
17674
  return;
17450
- const viewportHeight = Math.max(1, Math.floor(scrollBox.viewport.height || height));
17675
+ const measuredHeight = Math.max(1, Math.floor(scrollBox.viewport.height || viewportHeight));
17451
17676
  const currentTop = Math.max(0, Math.floor(scrollBox.scrollTop));
17452
- const currentEnd = currentTop + viewportHeight;
17677
+ const currentEnd = currentTop + measuredHeight;
17453
17678
  if (target < currentTop || target + 1 > currentEnd) {
17454
- 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));
17455
17680
  scrollBox.scrollTop = nextTop;
17456
17681
  setScrollTop(nextTop);
17457
17682
  onViewportChange?.(nextTop);
@@ -17470,73 +17695,323 @@ function ReviewDiffPane({
17470
17695
  }
17471
17696
  }, delay));
17472
17697
  return clearPendingTimers;
17473
- }, [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]);
17474
17707
  const leadingSpacer = window.offsets[window.first] ?? 0;
17475
17708
  const trailingSpacer = window.total - (window.offsets[window.last + 1] ?? window.total);
17476
- return /* @__PURE__ */ jsx3("scrollbox", {
17477
- id: "review-diff-scrollbox",
17478
- ref: scrollRef,
17479
- ...focused === undefined ? {} : { focused },
17480
- width: "100%",
17481
- height: "100%",
17482
- scrollY: true,
17483
- viewportCulling: true,
17484
- verticalScrollbarOptions: { visible: false },
17485
- onMouseScroll: (event) => {
17486
- const direction = event.scroll?.direction;
17487
- const delta = Math.max(1, Math.floor(event.scroll?.delta ?? 1));
17488
- const target = event.currentTarget;
17489
- const scrollBox = target && typeof target.scrollBy === "function" ? target : scrollRef.current;
17490
- if (direction === "down")
17491
- scrollBox?.scrollBy(delta);
17492
- else if (direction === "up")
17493
- scrollBox?.scrollBy(-delta);
17494
- },
17495
- children: /* @__PURE__ */ jsxs3("box", {
17496
- id: "review-diff-content",
17497
- 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" },
17498
17907
  children: [
17499
- leadingSpacer > 0 ? /* @__PURE__ */ jsx3("box", {
17500
- style: { width: "100%", height: leadingSpacer }
17501
- }, "review-leading-spacer") : null,
17502
- window.first <= window.last ? files.slice(window.first, window.last + 1).map((file, offset) => {
17503
- const fileIndex = window.first + offset;
17504
- const sectionTop = window.offsets[fileIndex] ?? 0;
17505
- const sectionHeight = window.heights[fileIndex] ?? 0;
17506
- const rowStart = Math.max(0, Math.floor(scrollTop - sectionTop - overscan));
17507
- const rowEnd = Math.min(sectionHeight, Math.ceil(scrollTop + Math.max(1, height) + overscan - sectionTop));
17508
- const highlight = highlightByFileKey?.get(file.id);
17509
- const select = onSelectFile ? () => onSelectFile(file.id) : undefined;
17510
- return /* @__PURE__ */ jsx3(ReviewDiffSection, {
17511
- file,
17512
- state,
17513
- layout,
17514
- width,
17515
- selectedHunkIndex: file.id === selectedFileKey ? selectedHunkIndex : -1,
17516
- showDivider: fileIndex > 0,
17517
- showLineNumbers,
17518
- wrapLines,
17519
- rowStart,
17520
- rowEnd: Math.max(rowStart, rowEnd),
17521
- ...highlight ? { highlight } : {},
17522
- ...expandedSourceByGap ? { expandedSourceByGap } : {},
17523
- ...select ? { onSelect: select } : {},
17524
- ...onSelectFeedback ? { onSelectFeedback } : {},
17525
- ...onSelectDiffAddress ? { onSelectDiffAddress } : {},
17526
- ...selectedFeedbackId !== undefined ? { selectedFeedbackId } : {},
17527
- ...onToggleGap ? { onToggleGap: (gapId) => onToggleGap(file.id, gapId) } : {}
17528
- }, 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
17979
+ }) : 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
17529
17986
  }) : null,
17530
- trailingSpacer > 0 ? /* @__PURE__ */ jsx3("box", {
17531
- style: { width: "100%", height: trailingSpacer }
17532
- }, "review-trailing-spacer") : 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
+ })
17533
18008
  ]
17534
18009
  })
17535
18010
  });
17536
18011
  }
17537
18012
 
17538
18013
  // src/ui/review-workspace/hooks/useReviewHighlights.ts
17539
- 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";
17540
18015
 
17541
18016
  // src/review/git/highlight/highlight-cache.ts
17542
18017
  class HighlightCache {
@@ -17600,9 +18075,9 @@ function syntaxThemeForAppearance(appearance) {
17600
18075
  import { parsePatchFiles as parsePatchFiles2 } from "@pierre/diffs";
17601
18076
 
17602
18077
  // src/review/core/identity.ts
17603
- import { createHash as createHash3 } from "node:crypto";
18078
+ import { createHash as createHash4 } from "node:crypto";
17604
18079
  function sha256Tuple2(parts) {
17605
- const hash = createHash3("sha256");
18080
+ const hash = createHash4("sha256");
17606
18081
  for (const part of parts) {
17607
18082
  const bytes = new TextEncoder().encode(part);
17608
18083
  const length = Buffer.alloc(4);
@@ -18144,7 +18619,7 @@ function findPatchChunk(metadata, chunks, index) {
18144
18619
  const byIndex = chunks[index];
18145
18620
  if (byIndex)
18146
18621
  return byIndex;
18147
- 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))) ?? "";
18148
18623
  }
18149
18624
  function parseHunkHeader(line) {
18150
18625
  const m = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
@@ -18156,8 +18631,8 @@ function parseHunkHeader(line) {
18156
18631
  const newCount = m[4] !== undefined ? Number.parseInt(m[4], 10) : 1;
18157
18632
  return { oldStart, oldCount, newStart, newCount };
18158
18633
  }
18159
- function extractHunksFromChunk(chunk2) {
18160
- const lines = chunk2.split(`
18634
+ function extractHunksFromChunk(chunk3) {
18635
+ const lines = chunk3.split(`
18161
18636
  `);
18162
18637
  const hunks = [];
18163
18638
  let currentHunk = null;
@@ -18307,10 +18782,10 @@ function collectHastHighlightRuns(node, appearance) {
18307
18782
  }
18308
18783
  const props = cur.properties ?? {};
18309
18784
  const style = parseStyleValue(props["style"]);
18310
- const color2 = style.get("color");
18785
+ const color3 = style.get("color");
18311
18786
  const bg3 = style.get("background-color");
18312
18787
  const isWordDiff = bg3 !== undefined && bg3 !== "transparent";
18313
- const nextFg = color2 ?? fg5;
18788
+ const nextFg = color3 ?? fg5;
18314
18789
  const nextWordDiff = wordDiff || isWordDiff;
18315
18790
  const pushFg = nextFg;
18316
18791
  const children = cur.children ?? [];
@@ -18497,9 +18972,9 @@ function useReviewHighlights(options) {
18497
18972
  } = options;
18498
18973
  const reviewId = options.reviewId ?? state?.document.identity.id ?? "review";
18499
18974
  const generationId = options.generationId ?? state?.document.generation.id ?? "generation";
18500
- const requestedKey = useMemo3(() => requestedFileKeys?.join("\x00") ?? "", [requestedFileKeys]);
18501
- const [highlights, setHighlights] = useState2(new Map);
18502
- 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);
18503
18978
  useEffect2(() => {
18504
18979
  let cancelled = false;
18505
18980
  if (!enabled || files.length === 0) {
@@ -19060,7 +19535,7 @@ function planReviewIntent(state, intent) {
19060
19535
  }
19061
19536
 
19062
19537
  // src/ui/review-workspace/ReviewWorkspaceApp.tsx
19063
- 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";
19064
19539
  var REVIEW_SIDEBAR_DEFAULT_WIDTH = 30;
19065
19540
  var REVIEW_SIDEBAR_MIN_WIDTH = 20;
19066
19541
  var REVIEW_DIFF_MIN_CONTENT_WIDTH = 40;
@@ -19075,30 +19550,6 @@ var COLORS2 = {
19075
19550
  changed: "#f0c674",
19076
19551
  feedback: "#c397d8"
19077
19552
  };
19078
- var colorCache3 = new Map;
19079
- function textChunk(text, style) {
19080
- let fg5 = colorCache3.get(COLORS2[style]);
19081
- if (!fg5) {
19082
- fg5 = parseColor3(COLORS2[style]);
19083
- colorCache3.set(COLORS2[style], fg5);
19084
- }
19085
- return { __isChunk: true, text, fg: fg5 };
19086
- }
19087
- function headerText(state, width, error) {
19088
- const lines = [...reviewHeaderLines(state, width)];
19089
- if (error)
19090
- lines.push([{ text: `! ${error.title}: ${error.detail}`, style: "dim" }]);
19091
- const chunks = [];
19092
- for (const [index, line] of lines.entries()) {
19093
- if (index > 0)
19094
- chunks.push(textChunk(`
19095
- `, "plain"));
19096
- for (const span of line) {
19097
- chunks.push(textChunk(span.text, span.style === "strong" ? "strong" : span.style === "dim" ? "dim" : "plain"));
19098
- }
19099
- }
19100
- return new StyledText8(chunks);
19101
- }
19102
19553
  function fitText(text, width, overflowMarker = ".") {
19103
19554
  if (cellWidth(text) <= width)
19104
19555
  return text;
@@ -19123,7 +19574,14 @@ function padText(text, width) {
19123
19574
  return text;
19124
19575
  return text + " ".repeat(width - w);
19125
19576
  }
19126
- 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) {
19127
19585
  const selected = state.selection.fileKey ?? "none";
19128
19586
  const entryFor = (id) => REVIEW_COMMANDS.find((candidate) => candidate.id === id);
19129
19587
  const keyFor = (id) => {
@@ -19138,6 +19596,7 @@ function reviewFooter(state, layout, focus) {
19138
19596
  command("review.focusFiles", true),
19139
19597
  command("review.toggleFocus", true),
19140
19598
  `${command("review.layoutCycle", true)}(${layout})`,
19599
+ command("review.chooseBase"),
19141
19600
  pair("review.moveDown", "review.moveUp"),
19142
19601
  pair("review.nextHunk", "review.prevHunk"),
19143
19602
  pair("review.nextFile", "review.prevFile"),
@@ -19156,7 +19615,8 @@ function reviewFooter(state, layout, focus) {
19156
19615
  command("review.help"),
19157
19616
  command("review.close")
19158
19617
  ];
19159
- return `${hints.join(" | ")} | ${focus} — ${selected}`;
19618
+ const trailer = `${focus} — ${selected}`;
19619
+ return notice ? `${notice} | ${hints.join(" | ")} | ${trailer}` : `${hints.join(" | ")} | ${trailer}`;
19160
19620
  }
19161
19621
  function feedbackDraftText(state) {
19162
19622
  const draft = state.draft;
@@ -19200,7 +19660,7 @@ function isPrintableFilterKey(name) {
19200
19660
  const codePoint = value.codePointAt(0) ?? 0;
19201
19661
  return codePoint >= 32 && codePoint !== 127;
19202
19662
  }
19203
- function consume(event) {
19663
+ function consume2(event) {
19204
19664
  try {
19205
19665
  event.preventDefault?.();
19206
19666
  } catch {}
@@ -19286,37 +19746,53 @@ function ReviewWorkspaceApp({ session }) {
19286
19746
  const active2 = session.active;
19287
19747
  const onClose = session.onClose;
19288
19748
  const finishDialog = session.finishDialog;
19289
- 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]);
19290
19763
  const state = controller.state ?? publishedState;
19291
- const [layoutMode, setLayoutMode] = useState3("auto");
19292
- const [sidebarWidthPreference, setSidebarWidthPreference] = useState3(REVIEW_SIDEBAR_DEFAULT_WIDTH);
19293
- const [resizeBarHovered, setResizeBarHovered] = useState3(false);
19294
- const [resizingSidebar, setResizingSidebar] = useState3(false);
19295
- const [visibleFileKeys, setVisibleFileKeys] = useState3([]);
19296
- const [focus, setFocus] = useState3("stream");
19297
- const [rangeStart, setRangeStart] = useState3(null);
19298
- const [pendingRangeAnchor, setPendingRangeAnchor] = useState3(null);
19299
- const localRangeIdentityRef = useRef2(null);
19300
- const [selectedFeedbackId, setSelectedFeedbackId] = useState3(null);
19301
- const [editingFeedbackId, setEditingFeedbackId] = useState3(null);
19302
- const [pendingDeleteFeedbackId, setPendingDeleteFeedbackId] = useState3(null);
19303
- const [composerFocus, setComposerFocus] = useState3("body");
19304
- const [composerControlIndex, setComposerControlIndex] = useState3(0);
19305
- const [reanchorFeedbackId, setReanchorFeedbackId] = useState3(null);
19306
- const [feedbackMessage, setFeedbackMessage] = useState3(null);
19307
- const [helpOpen, setHelpOpen] = useState3(false);
19308
- const diffScrollRef = useRef2(null);
19309
- const resizingSidebarRef = useRef2(false);
19310
- const resizeDraggedRef = useRef2(false);
19311
- const resizeReleaseSuppressionRef = useRef2(false);
19312
- const resizeReleaseCleanupTokenRef = useRef2(0);
19313
- const filterInputRef = useRef2(null);
19314
- const pendingDeleteFeedbackRef = useRef2(null);
19315
- const composerBodyRef = useRef2(null);
19316
- const replacementRef = useRef2(null);
19317
- const finishSummaryRef = useRef2(null);
19318
- const finishSubmitRef = useRef2(false);
19319
- 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);
19320
19796
  const dimensions2 = { width: Math.max(1, terminal.width), height: Math.max(1, terminal.height) };
19321
19797
  const maxSidebarWidth = Math.max(REVIEW_SIDEBAR_MIN_WIDTH, dimensions2.width - REVIEW_RESIZE_BAR_WIDTH - REVIEW_DIFF_BORDER_WIDTH - REVIEW_DIFF_MIN_CONTENT_WIDTH);
19322
19798
  const sidebarWidth = dimensions2.width >= REVIEW_SIDEBAR_VISIBILITY_WIDTH ? Math.min(Math.max(sidebarWidthPreference, REVIEW_SIDEBAR_MIN_WIDTH), maxSidebarWidth) : 0;
@@ -19327,12 +19803,12 @@ function ReviewWorkspaceApp({ session }) {
19327
19803
  const resizeBarHeight = Math.max(1, dimensions2.height - 4 - composerHeight);
19328
19804
  const sidebarFocused = focus === "sidebar" || focus === "filter";
19329
19805
  const diffFocused = focus === "stream";
19330
- const files = useMemo4(() => state ? toHunkReviewFiles(visibleReviewFiles(state)) : [], [state?.document, state?.feedback, state?.filter, state?.viewed]);
19331
- const sidebarEntries = useMemo4(() => state ? buildReviewSidebarEntries(state) : [], [state]);
19332
- const sidebarFileEntries = useMemo4(() => sidebarEntries.filter((entry) => entry.kind === "file"), [sidebarEntries]);
19333
- 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]);
19334
19810
  const sidebarTextWidth = Math.max(8, sidebarWidth - 2);
19335
- const expandedSourceByGap = useMemo4(() => {
19811
+ const expandedSourceByGap = useMemo5(() => {
19336
19812
  const next = typeof controller.getExpandedSourceByGap === "function" ? controller.getExpandedSourceByGap() : new Map;
19337
19813
  const previous = expandedSourceRef.current;
19338
19814
  if (previous.size === next.size && [...next].every(([key, lines]) => previous.get(key) === lines))
@@ -19413,9 +19889,7 @@ function ReviewWorkspaceApp({ session }) {
19413
19889
  setResizingSidebar(false);
19414
19890
  setResizeBarHovered(false);
19415
19891
  }, []);
19416
- useLayoutEffect2(() => {
19417
- if (active2)
19418
- return;
19892
+ const resetLocalReviewUi = useCallback(() => {
19419
19893
  setRangeStart(null);
19420
19894
  setPendingRangeAnchor(null);
19421
19895
  setSelectedFeedbackId(null);
@@ -19429,8 +19903,33 @@ function ReviewWorkspaceApp({ session }) {
19429
19903
  setHelpOpen(false);
19430
19904
  setFocus("stream");
19431
19905
  resetSidebarResize();
19432
- }, [active2, resetSidebarResize]);
19433
- 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(() => {
19434
19933
  if (sidebarWidth > 0)
19435
19934
  return;
19436
19935
  resetSidebarResize();
@@ -19439,8 +19938,8 @@ function ReviewWorkspaceApp({ session }) {
19439
19938
  if (sidebarWidth === 0 && focus !== "stream")
19440
19939
  setFocus("stream");
19441
19940
  }, [focus, sidebarWidth]);
19442
- useLayoutEffect2(() => {
19443
- if (!state?.draft)
19941
+ useLayoutEffect3(() => {
19942
+ if (!state?.draft || baseSelection)
19444
19943
  return;
19445
19944
  if (composerFocus === "body") {
19446
19945
  composerBodyRef.current?.focus();
@@ -19452,7 +19951,7 @@ function ReviewWorkspaceApp({ session }) {
19452
19951
  composerBodyRef.current?.blur();
19453
19952
  replacementRef.current?.blur();
19454
19953
  }
19455
- }, [composerFocus, state?.draft]);
19954
+ }, [baseSelection !== undefined, composerFocus, state?.draft]);
19456
19955
  useEffect3(() => {
19457
19956
  const pending = pendingRangeAnchor;
19458
19957
  const start = rangeStart;
@@ -19701,7 +20200,48 @@ function ReviewWorkspaceApp({ session }) {
19701
20200
  session.invalidate();
19702
20201
  } catch {}
19703
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]);
19704
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
+ }
19705
20245
  const current = controller.state;
19706
20246
  if (commandId === "review.focusDiff") {
19707
20247
  setFocus("stream");
@@ -19944,10 +20484,17 @@ function ReviewWorkspaceApp({ session }) {
19944
20484
  return true;
19945
20485
  }
19946
20486
  return false;
19947
- }, [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]);
19948
20488
  const handleKey = useCallback((event) => {
19949
20489
  if (!active2)
19950
20490
  return;
20491
+ if (baseSelection || controller.baseSelection) {
20492
+ if (keyName(event) === "escape") {
20493
+ consume2(event);
20494
+ cancelBaseSelection();
20495
+ }
20496
+ return;
20497
+ }
19951
20498
  const name = keyName(event);
19952
20499
  const current = controller.state;
19953
20500
  if (name === "escape") {
@@ -19959,25 +20506,25 @@ function ReviewWorkspaceApp({ session }) {
19959
20506
  setComposerFocus("body");
19960
20507
  setComposerControlIndex(0);
19961
20508
  session.invalidate();
19962
- consume(event);
20509
+ consume2(event);
19963
20510
  return;
19964
20511
  }
19965
20512
  if (finishDialog.isOpen()) {
19966
20513
  finishDialog.close();
19967
20514
  session.invalidate();
19968
- consume(event);
20515
+ consume2(event);
19969
20516
  return;
19970
20517
  }
19971
20518
  if (helpOpen) {
19972
20519
  setHelpOpen(false);
19973
- consume(event);
20520
+ consume2(event);
19974
20521
  return;
19975
20522
  }
19976
20523
  if (pendingDeleteFeedbackId) {
19977
20524
  pendingDeleteFeedbackRef.current = null;
19978
20525
  setPendingDeleteFeedbackId(null);
19979
20526
  session.invalidate();
19980
- consume(event);
20527
+ consume2(event);
19981
20528
  return;
19982
20529
  }
19983
20530
  if (focus === "filter") {
@@ -19990,19 +20537,19 @@ function ReviewWorkspaceApp({ session }) {
19990
20537
  filterInputRef.current?.blur();
19991
20538
  setFocus("stream");
19992
20539
  }
19993
- consume(event);
20540
+ consume2(event);
19994
20541
  return;
19995
20542
  }
19996
20543
  if (reanchorFeedbackId) {
19997
20544
  setReanchorFeedbackId(null);
19998
20545
  setFeedbackMessage(null);
19999
- consume(event);
20546
+ consume2(event);
20000
20547
  return;
20001
20548
  }
20002
20549
  if (rangeStart || pendingRangeAnchor) {
20003
20550
  setRangeStart(null);
20004
20551
  setPendingRangeAnchor(null);
20005
- consume(event);
20552
+ consume2(event);
20006
20553
  return;
20007
20554
  }
20008
20555
  }
@@ -20010,24 +20557,24 @@ function ReviewWorkspaceApp({ session }) {
20010
20557
  if (event.ctrl && (name === "1" || name === "2" || name === "3")) {
20011
20558
  finishDialog.setDecision(name === "1" ? "comment" : name === "2" ? "approve" : "request-changes");
20012
20559
  session.invalidate();
20013
- consume(event);
20560
+ consume2(event);
20014
20561
  return;
20015
20562
  }
20016
20563
  if (event.ctrl && name.toLowerCase() === "s" || name === "enter") {
20017
20564
  submitFinish();
20018
- consume(event);
20565
+ consume2(event);
20019
20566
  return;
20020
20567
  }
20021
20568
  return;
20022
20569
  }
20023
20570
  if (helpOpen) {
20024
- consume(event);
20571
+ consume2(event);
20025
20572
  return;
20026
20573
  }
20027
20574
  if (current?.draft) {
20028
20575
  if (event.ctrl && name.toLowerCase() === "s") {
20029
20576
  saveDraft();
20030
- consume(event);
20577
+ consume2(event);
20031
20578
  } else if (name === "tab") {
20032
20579
  const hasReplacement = canShowReplacementDraft(current);
20033
20580
  const backwards = event.shift === true;
@@ -20057,14 +20604,14 @@ function ReviewWorkspaceApp({ session }) {
20057
20604
  setComposerControlIndex(0);
20058
20605
  }
20059
20606
  }
20060
- consume(event);
20607
+ consume2(event);
20061
20608
  }
20062
20609
  return;
20063
20610
  }
20064
20611
  if (focus === "filter" && name === "enter") {
20065
20612
  filterInputRef.current?.blur();
20066
20613
  setFocus("stream");
20067
- consume(event);
20614
+ consume2(event);
20068
20615
  return;
20069
20616
  }
20070
20617
  if (focus === "filter" && !event.ctrl && !event.meta && !event.option && isPrintableFilterKey(name))
@@ -20074,17 +20621,30 @@ function ReviewWorkspaceApp({ session }) {
20074
20621
  if (!command || current && !command.available(current))
20075
20622
  return;
20076
20623
  if (executeCommand(command.id))
20077
- consume(event);
20078
- }, [active2, composerControlIndex, composerFocus, controller, executeCommand, finishDialog, focus, helpOpen, onClose, pendingDeleteFeedbackId, pendingRangeAnchor, rangeStart, reanchorFeedbackId, saveDraft, session, submitFinish]);
20079
- 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;
20080
20637
  if (!state) {
20081
- return /* @__PURE__ */ jsx4("box", {
20638
+ return /* @__PURE__ */ jsxs5("box", {
20082
20639
  id: "react-review-workspace",
20083
20640
  visible: active2,
20084
- style: { width: "100%", height: "100%" },
20085
- children: /* @__PURE__ */ jsx4("text", {
20086
- content: "Loading branch review…"
20087
- })
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
+ ]
20088
20648
  });
20089
20649
  }
20090
20650
  function selectFile(fileKey, nextFocus2 = "stream") {
@@ -20106,28 +20666,55 @@ function ReviewWorkspaceApp({ session }) {
20106
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");
20107
20667
  const replacementInvalid = suggestionReplacementInvalid(state);
20108
20668
  const orphanedFeedback = state.feedback.filter((feedback) => !state.document.files.some((file) => file.key === feedback.anchor.fileKey));
20109
- return /* @__PURE__ */ jsxs4("box", {
20669
+ return /* @__PURE__ */ jsxs5("box", {
20110
20670
  id: "react-review-workspace",
20111
20671
  visible: active2,
20112
20672
  onMouse: handleSidebarResizeMouse,
20113
20673
  style: { position: "relative", width: "100%", height: "100%", flexDirection: "column", overflow: "hidden" },
20114
20674
  children: [
20115
- /* @__PURE__ */ jsx4("box", {
20675
+ /* @__PURE__ */ jsxs5("box", {
20116
20676
  id: "react-review-header",
20117
- style: { width: "100%", height: 3, flexShrink: 0 },
20118
- children: /* @__PURE__ */ jsx4("text", {
20119
- content: headerText(state, dimensions2.width, controller.error),
20120
- wrapMode: "none",
20121
- truncate: true
20122
- })
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
+ ]
20123
20710
  }),
20124
- /* @__PURE__ */ jsxs4("box", {
20711
+ /* @__PURE__ */ jsxs5("box", {
20125
20712
  id: "react-review-body",
20126
20713
  style: { width: "100%", flexGrow: 1, flexDirection: "row", overflow: "hidden" },
20127
20714
  children: [
20128
- sidebarWidth > 0 ? /* @__PURE__ */ jsxs4(Fragment, {
20715
+ sidebarWidth > 0 ? /* @__PURE__ */ jsxs5(Fragment, {
20129
20716
  children: [
20130
- /* @__PURE__ */ jsxs4("box", {
20717
+ /* @__PURE__ */ jsxs5("box", {
20131
20718
  id: "react-review-sidebar",
20132
20719
  borderColor: sidebarFocused ? ANSI_GREEN : DEFAULT_FOREGROUND,
20133
20720
  title: `[1] Files ${sidebarFileEntries.length}/${state.document.files.length}`,
@@ -20135,21 +20722,21 @@ function ReviewWorkspaceApp({ session }) {
20135
20722
  style: { width: sidebarWidth, height: "100%", flexShrink: 0, border: true, flexDirection: "column" },
20136
20723
  onMouseDown: () => setFocus("sidebar"),
20137
20724
  children: [
20138
- /* @__PURE__ */ jsxs4("box", {
20725
+ /* @__PURE__ */ jsxs5("box", {
20139
20726
  id: "review-file-filter",
20140
20727
  style: { width: "100%", height: 1, flexShrink: 0, flexDirection: "row" },
20141
20728
  children: [
20142
- /* @__PURE__ */ jsx4("text", {
20729
+ /* @__PURE__ */ jsx6("text", {
20143
20730
  content: "/ ",
20144
20731
  fg: COLORS2.dim
20145
20732
  }),
20146
- /* @__PURE__ */ jsx4("input", {
20733
+ /* @__PURE__ */ jsx6("input", {
20147
20734
  id: "review-file-filter-input",
20148
20735
  ref: filterInputRef,
20149
20736
  width: Math.max(4, sidebarWidth - 4),
20150
20737
  value: state.filter.query,
20151
20738
  placeholder: "filter files",
20152
- focused: focus === "filter",
20739
+ focused: focus === "filter" && !baseSelection,
20153
20740
  onMouseUp: () => {
20154
20741
  if (resizingSidebarRef.current || resizeReleaseSuppressionRef.current)
20155
20742
  return;
@@ -20176,28 +20763,28 @@ function ReviewWorkspaceApp({ session }) {
20176
20763
  filterInputRef.current?.blur();
20177
20764
  setFocus("stream");
20178
20765
  }
20179
- consume(event);
20766
+ consume2(event);
20180
20767
  }
20181
20768
  })
20182
20769
  ]
20183
20770
  }),
20184
- /* @__PURE__ */ jsx4("scrollbox", {
20771
+ /* @__PURE__ */ jsx6("scrollbox", {
20185
20772
  id: "react-review-sidebar-scrollbox",
20186
- focused: focus === "sidebar",
20773
+ focused: focus === "sidebar" && !baseSelection,
20187
20774
  width: "100%",
20188
20775
  flexGrow: 1,
20189
20776
  scrollY: true,
20190
20777
  viewportCulling: true,
20191
20778
  verticalScrollbarOptions: { visible: false },
20192
20779
  onMouseDown: () => setFocus("sidebar"),
20193
- children: /* @__PURE__ */ jsx4("box", {
20780
+ children: /* @__PURE__ */ jsx6("box", {
20194
20781
  style: { width: "100%", flexDirection: "column" },
20195
20782
  children: sidebarEntries.map((entry) => {
20196
20783
  if (entry.kind === "group") {
20197
- return /* @__PURE__ */ jsx4("box", {
20784
+ return /* @__PURE__ */ jsx6("box", {
20198
20785
  id: `review-file-group:${entry.label}`,
20199
20786
  style: { width: "100%", height: 1, backgroundColor: REVIEW_SIDEBAR_THEME.panel, paddingLeft: 1 },
20200
- children: /* @__PURE__ */ jsx4("text", {
20787
+ children: /* @__PURE__ */ jsx6("text", {
20201
20788
  fg: REVIEW_SIDEBAR_THEME.muted,
20202
20789
  children: fitText(entry.label, sidebarTextWidth)
20203
20790
  })
@@ -20206,11 +20793,11 @@ function ReviewWorkspaceApp({ session }) {
20206
20793
  const selected = entry.id === state.selection.fileKey;
20207
20794
  const rowBackground = selected ? REVIEW_SIDEBAR_THEME.panelAlt : REVIEW_SIDEBAR_THEME.panel;
20208
20795
  const stats = sidebarEntryStats(entry);
20209
- const { icon, color: color2 } = getFileStateIcon(entry);
20796
+ const { icon, color: color3 } = getFileStateIcon(entry);
20210
20797
  const iconWidth = icon ? 2 : 0;
20211
20798
  const statsSectionWidth = sidebarStatsWidth > 0 ? sidebarStatsWidth + 1 : 0;
20212
20799
  const nameWidth = Math.max(1, sidebarTextWidth - 1 - iconWidth - statsSectionWidth);
20213
- return /* @__PURE__ */ jsxs4("box", {
20800
+ return /* @__PURE__ */ jsxs5("box", {
20214
20801
  id: `review-file-row:${entry.id}`,
20215
20802
  style: { width: "100%", height: 1, backgroundColor: rowBackground, flexDirection: "row" },
20216
20803
  onMouseDown: () => setFocus("sidebar"),
@@ -20218,30 +20805,30 @@ function ReviewWorkspaceApp({ session }) {
20218
20805
  executeCommand("review.selectFile", { fileKey: entry.id, nextFocus: "sidebar" });
20219
20806
  },
20220
20807
  children: [
20221
- /* @__PURE__ */ jsx4("box", {
20808
+ /* @__PURE__ */ jsx6("box", {
20222
20809
  style: { width: 1, height: 1, backgroundColor: selected ? REVIEW_SIDEBAR_THEME.accent : rowBackground }
20223
20810
  }),
20224
- /* @__PURE__ */ jsxs4("box", {
20811
+ /* @__PURE__ */ jsxs5("box", {
20225
20812
  style: { flexGrow: 1, height: 1, paddingLeft: 0, flexDirection: "row", backgroundColor: rowBackground },
20226
20813
  children: [
20227
- icon ? /* @__PURE__ */ jsx4("text", {
20228
- fg: color2,
20814
+ icon ? /* @__PURE__ */ jsx6("text", {
20815
+ fg: color3,
20229
20816
  children: `${icon} `
20230
20817
  }) : null,
20231
- /* @__PURE__ */ jsx4("text", {
20818
+ /* @__PURE__ */ jsx6("text", {
20232
20819
  fg: REVIEW_SIDEBAR_THEME.text,
20233
20820
  children: padText(fitText(entry.name, nameWidth), nameWidth)
20234
20821
  }),
20235
- statsSectionWidth > 0 ? /* @__PURE__ */ jsx4("box", {
20822
+ statsSectionWidth > 0 ? /* @__PURE__ */ jsx6("box", {
20236
20823
  style: { width: statsSectionWidth, height: 1, flexDirection: "row", justifyContent: "flex-end", backgroundColor: rowBackground },
20237
- children: stats.map((stat3, index) => /* @__PURE__ */ jsxs4("box", {
20824
+ children: stats.map((stat3, index) => /* @__PURE__ */ jsxs5("box", {
20238
20825
  style: { height: 1, flexDirection: "row", backgroundColor: rowBackground },
20239
20826
  children: [
20240
- index > 0 ? /* @__PURE__ */ jsx4("text", {
20827
+ index > 0 ? /* @__PURE__ */ jsx6("text", {
20241
20828
  fg: selected ? REVIEW_SIDEBAR_THEME.text : REVIEW_SIDEBAR_THEME.muted,
20242
20829
  children: " "
20243
20830
  }) : null,
20244
- /* @__PURE__ */ jsx4("text", {
20831
+ /* @__PURE__ */ jsx6("text", {
20245
20832
  fg: stat3.kind === "agent-comment" ? REVIEW_SIDEBAR_THEME.noteBorder : stat3.kind === "addition" ? REVIEW_SIDEBAR_THEME.badgeAdded : REVIEW_SIDEBAR_THEME.badgeRemoved,
20246
20833
  children: stat3.text
20247
20834
  })
@@ -20257,7 +20844,7 @@ function ReviewWorkspaceApp({ session }) {
20257
20844
  })
20258
20845
  ]
20259
20846
  }),
20260
- /* @__PURE__ */ jsx4("box", {
20847
+ /* @__PURE__ */ jsx6("box", {
20261
20848
  id: "review-pane-resize-bar",
20262
20849
  style: { width: REVIEW_RESIZE_BAR_WIDTH, height: "100%", flexShrink: 0 },
20263
20850
  onMouseOver: () => setResizeBarHovered(true),
@@ -20290,7 +20877,7 @@ function ReviewWorkspaceApp({ session }) {
20290
20877
  event.preventDefault();
20291
20878
  event.stopPropagation();
20292
20879
  },
20293
- children: /* @__PURE__ */ jsx4("text", {
20880
+ children: /* @__PURE__ */ jsx6("text", {
20294
20881
  id: "review-pane-resize-bar-glyphs",
20295
20882
  selectable: false,
20296
20883
  content: splitterGlyphs("vertical", REVIEW_RESIZE_BAR_WIDTH, resizeBarHeight, resizeBarHovered || resizingSidebar),
@@ -20303,20 +20890,20 @@ function ReviewWorkspaceApp({ session }) {
20303
20890
  })
20304
20891
  ]
20305
20892
  }) : null,
20306
- /* @__PURE__ */ jsx4("box", {
20893
+ /* @__PURE__ */ jsx6("box", {
20307
20894
  id: "react-review-diff",
20308
20895
  borderColor: diffFocused ? ANSI_GREEN : DEFAULT_FOREGROUND,
20309
20896
  title: `[0] Diff — ${layout}`,
20310
20897
  titleColor: diffFocused ? ANSI_GREEN : DEFAULT_FOREGROUND,
20311
20898
  style: { width: "100%", height: "100%", flexGrow: 1, border: true, minWidth: 0 },
20312
20899
  onMouseDown: () => setFocus("stream"),
20313
- children: /* @__PURE__ */ jsx4(ReviewDiffPane, {
20900
+ children: /* @__PURE__ */ jsx6(ReviewDiffPane, {
20314
20901
  files,
20315
20902
  state,
20316
20903
  layout,
20317
20904
  width: diffWidth,
20318
20905
  height: diffHeight,
20319
- focused: focus === "stream",
20906
+ focused: focus === "stream" && !baseSelection,
20320
20907
  scrollRef: diffScrollRef,
20321
20908
  selectedFileKey: state.selection.fileKey,
20322
20909
  selectedHunkIndex: state.selection.hunkIndex,
@@ -20337,14 +20924,14 @@ function ReviewWorkspaceApp({ session }) {
20337
20924
  },
20338
20925
  selectedFeedbackId,
20339
20926
  onViewportChange: session.setViewportStart
20340
- })
20927
+ }, state.document.identity.id)
20341
20928
  })
20342
20929
  ]
20343
20930
  }),
20344
- orphanedFeedback.length > 0 ? /* @__PURE__ */ jsx4("box", {
20931
+ orphanedFeedback.length > 0 ? /* @__PURE__ */ jsx6("box", {
20345
20932
  id: "review-orphaned-feedback",
20346
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" },
20347
- children: orphanedFeedback.slice(0, 4).map((feedback) => /* @__PURE__ */ jsxs4("box", {
20934
+ children: orphanedFeedback.slice(0, 4).map((feedback) => /* @__PURE__ */ jsxs5("box", {
20348
20935
  style: { width: "100%", height: 1, flexDirection: "row" },
20349
20936
  onMouseUp: () => {
20350
20937
  if (resizingSidebarRef.current || resizeReleaseSuppressionRef.current)
@@ -20352,32 +20939,32 @@ function ReviewWorkspaceApp({ session }) {
20352
20939
  executeCommand("review.selectFeedback", feedback.id);
20353
20940
  },
20354
20941
  children: [
20355
- /* @__PURE__ */ jsx4("text", {
20942
+ /* @__PURE__ */ jsx6("text", {
20356
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`,
20357
20944
  wrapMode: "none",
20358
20945
  truncate: true
20359
20946
  }),
20360
- /* @__PURE__ */ jsx4("box", {
20947
+ /* @__PURE__ */ jsx6("box", {
20361
20948
  id: `review-delete-feedback:${feedback.id}`,
20362
20949
  onMouseUp: () => {
20363
20950
  if (resizingSidebarRef.current || resizeReleaseSuppressionRef.current)
20364
20951
  return;
20365
20952
  deleteFeedback(feedback.id);
20366
20953
  },
20367
- children: /* @__PURE__ */ jsx4("text", {
20954
+ children: /* @__PURE__ */ jsx6("text", {
20368
20955
  content: pendingDeleteFeedbackId === feedback.id ? "[delete again]" : "[delete]",
20369
20956
  wrapMode: "none",
20370
20957
  truncate: true
20371
20958
  })
20372
20959
  }),
20373
- /* @__PURE__ */ jsx4("box", {
20960
+ /* @__PURE__ */ jsx6("box", {
20374
20961
  id: `review-reanchor-feedback:${feedback.id}`,
20375
20962
  onMouseUp: () => {
20376
20963
  if (resizingSidebarRef.current || resizeReleaseSuppressionRef.current)
20377
20964
  return;
20378
20965
  reanchorFeedback(feedback.id);
20379
20966
  },
20380
- children: /* @__PURE__ */ jsx4("text", {
20967
+ children: /* @__PURE__ */ jsx6("text", {
20381
20968
  content: "[re-anchor]",
20382
20969
  wrapMode: "none",
20383
20970
  truncate: true
@@ -20386,29 +20973,29 @@ function ReviewWorkspaceApp({ session }) {
20386
20973
  ]
20387
20974
  }, feedback.id))
20388
20975
  }) : null,
20389
- feedbackMessage ? /* @__PURE__ */ jsx4("box", {
20976
+ feedbackMessage ? /* @__PURE__ */ jsx6("box", {
20390
20977
  id: "review-feedback-message",
20391
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" },
20392
- children: /* @__PURE__ */ jsx4("text", {
20979
+ children: /* @__PURE__ */ jsx6("text", {
20393
20980
  content: feedbackMessage,
20394
20981
  wrapMode: "none",
20395
20982
  truncate: true
20396
20983
  })
20397
20984
  }) : null,
20398
- state.draft ? /* @__PURE__ */ jsxs4("box", {
20985
+ state.draft ? /* @__PURE__ */ jsxs5("box", {
20399
20986
  id: "review-feedback-composer",
20400
20987
  style: { width: "100%", height: composerHeight, flexShrink: 0, border: true, flexDirection: "column" },
20401
20988
  children: [
20402
- /* @__PURE__ */ jsx4("text", {
20989
+ /* @__PURE__ */ jsx6("text", {
20403
20990
  content: feedbackDraftText(state),
20404
20991
  wrapMode: "none",
20405
20992
  truncate: true
20406
20993
  }),
20407
- /* @__PURE__ */ jsxs4("box", {
20994
+ /* @__PURE__ */ jsxs5("box", {
20408
20995
  id: "review-feedback-controls",
20409
20996
  style: { width: "100%", height: 1, flexDirection: "row" },
20410
20997
  children: [
20411
- /* @__PURE__ */ jsx4("box", {
20998
+ /* @__PURE__ */ jsx6("box", {
20412
20999
  id: "review-feedback-kind-note",
20413
21000
  style: composerFocus === "controls" && composerControlIndex === 0 ? { backgroundColor: "#365f8a" } : {},
20414
21001
  onMouseUp: () => {
@@ -20424,11 +21011,11 @@ function ReviewWorkspaceApp({ session }) {
20424
21011
  session.invalidate();
20425
21012
  } catch {}
20426
21013
  },
20427
- children: /* @__PURE__ */ jsx4("text", {
21014
+ children: /* @__PURE__ */ jsx6("text", {
20428
21015
  content: state.draft.kind === "note" ? "[Note]" : " Note "
20429
21016
  })
20430
21017
  }),
20431
- /* @__PURE__ */ jsx4("box", {
21018
+ /* @__PURE__ */ jsx6("box", {
20432
21019
  id: "review-feedback-kind-suggestion",
20433
21020
  style: composerFocus === "controls" && composerControlIndex === 1 ? { backgroundColor: "#365f8a" } : {},
20434
21021
  onMouseUp: () => {
@@ -20445,11 +21032,11 @@ function ReviewWorkspaceApp({ session }) {
20445
21032
  session.invalidate();
20446
21033
  } catch {}
20447
21034
  },
20448
- children: /* @__PURE__ */ jsx4("text", {
21035
+ children: /* @__PURE__ */ jsx6("text", {
20449
21036
  content: state.draft.kind === "suggestion" ? "[Suggestion]" : " Suggestion "
20450
21037
  })
20451
21038
  }),
20452
- /* @__PURE__ */ jsx4("box", {
21039
+ /* @__PURE__ */ jsx6("box", {
20453
21040
  id: "review-feedback-severity-comment",
20454
21041
  style: composerFocus === "controls" && composerControlIndex === 2 ? { backgroundColor: "#365f8a" } : {},
20455
21042
  onMouseUp: () => {
@@ -20465,11 +21052,11 @@ function ReviewWorkspaceApp({ session }) {
20465
21052
  session.invalidate();
20466
21053
  } catch {}
20467
21054
  },
20468
- children: /* @__PURE__ */ jsx4("text", {
21055
+ children: /* @__PURE__ */ jsx6("text", {
20469
21056
  content: state.draft.severity === "comment" ? "[Comment]" : " Comment "
20470
21057
  })
20471
21058
  }),
20472
- /* @__PURE__ */ jsx4("box", {
21059
+ /* @__PURE__ */ jsx6("box", {
20473
21060
  id: "review-feedback-severity-blocking",
20474
21061
  style: composerFocus === "controls" && composerControlIndex === 3 ? { backgroundColor: "#365f8a" } : {},
20475
21062
  onMouseUp: () => {
@@ -20485,11 +21072,11 @@ function ReviewWorkspaceApp({ session }) {
20485
21072
  session.invalidate();
20486
21073
  } catch {}
20487
21074
  },
20488
- children: /* @__PURE__ */ jsx4("text", {
21075
+ children: /* @__PURE__ */ jsx6("text", {
20489
21076
  content: state.draft.severity === "blocking" ? "[Blocking]" : " Blocking "
20490
21077
  })
20491
21078
  }),
20492
- /* @__PURE__ */ jsx4("box", {
21079
+ /* @__PURE__ */ jsx6("box", {
20493
21080
  id: "review-feedback-save",
20494
21081
  style: composerFocus === "controls" && composerControlIndex === 4 ? { backgroundColor: "#365f8a" } : {},
20495
21082
  onMouseUp: () => {
@@ -20499,11 +21086,11 @@ function ReviewWorkspaceApp({ session }) {
20499
21086
  setComposerControlIndex(4);
20500
21087
  saveDraft();
20501
21088
  },
20502
- children: /* @__PURE__ */ jsx4("text", {
21089
+ children: /* @__PURE__ */ jsx6("text", {
20503
21090
  content: replacementInvalid ? "[Save disabled]" : " [Save] "
20504
21091
  })
20505
21092
  }),
20506
- /* @__PURE__ */ jsx4("box", {
21093
+ /* @__PURE__ */ jsx6("box", {
20507
21094
  id: "review-feedback-cancel",
20508
21095
  style: composerFocus === "controls" && composerControlIndex === 5 ? { backgroundColor: "#365f8a" } : {},
20509
21096
  onMouseUp: () => {
@@ -20521,19 +21108,19 @@ function ReviewWorkspaceApp({ session }) {
20521
21108
  session.invalidate();
20522
21109
  } catch {}
20523
21110
  },
20524
- children: /* @__PURE__ */ jsx4("text", {
21111
+ children: /* @__PURE__ */ jsx6("text", {
20525
21112
  content: " [Cancel] "
20526
21113
  })
20527
21114
  })
20528
21115
  ]
20529
21116
  }),
20530
- /* @__PURE__ */ jsx4("textarea", {
21117
+ /* @__PURE__ */ jsx6("textarea", {
20531
21118
  id: "review-feedback-body",
20532
21119
  ref: composerBodyRef,
20533
21120
  width: "100%",
20534
21121
  height: 2,
20535
21122
  initialValue: state.draft.body,
20536
- focused: composerFocus === "body",
21123
+ focused: composerFocus === "body" && !baseSelection,
20537
21124
  keyBindings: [{ name: "escape", action: "submit" }],
20538
21125
  onSubmit: () => {
20539
21126
  const latest = controller.state;
@@ -20558,10 +21145,10 @@ function ReviewWorkspaceApp({ session }) {
20558
21145
  setComposerFocus("body");
20559
21146
  setComposerControlIndex(0);
20560
21147
  session.invalidate();
20561
- consume(event);
21148
+ consume2(event);
20562
21149
  } else if (event.ctrl && name.toLowerCase() === "s" && latest?.draft) {
20563
21150
  saveDraft();
20564
- consume(event);
21151
+ consume2(event);
20565
21152
  }
20566
21153
  },
20567
21154
  onContentChange: () => {
@@ -20573,16 +21160,16 @@ function ReviewWorkspaceApp({ session }) {
20573
21160
  controller.dispatch(planReviewIntent(latest, { type: "feedback/update-draft", body }));
20574
21161
  } catch {}
20575
21162
  }
20576
- }),
20577
- canShowReplacementDraft(state) ? /* @__PURE__ */ jsxs4(Fragment, {
21163
+ }, state.document.identity.id),
21164
+ canShowReplacementDraft(state) ? /* @__PURE__ */ jsxs5(Fragment, {
20578
21165
  children: [
20579
- /* @__PURE__ */ jsx4("textarea", {
21166
+ /* @__PURE__ */ jsx6("textarea", {
20580
21167
  id: "review-feedback-replacement",
20581
21168
  ref: replacementRef,
20582
21169
  width: "100%",
20583
21170
  height: 2,
20584
21171
  initialValue: state.draft.replacement ?? "",
20585
- focused: composerFocus === "replacement",
21172
+ focused: composerFocus === "replacement" && !baseSelection,
20586
21173
  wrapMode: "char",
20587
21174
  placeholder: "Replacement text",
20588
21175
  onContentChange: () => {
@@ -20595,8 +21182,8 @@ function ReviewWorkspaceApp({ session }) {
20595
21182
  session.invalidate();
20596
21183
  } catch {}
20597
21184
  }
20598
- }),
20599
- replacementInvalid ? /* @__PURE__ */ jsx4("text", {
21185
+ }, state.document.identity.id),
21186
+ replacementInvalid ? /* @__PURE__ */ jsx6("text", {
20600
21187
  id: "review-feedback-replacement-error",
20601
21188
  content: "Invalid replacement: enter non-whitespace text.",
20602
21189
  wrapMode: "none",
@@ -20606,10 +21193,10 @@ function ReviewWorkspaceApp({ session }) {
20606
21193
  }) : null
20607
21194
  ]
20608
21195
  }) : null,
20609
- helpOpen ? /* @__PURE__ */ jsx4("box", {
21196
+ helpOpen ? /* @__PURE__ */ jsx6("box", {
20610
21197
  id: "review-help-dialog",
20611
- 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" },
20612
- 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", {
20613
21200
  content: `Review commands
20614
21201
  ${reviewHelp(focus, state)}
20615
21202
  Esc close this help`,
@@ -20617,16 +21204,16 @@ Esc close this help`,
20617
21204
  truncate: true
20618
21205
  })
20619
21206
  }) : null,
20620
- finishDialog.isOpen() ? /* @__PURE__ */ jsxs4("box", {
21207
+ finishDialog.isOpen() ? /* @__PURE__ */ jsxs5("box", {
20621
21208
  id: "review-finish-dialog",
20622
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" },
20623
21210
  children: [
20624
- /* @__PURE__ */ jsx4("text", {
21211
+ /* @__PURE__ */ jsx6("text", {
20625
21212
  content: `Finish review — ${finishDialog.getDecision()}`,
20626
21213
  wrapMode: "none",
20627
21214
  truncate: true
20628
21215
  }),
20629
- /* @__PURE__ */ jsx4("textarea", {
21216
+ /* @__PURE__ */ jsx6("textarea", {
20630
21217
  id: "review-finish-summary",
20631
21218
  ref: finishSummaryRef,
20632
21219
  width: "100%",
@@ -20640,10 +21227,10 @@ Esc close this help`,
20640
21227
  if (name === "escape") {
20641
21228
  finishDialog.close();
20642
21229
  session.invalidate();
20643
- consume(event);
21230
+ consume2(event);
20644
21231
  } else if (event.ctrl && name.toLowerCase() === "s") {
20645
21232
  submitFinish();
20646
- consume(event);
21233
+ consume2(event);
20647
21234
  }
20648
21235
  },
20649
21236
  keyBindings: [{ name: "enter", action: "submit" }],
@@ -20653,15 +21240,15 @@ Esc close this help`,
20653
21240
  session.invalidate();
20654
21241
  }
20655
21242
  }),
20656
- /* @__PURE__ */ jsx4("text", {
21243
+ /* @__PURE__ */ jsx6("text", {
20657
21244
  content: finishDialog.getValidationMessage(),
20658
21245
  wrapMode: "none",
20659
21246
  truncate: true
20660
21247
  }),
20661
- /* @__PURE__ */ jsxs4("box", {
21248
+ /* @__PURE__ */ jsxs5("box", {
20662
21249
  style: { flexDirection: "row", height: 1 },
20663
21250
  children: [
20664
- /* @__PURE__ */ jsx4("box", {
21251
+ /* @__PURE__ */ jsx6("box", {
20665
21252
  id: "review-finish-comment",
20666
21253
  onMouseUp: () => {
20667
21254
  if (resizingSidebarRef.current || resizeReleaseSuppressionRef.current)
@@ -20669,11 +21256,11 @@ Esc close this help`,
20669
21256
  finishDialog.setDecision("comment");
20670
21257
  session.invalidate();
20671
21258
  },
20672
- children: /* @__PURE__ */ jsx4("text", {
21259
+ children: /* @__PURE__ */ jsx6("text", {
20673
21260
  content: finishDialog.getDecision() === "comment" ? "[Comment]" : " Comment "
20674
21261
  })
20675
21262
  }),
20676
- /* @__PURE__ */ jsx4("box", {
21263
+ /* @__PURE__ */ jsx6("box", {
20677
21264
  id: "review-finish-approve",
20678
21265
  onMouseUp: () => {
20679
21266
  if (resizingSidebarRef.current || resizeReleaseSuppressionRef.current)
@@ -20681,11 +21268,11 @@ Esc close this help`,
20681
21268
  finishDialog.setDecision("approve");
20682
21269
  session.invalidate();
20683
21270
  },
20684
- children: /* @__PURE__ */ jsx4("text", {
21271
+ children: /* @__PURE__ */ jsx6("text", {
20685
21272
  content: finishDialog.getDecision() === "approve" ? "[Approve]" : " Approve "
20686
21273
  })
20687
21274
  }),
20688
- /* @__PURE__ */ jsx4("box", {
21275
+ /* @__PURE__ */ jsx6("box", {
20689
21276
  id: "review-finish-request-changes",
20690
21277
  onMouseUp: () => {
20691
21278
  if (resizingSidebarRef.current || resizeReleaseSuppressionRef.current)
@@ -20693,39 +21280,40 @@ Esc close this help`,
20693
21280
  finishDialog.setDecision("request-changes");
20694
21281
  session.invalidate();
20695
21282
  },
20696
- children: /* @__PURE__ */ jsx4("text", {
21283
+ children: /* @__PURE__ */ jsx6("text", {
20697
21284
  content: finishDialog.getDecision() === "request-changes" ? "[Request Changes]" : " Request Changes "
20698
21285
  })
20699
21286
  })
20700
21287
  ]
20701
21288
  }),
20702
- /* @__PURE__ */ jsx4("box", {
21289
+ /* @__PURE__ */ jsx6("box", {
20703
21290
  id: "review-finish-submit",
20704
21291
  onMouseUp: () => {
20705
21292
  if (resizingSidebarRef.current || resizeReleaseSuppressionRef.current)
20706
21293
  return;
20707
21294
  submitFinish();
20708
21295
  },
20709
- children: /* @__PURE__ */ jsx4("text", {
21296
+ children: /* @__PURE__ */ jsx6("text", {
20710
21297
  content: " Submit "
20711
21298
  })
20712
21299
  }),
20713
- /* @__PURE__ */ jsx4("text", {
21300
+ /* @__PURE__ */ jsx6("text", {
20714
21301
  content: "Ctrl-1 comment · Ctrl-2 approve · Ctrl-3 request changes · Enter/Ctrl-S submit · Esc cancel",
20715
21302
  wrapMode: "none",
20716
21303
  truncate: true
20717
21304
  })
20718
21305
  ]
20719
21306
  }) : null,
20720
- /* @__PURE__ */ jsx4("box", {
21307
+ /* @__PURE__ */ jsx6("box", {
20721
21308
  id: "react-review-footer",
20722
21309
  style: { width: "100%", height: 1, flexShrink: 0 },
20723
- children: /* @__PURE__ */ jsx4("text", {
20724
- content: reviewFooter(state, layout, focus),
21310
+ children: /* @__PURE__ */ jsx6("text", {
21311
+ content: reviewFooter(state, layout, focus, projectionNotice),
20725
21312
  wrapMode: "none",
20726
21313
  truncate: true
20727
21314
  })
20728
- })
21315
+ }),
21316
+ basePicker
20729
21317
  ]
20730
21318
  });
20731
21319
  }
@@ -21051,7 +21639,7 @@ var persistedReviewStateSchema = z.object({
21051
21639
  lastSubmission: submittedReviewRefSchema.nullable(),
21052
21640
  submissionInProgress: submissionInProgressSchema.nullable().optional()
21053
21641
  }).strict();
21054
- 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) => {
21055
21643
  for (const key of Object.keys(val)) {
21056
21644
  if (!isValidBaseByHeadKey(key)) {
21057
21645
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: `invalid baseByHead key: ${key}`, path: [key] });
@@ -21205,8 +21793,9 @@ function toDatabase(raw) {
21205
21793
  for (const [k, v] of Object.entries(raw.reviews))
21206
21794
  reviews[k] = toPersistedReviewState(v);
21207
21795
  const baseByHead = {};
21208
- for (const [k, v] of Object.entries(raw.baseByHead))
21209
- 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
+ }
21210
21799
  return { version: 2, baseByHead, reviews };
21211
21800
  }
21212
21801
  function toIdentity(raw) {
@@ -21572,7 +22161,7 @@ class ReactReviewSession {
21572
22161
  }
21573
22162
 
21574
22163
  // src/ui/review-workspace/react-review-host.tsx
21575
- import { jsx as jsx5 } from "@opentui/react/jsx-runtime";
22164
+ import { jsx as jsx7 } from "@opentui/react/jsx-runtime";
21576
22165
  var rootsByRenderer = new WeakMap;
21577
22166
 
21578
22167
  class ReactReviewHost {
@@ -21595,7 +22184,7 @@ class ReactReviewHost {
21595
22184
  if (!existing.mounted) {
21596
22185
  existing.mounted = true;
21597
22186
  flushSync(() => {
21598
- this.reactRoot.render(/* @__PURE__ */ jsx5(ReviewWorkspaceApp, {
22187
+ this.reactRoot.render(/* @__PURE__ */ jsx7(ReviewWorkspaceApp, {
21599
22188
  session: this.session
21600
22189
  }));
21601
22190
  });
@@ -21607,7 +22196,7 @@ class ReactReviewHost {
21607
22196
  this.reactRoot = createRoot(renderer);
21608
22197
  rootsByRenderer.set(renderer, { root: this.reactRoot, session: this.session, mounted: true });
21609
22198
  flushSync(() => {
21610
- this.reactRoot.render(/* @__PURE__ */ jsx5(ReviewWorkspaceApp, {
22199
+ this.reactRoot.render(/* @__PURE__ */ jsx7(ReviewWorkspaceApp, {
21611
22200
  session: this.session
21612
22201
  }));
21613
22202
  });
@@ -21924,7 +22513,7 @@ class AppScreenController {
21924
22513
  }
21925
22514
 
21926
22515
  // src/ui/review-workspace/controller.ts
21927
- import { createHash as createHash5 } from "node:crypto";
22516
+ import { createHash as createHash6 } from "node:crypto";
21928
22517
 
21929
22518
  // src/review/core/state.ts
21930
22519
  function createInitialReviewState(document) {
@@ -22188,6 +22777,24 @@ function reduceReviewState(state, action) {
22188
22777
  revision: state.revision + 1
22189
22778
  };
22190
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
+ }
22191
22798
  case "projection/set": {
22192
22799
  if (projectionsEqual(state.projection, action.projection))
22193
22800
  return state;
@@ -22767,65 +23374,97 @@ async function currentBranchRef(runner) {
22767
23374
  const ref = (await output(runner, ["symbolic-ref", "--quiet", "HEAD"]))?.trim();
22768
23375
  return ref?.startsWith("refs/heads/") ? ref : undefined;
22769
23376
  }
22770
- async function symbolicDefault(runner, remote) {
22771
- const symbolic = (await output(runner, ["symbolic-ref", "--quiet", `refs/remotes/${remote}/HEAD`]))?.trim();
22772
- if (symbolic === undefined || !symbolic.startsWith(`refs/remotes/${remote}/`))
22773
- return;
22774
- const oid = await resolveRefOid(runner, symbolic);
22775
- return oid === undefined ? undefined : { ref: symbolic, oid };
22776
- }
22777
- async function remotes(runner) {
22778
- const raw = await output(runner, ["remote"]);
22779
- return (raw ?? "").split(/\r?\n/).map((remote) => remote.trim()).filter(Boolean);
22780
- }
22781
- async function reviewBaseCandidates(runner) {
22782
- const localRaw = await output(runner, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]);
22783
- const remoteRaw = await output(runner, ["for-each-ref", "--format=%(refname)", "refs/remotes"]);
22784
- const configuredRemotes = await remotes(runner);
22785
- const values = [
22786
- ...(localRaw ?? "").split(/\r?\n/),
22787
- ...(remoteRaw ?? "").split(/\r?\n/)
22788
- ].map((ref) => ref.trim()).filter((ref) => {
22789
- if (ref.length === 0)
22790
- return false;
22791
- if (!ref.startsWith("refs/remotes/"))
22792
- return true;
22793
- const remoteRef = ref.slice("refs/remotes/".length);
22794
- 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);
22795
23406
  });
22796
- return [...new Set(values)].sort();
22797
- }
22798
- async function candidates(runner) {
22799
- return reviewBaseCandidates(runner);
22800
- }
22801
- async function inferReviewBase(runner) {
22802
- const branchRef = await currentBranchRef(runner);
22803
- const allCandidates = await candidates(runner);
22804
- if (branchRef === undefined || await resolveRefOid(runner, "HEAD") === undefined) {
22805
- return { kind: "choose", candidates: allCandidates, reason: branchRef === undefined ? "HEAD is detached" : "HEAD has no commit" };
22806
- }
22807
- const remoteNames = await remotes(runner);
22808
- const upstream = (await output(runner, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]))?.trim();
22809
- const upstreamRemote = remoteNames.filter((remote) => upstream !== undefined && upstream.startsWith(`${remote}/`)).sort((left2, right2) => right2.length - left2.length)[0];
22810
- if (upstreamRemote !== undefined) {
22811
- const preferred = await symbolicDefault(runner, upstreamRemote);
22812
- if (preferred !== undefined) {
22813
- return { kind: "confident", ref: preferred.ref, oid: preferred.oid, reason: `remote default for upstream ${upstreamRemote}` };
22814
- }
22815
- }
22816
- if (remoteNames.includes("origin")) {
22817
- const preferred = await symbolicDefault(runner, "origin");
22818
- if (preferred !== undefined) {
22819
- return { kind: "confident", ref: preferred.ref, oid: preferred.oid, reason: "origin symbolic default" };
22820
- }
22821
- }
22822
- if (remoteNames.length === 1) {
22823
- const preferred = await symbolicDefault(runner, remoteNames[0]);
22824
- if (preferred !== undefined) {
22825
- 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";
22826
23453
  }
22827
- }
22828
- 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
+ };
22829
23468
  }
22830
23469
 
22831
23470
  // src/review/git/load-review-document.ts
@@ -23012,6 +23651,187 @@ async function loadReviewDocument(runner, baseRef) {
23012
23651
  return createReviewDocument({ identity, generation, commits: commitsForDoc, files });
23013
23652
  }
23014
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
+
23015
23835
  // src/review/git/load-source-context.ts
23016
23836
  var DEFAULT_MAX_BYTES = 1e6;
23017
23837
  function isZeroOid(oid) {
@@ -23354,12 +24174,12 @@ function persistedFromReviewState(state) {
23354
24174
  }
23355
24175
 
23356
24176
  // src/review/storage/review-artifact-store.ts
23357
- import { createHash as createHash4 } from "node:crypto";
24177
+ import { createHash as createHash5 } from "node:crypto";
23358
24178
  function artifactRelativePath(reviewId, artifactId) {
23359
24179
  return `githunk/reviews/${reviewId}/${artifactId}.json`;
23360
24180
  }
23361
24181
  function artifactDigest(text) {
23362
- return createHash4("sha256").update(text, "utf8").digest("hex");
24182
+ return createHash5("sha256").update(text, "utf8").digest("hex");
23363
24183
  }
23364
24184
  function artifactText(artifact) {
23365
24185
  return serializeReviewArtifactV1(artifact) + `
@@ -23598,6 +24418,7 @@ class ReviewWorkspaceController {
23598
24418
  stateStore;
23599
24419
  artifactStore;
23600
24420
  loadDocumentImpl;
24421
+ loadSinceLastReviewImpl;
23601
24422
  loadSourceContextImpl;
23602
24423
  nowImpl;
23603
24424
  randomIdImpl;
@@ -23610,14 +24431,18 @@ class ReviewWorkspaceController {
23610
24431
  activeReviewId;
23611
24432
  activeGenerationId;
23612
24433
  baseRef;
24434
+ _baseSelection;
24435
+ baseSelectionRequestId = 0;
23613
24436
  sourceContextCache = new Map;
23614
24437
  pendingGapRequests = new Map;
23615
24438
  gapRequestCounter = 0;
24439
+ aggregateDocument;
23616
24440
  constructor(options) {
23617
24441
  this.runner = options.runner;
23618
24442
  this.stateStore = options.stateStore;
23619
24443
  this.artifactStore = options.artifactStore;
23620
24444
  this.loadDocumentImpl = options.loadDocument ?? ((baseRef) => loadReviewDocument(options.runner, baseRef));
24445
+ this.loadSinceLastReviewImpl = options.loadSinceLastReview ?? ((aggregate, fromHeadOid) => loadSinceLastReviewProjection(options.runner, aggregate, fromHeadOid));
23621
24446
  this.loadSourceContextImpl = options.loadSourceContextImpl;
23622
24447
  this.nowImpl = options.now ?? (() => new Date().toISOString());
23623
24448
  this.randomIdImpl = options.randomId ?? (() => {
@@ -23643,6 +24468,78 @@ class ReviewWorkspaceController {
23643
24468
  get base() {
23644
24469
  return this.baseRef;
23645
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
+ }
23646
24543
  clearError() {
23647
24544
  if (this._error === undefined)
23648
24545
  return;
@@ -23651,7 +24548,7 @@ class ReviewWorkspaceController {
23651
24548
  }
23652
24549
  get refreshGeneration() {
23653
24550
  return async () => {
23654
- if (this.baseRef === undefined || this.destroyed)
24551
+ if (this.baseRef === undefined || this.destroyed || this._baseSelection !== undefined)
23655
24552
  return;
23656
24553
  const token = ++this.requestId;
23657
24554
  const capturedGeneration = this.activeGenerationId;
@@ -23695,10 +24592,12 @@ class ReviewWorkspaceController {
23695
24592
  this.sourceContextCache.clear();
23696
24593
  this.pendingGapRequests.clear();
23697
24594
  const currentState = this._state;
23698
- 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);
23699
24597
  if (!ownsRequest())
23700
24598
  return;
23701
24599
  this._state = nextState;
24600
+ this.aggregateDocument = undefined;
23702
24601
  this.activeReviewId = doc.identity.id;
23703
24602
  this.activeGenerationId = doc.generation.id;
23704
24603
  this._error = undefined;
@@ -23719,10 +24618,21 @@ class ReviewWorkspaceController {
23719
24618
  throw new Error("controller destroyed");
23720
24619
  const token = ++this.requestId;
23721
24620
  let resolvedBase = baseRef;
23722
- 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;
23723
24627
  if (resolvedBase === undefined) {
23724
24628
  try {
23725
- 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
+ }
23726
24636
  const warning = this.stateStore?.quarantineWarning;
23727
24637
  if (warning) {
23728
24638
  const pathMatch = warning.match(/moved to (\S+)/);
@@ -23738,6 +24648,8 @@ class ReviewWorkspaceController {
23738
24648
  }
23739
24649
  if (this.destroyed || token !== this.requestId)
23740
24650
  throw new Error("open cancelled");
24651
+ if (resolvedBase === undefined)
24652
+ throw new Error("base selection required");
23741
24653
  let doc;
23742
24654
  try {
23743
24655
  doc = await this.loadDocumentImpl(resolvedBase);
@@ -23762,7 +24674,20 @@ class ReviewWorkspaceController {
23762
24674
  const qPath = pathMatch?.[1] ?? warning;
23763
24675
  corruptError = createCorruptStateError(qPath, warning);
23764
24676
  }
23765
- 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
+ }
23766
24691
  if (persisted !== undefined) {
23767
24692
  const initial = createInitialReviewState(doc);
23768
24693
  const reconstructed = {
@@ -23807,7 +24732,7 @@ class ReviewWorkspaceController {
23807
24732
  }
23808
24733
  dispatch(action) {
23809
24734
  const current = this._state;
23810
- if (current === undefined)
24735
+ if (current === undefined || this._baseSelection !== undefined)
23811
24736
  return;
23812
24737
  const next = reduceReviewState(current, action);
23813
24738
  if (next === current)
@@ -23825,7 +24750,7 @@ class ReviewWorkspaceController {
23825
24750
  }
23826
24751
  dispatchIntent(intent) {
23827
24752
  const current = this._state;
23828
- if (current === undefined)
24753
+ if (current === undefined || this._baseSelection !== undefined)
23829
24754
  return false;
23830
24755
  try {
23831
24756
  const action = planReviewIntent(current, intent);
@@ -23835,6 +24760,50 @@ class ReviewWorkspaceController {
23835
24760
  return false;
23836
24761
  }
23837
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
+ }
23838
24807
  async flushDrafts() {
23839
24808
  if (!this.stateStore)
23840
24809
  return;
@@ -23868,7 +24837,7 @@ class ReviewWorkspaceController {
23868
24837
  artifactIdFromMarker = marker.artifactId;
23869
24838
  const raw = await this.artifactStore.readRaw(reviewId, marker.artifactId);
23870
24839
  if (raw !== undefined) {
23871
- const digest = createHash5("sha256").update(raw, "utf8").digest("hex");
24840
+ const digest = createHash6("sha256").update(raw, "utf8").digest("hex");
23872
24841
  if (digest === marker.digest) {
23873
24842
  const parsed = JSON.parse(raw);
23874
24843
  const res = parseReviewArtifactV1(parsed);
@@ -24083,6 +25052,8 @@ class ReviewWorkspaceController {
24083
25052
  this.destroyed = true;
24084
25053
  this.listeners.clear();
24085
25054
  this.requestId++;
25055
+ this.baseSelectionRequestId++;
25056
+ this._baseSelection = undefined;
24086
25057
  this.pendingGapRequests.clear();
24087
25058
  this.sourceContextCache.clear();
24088
25059
  if (this.activeReviewId && this.stateStore) {
@@ -24115,7 +25086,7 @@ class ReviewWorkspaceController {
24115
25086
  const submissionInProgress = db.reviews[reviewId]?.submissionInProgress ?? null;
24116
25087
  return {
24117
25088
  ...db,
24118
- baseByHead: { ...db.baseByHead, [headKey]: { baseRef: snapshot.document.identity.baseRef } },
25089
+ baseByHead: { ...db.baseByHead, [headKey]: { baseRef: snapshot.document.identity.baseRef, confirmed: true } },
24119
25090
  reviews: { ...db.reviews, [reviewId]: { ...persisted, submissionInProgress } }
24120
25091
  };
24121
25092
  });
@@ -24128,37 +25099,26 @@ class ReviewWorkspaceController {
24128
25099
  throw err;
24129
25100
  }
24130
25101
  }
24131
- async resolveBase() {
25102
+ async rememberedBase() {
24132
25103
  const headRef = await currentBranchRef(this.runner);
24133
25104
  const detachedOid = headRef === undefined ? await resolveRefOid(this.runner, "HEAD") : undefined;
24134
25105
  const headKey = headRef ?? (detachedOid ? `detached:${detachedOid}` : undefined);
24135
- if (headKey !== undefined && this.stateStore) {
24136
- try {
24137
- const db = await this.stateStore.load();
24138
- const warning = this.stateStore.quarantineWarning;
24139
- if (warning) {
24140
- const pathMatch = warning.match(/moved to (\S+)/);
24141
- const qPath = pathMatch?.[1] ?? warning;
24142
- this._error = createCorruptStateError(qPath, warning);
24143
- this.publish();
24144
- }
24145
- const remembered = db.baseByHead[headKey]?.baseRef;
24146
- if (remembered && await resolveRefOid(this.runner, remembered) !== undefined) {
24147
- return remembered;
24148
- }
24149
- } catch {}
24150
- }
25106
+ if (headKey === undefined || this.stateStore === undefined)
25107
+ return;
24151
25108
  try {
24152
- const inferred = await inferReviewBase(this.runner);
24153
- if (inferred.kind === "confident")
24154
- return inferred.ref;
24155
- const candidates2 = inferred.kind === "choose" ? inferred.candidates : await reviewBaseCandidates(this.runner);
24156
- if (candidates2.length > 0)
24157
- 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];
24158
25117
  } catch (err) {
24159
- throw classifyLoadError(err);
25118
+ this._error = createStorageError(err instanceof Error ? err.message : String(err));
25119
+ this.publish();
25120
+ return;
24160
25121
  }
24161
- return "refs/heads/main";
24162
25122
  }
24163
25123
  }
24164
25124
 
@@ -24892,6 +25852,64 @@ ${detail}
24892
25852
  if (false) {}
24893
25853
 
24894
25854
  // src/cli.ts
25855
+ var RELEASES_API = "https://api.github.com/repos/XuHaoJun/githunk/releases/latest";
25856
+ var DOWNLOAD_BASE = "https://github.com/XuHaoJun/githunk/releases/download";
25857
+ async function fetchText(url) {
25858
+ const response = await fetch(url);
25859
+ if (!response.ok)
25860
+ throw new Error(`request failed: ${url} (${response.status})`);
25861
+ return response.text();
25862
+ }
25863
+ async function fetchBytes(url) {
25864
+ const response = await fetch(url);
25865
+ if (!response.ok)
25866
+ throw new Error(`request failed: ${url} (${response.status})`);
25867
+ return new Uint8Array(await response.arrayBuffer());
25868
+ }
25869
+ function productionUpdateEnv() {
25870
+ return {
25871
+ executablePath: process.execPath,
25872
+ platform: process.platform,
25873
+ arch: process.arch,
25874
+ installedVersion: () => {
25875
+ const proc = spawnSync(process.execPath, ["--version"], { encoding: "utf8" });
25876
+ if (proc.status !== 0)
25877
+ throw new Error("could not read the installed version");
25878
+ return proc.stdout.trim();
25879
+ },
25880
+ fetchReleaseTag: async () => {
25881
+ const payload = JSON.parse(await fetchText(RELEASES_API));
25882
+ const tag = typeof payload === "object" && payload !== null && "tag_name" in payload ? payload.tag_name : undefined;
25883
+ if (typeof tag !== "string" || tag === "")
25884
+ throw new Error("could not read the newest release");
25885
+ return tag;
25886
+ },
25887
+ fetchAsset: async (tag, asset) => ({
25888
+ tarball: await fetchBytes(`${DOWNLOAD_BASE}/${tag}/${asset}`),
25889
+ checksums: await fetchText(`${DOWNLOAD_BASE}/${tag}/SHA256SUMS`)
25890
+ }),
25891
+ withTempDir: async (run) => {
25892
+ const dir = mkdtempSync(join6(tmpdir(), "githunk-update-"));
25893
+ try {
25894
+ return await run(dir);
25895
+ } finally {
25896
+ rmSync(dir, { recursive: true, force: true });
25897
+ }
25898
+ },
25899
+ writeFile: (path, data) => writeFile(path, data),
25900
+ extractTarball: async (archivePath, destDir) => {
25901
+ const proc = spawnSync("tar", ["-xzf", archivePath, "-C", destDir]);
25902
+ if (proc.status !== 0)
25903
+ throw new Error("could not extract the release archive (need tar on PATH)");
25904
+ },
25905
+ stagedBinary: (dir) => join6(dir, `githunk-${process.platform === "win32" ? "windows" : process.platform}-${process.arch === "arm64" ? "arm64" : "x64"}`, process.platform === "win32" ? "githunk.exe" : "githunk"),
25906
+ writeBinary: async (stagedPath, destPath) => {
25907
+ cpSync(stagedPath, `${destPath}.new`);
25908
+ chmodSync(`${destPath}.new`, 493);
25909
+ renameSync(`${destPath}.new`, destPath);
25910
+ }
25911
+ };
25912
+ }
24895
25913
  var result = parseCliArgs(process.argv.slice(2));
24896
25914
  if (result.kind === "help" || result.kind === "version") {
24897
25915
  process.stdout.write(result.text.endsWith(`
@@ -24903,6 +25921,13 @@ if (result.kind === "help" || result.kind === "version") {
24903
25921
  `) ? result.message : `${result.message}
24904
25922
  `);
24905
25923
  process.exitCode = result.exitCode;
25924
+ } else if (result.kind === "update") {
25925
+ const outcome = await runUpdate({ ...result.version === undefined ? {} : { version: result.version }, check: result.check }, productionUpdateEnv());
25926
+ const stream = outcome.exitCode === 0 ? process.stdout : process.stderr;
25927
+ stream.write(outcome.message.endsWith(`
25928
+ `) ? outcome.message : `${outcome.message}
25929
+ `);
25930
+ process.exitCode = outcome.exitCode;
24906
25931
  } else {
24907
25932
  process.exitCode = await startApp(result.startDirectory === undefined ? {} : { startDirectory: result.startDirectory });
24908
25933
  }