santree 0.5.0 → 0.5.2

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.
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useEffect, useReducer, useCallback, useRef, useState } from "react";
3
3
  import { Text, Box, useInput, useStdout, useApp } from "ink";
4
4
  import Spinner from "ink-spinner";
@@ -9,7 +9,7 @@ import * as fs from "fs";
9
9
  import * as path from "path";
10
10
  const require = createRequire(import.meta.url);
11
11
  const { version } = require("../../package.json");
12
- import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasInitScript, getInitScriptPath, removeWorktree, getDiffTool, } from "../lib/git.js";
12
+ import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasInitScript, getInitScriptPath, removeWorktree, getDiffTool, getWorktreeStatus, stageFile, unstageFile, stageAll, unstageAll, discardFile, } from "../lib/git.js";
13
13
  import { run, spawnAsync } from "../lib/exec.js";
14
14
  import { resolveAgentBinary } from "../lib/ai.js";
15
15
  import { getInstalledClaudeVersion } from "../lib/version.js";
@@ -28,7 +28,7 @@ import ReviewList from "../lib/dashboard/ReviewList.js";
28
28
  import ReviewDetailPanel, { buildReviewActions } from "../lib/dashboard/ReviewDetailPanel.js";
29
29
  import { CommitOverlay, PrCreateOverlay } from "../lib/dashboard/Overlays.js";
30
30
  import { MultilineTextArea } from "../lib/dashboard/MultilineTextArea.js";
31
- import DiffOverlay, { flattenTreeFiles, computeDiffLayout } from "../lib/dashboard/DiffOverlay.js";
31
+ import DiffOverlay, { flattenTreeFiles, computeDiffLayout, clampDiffLeftWidth, DIFF_DIVIDER_WIDTH, } from "../lib/dashboard/DiffOverlay.js";
32
32
  import { CURRENT_VERSION, CLAUDE_CODE_PACKAGE, getLatestVersion, getCachedLatestVersion, getLatestVersionFor, getCachedLatestVersionFor, isUpdateAvailable, } from "../lib/version.js";
33
33
  export const description = "Interactive dashboard of your Linear issues";
34
34
  const execAsync = promisify(exec);
@@ -193,11 +193,15 @@ function Tab({ active, label, mode }) {
193
193
  * Single-line global keymap shown at the bottom-left of the dashboard. The
194
194
  * `E workspace` hint only appears when the action is meaningful
195
195
  * (`SANTREE_EDITOR` is `code`/`cursor` and a `.code-workspace` file exists in
196
- * the repo root).
196
+ * the repo root). When the diff overlay is active, the keymap switches to
197
+ * diff-specific bindings since the global ones don't apply.
197
198
  */
198
- function CommandBar({ showWorkspace }) {
199
+ function CommandBar({ showWorkspace, mode = "default", }) {
199
200
  const dot = _jsx(Text, { dimColor: true, children: " · " });
200
201
  const Key = ({ k }) => (_jsx(Text, { color: "cyan", bold: true, children: k }));
202
+ if (mode === "diff") {
203
+ return (_jsxs(Text, { children: [_jsx(Key, { k: "j/k" }), _jsx(Text, { dimColor: true, children: " file" }), dot, _jsx(Key, { k: "\u21E7\u2191\u2193" }), _jsx(Text, { dimColor: true, children: " scroll" }), dot, _jsx(Key, { k: "\u2423" }), _jsx(Text, { dimColor: true, children: " stage" }), dot, _jsx(Key, { k: "a" }), _jsx(Text, { dimColor: true, children: " all" }), dot, _jsx(Key, { k: "d" }), _jsx(Text, { dimColor: true, children: " discard" }), dot, _jsx(Key, { k: "e" }), _jsx(Text, { dimColor: true, children: " edit" }), dot, _jsx(Key, { k: "q" }), _jsx(Text, { dimColor: true, children: " close" })] }));
204
+ }
201
205
  return (_jsxs(Text, { children: [_jsx(Key, { k: "j/k" }), _jsx(Text, { dimColor: true, children: " nav" }), dot, _jsx(Key, { k: "\u21E7\u2191\u2193" }), _jsx(Text, { dimColor: true, children: " scroll" }), dot, _jsx(Key, { k: "1/2" }), _jsx(Text, { dimColor: true, children: " tabs" }), showWorkspace ? (_jsxs(_Fragment, { children: [dot, _jsx(Key, { k: "E" }), _jsx(Text, { dimColor: true, children: " workspace" })] })) : null, dot, _jsx(Key, { k: "R" }), _jsx(Text, { dimColor: true, children: " refresh" }), dot, _jsx(Key, { k: "q" }), _jsx(Text, { dimColor: true, children: " quit" })] }));
202
206
  }
203
207
  // ── Component ─────────────────────────────────────────────────────────
@@ -218,7 +222,7 @@ export default function Dashboard() {
218
222
  const repoRootRef = useRef(null);
219
223
  const stateRef = useRef(state);
220
224
  stateRef.current = state;
221
- const draggingRef = useRef(false);
225
+ const draggingRef = useRef(null);
222
226
  const [termSize, setTermSize] = useState({
223
227
  columns: stdout?.columns ?? 80,
224
228
  rows: stdout?.rows ?? 24,
@@ -264,6 +268,13 @@ export default function Dashboard() {
264
268
  const leftWidthRef = useRef(leftWidth);
265
269
  leftWidthRef.current = leftWidth;
266
270
  const rightWidth = innerWidth - leftWidth - separatorWidth;
271
+ // Diff overlay's left pane width — null means "use the default formula"
272
+ // (computed inside computeDiffLayout). Becomes a number once the user drags
273
+ // the divider, and persists across overlay open/close while the dashboard
274
+ // session is alive.
275
+ const [diffLeftWidth, setDiffLeftWidth] = useState(null);
276
+ const diffLeftWidthRef = useRef(diffLeftWidth);
277
+ diffLeftWidthRef.current = diffLeftWidth;
267
278
  // Header (1) + tab strip (1) + 2 borders + command bar (1, inside box) = 5 rows
268
279
  const contentHeight = Math.max(3, rows - 5);
269
280
  const LIST_FOOTER_HEIGHT = 0;
@@ -333,11 +344,20 @@ export default function Dashboard() {
333
344
  const sepW = 3;
334
345
  // Release — stop dragging
335
346
  if (isRelease && draggingRef.current) {
336
- draggingRef.current = false;
347
+ draggingRef.current = null;
337
348
  return;
338
349
  }
339
350
  // Drag — resize if actively dragging
340
351
  if (isDrag && draggingRef.current) {
352
+ if (draggingRef.current === "diff") {
353
+ // DiffOverlay starts at abs col 2 with width=innerWidth; its
354
+ // 1-col divider sits at relative col (leftWidth+1) → abs col
355
+ // (leftWidth+2). Setting newLeft = col - 2 keeps it under the
356
+ // cursor; clampDiffLeftWidth enforces pane minimums.
357
+ const innerW = Math.max(40, cols - 2);
358
+ setDiffLeftWidth(clampDiffLeftWidth(col - 2, innerW));
359
+ return;
360
+ }
341
361
  // col is 1-based; outer border consumes col 1, so left pane spans cols 2..(lw+1).
342
362
  // Setting newLeft = col - 1 keeps the divider at the user's cursor.
343
363
  const newLeft = Math.max(minW, Math.min(col - 1, cols - 2 - sepW - minW));
@@ -361,12 +381,15 @@ export default function Dashboard() {
361
381
  files: s.diffFiles,
362
382
  fileIndex: s.diffFileIndex,
363
383
  fileScrollOffset: s.diffFileScrollOffset,
384
+ leftWidthOverride: diffLeftWidthRef.current ?? undefined,
364
385
  });
365
386
  // Body's first line is at absolute row 6 (title + tab + top border + overlay title + rule)
366
387
  const bodyRow = row - 6;
367
388
  if (bodyRow < 0 || bodyRow >= layout.bodyHeight)
368
389
  return;
369
- if (col <= layout.leftWidth) {
390
+ // DiffOverlay starts at abs col 2; left pane occupies abs cols
391
+ // 2..(leftWidth+1).
392
+ if (col <= layout.leftWidth + 1) {
370
393
  const maxIdx = s.diffFiles.length - 1;
371
394
  if (maxIdx < 0)
372
395
  return;
@@ -414,7 +437,7 @@ export default function Dashboard() {
414
437
  }
415
438
  if (!isPress)
416
439
  return;
417
- // Diff overlay click: select file row in left pane
440
+ // Diff overlay click: drag divider, or select file row in left pane
418
441
  {
419
442
  const s = stateRef.current;
420
443
  if (s.overlay === "diff") {
@@ -427,8 +450,17 @@ export default function Dashboard() {
427
450
  files: s.diffFiles,
428
451
  fileIndex: s.diffFileIndex,
429
452
  fileScrollOffset: s.diffFileScrollOffset,
453
+ leftWidthOverride: diffLeftWidthRef.current ?? undefined,
430
454
  });
431
- if (col > layout.leftWidth)
455
+ // Divider sits at abs col leftWidth+2 (DiffOverlay starts at
456
+ // abs col 2; divider at relative col leftWidth+1). Allow ±1
457
+ // tolerance — a 1-col target is hard to hit precisely.
458
+ const diffDivAbsCol = layout.leftWidth + 2;
459
+ if (col >= diffDivAbsCol - 1 && col <= diffDivAbsCol - 1 + DIFF_DIVIDER_WIDTH + 1) {
460
+ draggingRef.current = "diff";
461
+ return;
462
+ }
463
+ if (col > layout.leftWidth + 1)
432
464
  return;
433
465
  const bodyRow = row - 6;
434
466
  if (bodyRow < 0 || bodyRow >= layout.bodyHeight)
@@ -447,7 +479,7 @@ export default function Dashboard() {
447
479
  const divStart = lw + 2;
448
480
  const divEnd = lw + 1 + sepW;
449
481
  if (col >= divStart && col <= divEnd) {
450
- draggingRef.current = true;
482
+ draggingRef.current = "main";
451
483
  return;
452
484
  }
453
485
  // Left-click press: select item in left pane (cols 2..lw+1)
@@ -487,8 +519,16 @@ export default function Dashboard() {
487
519
  await refresh(true);
488
520
  };
489
521
  init();
490
- // Auto-refresh every 30s
491
- refreshTimerRef.current = setInterval(() => refresh(), 30_000);
522
+ // Auto-refresh every 30s. While the diff overlay is open, also bump
523
+ // the diff refresh tick so new/removed files (created or deleted
524
+ // outside the dashboard) eventually show up. Stage/unstage already
525
+ // patch XY in place, so this is purely about file-set drift.
526
+ refreshTimerRef.current = setInterval(() => {
527
+ refresh();
528
+ if (stateRef.current.overlay === "diff") {
529
+ dispatch({ type: "DIFF_REFRESH_FILES" });
530
+ }
531
+ }, 30_000);
492
532
  return () => {
493
533
  if (refreshTimerRef.current)
494
534
  clearInterval(refreshTimerRef.current);
@@ -554,17 +594,49 @@ export default function Dashboard() {
554
594
  useEffect(() => {
555
595
  if (state.overlay !== "diff" || !state.diffWorktreePath || !state.diffBaseBranch)
556
596
  return;
557
- if (!state.diffLoadingFiles)
558
- return;
559
597
  const cwd = state.diffWorktreePath;
560
598
  const base = state.diffBaseBranch;
561
599
  void (async () => {
562
600
  try {
563
601
  const { stdout: mergeBaseOut } = await execAsync(`git -C "${cwd}" merge-base "${base}" HEAD`);
564
602
  const mergeBase = mergeBaseOut.trim() || base;
565
- const { stdout } = await execAsync(`git -C "${cwd}" diff --name-status "${mergeBase}"`);
603
+ const [{ stdout }, porcelain] = await Promise.all([
604
+ execAsync(`git -C "${cwd}" diff --name-status "${mergeBase}"`),
605
+ getWorktreeStatus(cwd).catch(() => []),
606
+ ]);
566
607
  const files = parseNameStatus(stdout);
567
- const ordered = flattenTreeFiles(files);
608
+ // Merge porcelain (working-tree state) into the merge-base file list.
609
+ // XY status drives stage/unstage UX; untracked files (`??`) only show
610
+ // up here since `git diff` ignores them.
611
+ const porcelainByPath = new Map();
612
+ for (const p of porcelain)
613
+ porcelainByPath.set(p.path, p);
614
+ const enriched = files.map((f) => {
615
+ const p = porcelainByPath.get(f.path);
616
+ if (!p)
617
+ return f;
618
+ porcelainByPath.delete(f.path);
619
+ return {
620
+ ...f,
621
+ indexStatus: p.index,
622
+ workingStatus: p.working,
623
+ isUntracked: p.index === "?" && p.working === "?",
624
+ };
625
+ });
626
+ // Untracked entries left over → add as new DiffFile rows so they
627
+ // appear in the tree and can be staged.
628
+ for (const p of porcelainByPath.values()) {
629
+ if (p.index === "?" && p.working === "?") {
630
+ enriched.push({
631
+ path: p.path,
632
+ status: "?",
633
+ indexStatus: p.index,
634
+ workingStatus: p.working,
635
+ isUntracked: true,
636
+ });
637
+ }
638
+ }
639
+ const ordered = flattenTreeFiles(enriched);
568
640
  dispatch({ type: "DIFF_FILES_LOADED", files: ordered, mergeBase });
569
641
  }
570
642
  catch (err) {
@@ -572,7 +644,7 @@ export default function Dashboard() {
572
644
  dispatch({ type: "DIFF_FILES_ERROR", error: msg });
573
645
  }
574
646
  })();
575
- }, [state.overlay, state.diffWorktreePath, state.diffBaseBranch, state.diffLoadingFiles]);
647
+ }, [state.overlay, state.diffWorktreePath, state.diffBaseBranch, state.diffRefreshTick]);
576
648
  // ── Diff overlay: load content for selected file ──────────────────
577
649
  // If SANTREE_DIFF_TOOL is set, pipe `git diff` output through the tool so
578
650
  // the user's preferred renderer (delta, diff-so-fancy, etc.) handles
@@ -589,7 +661,15 @@ export default function Dashboard() {
589
661
  dispatch({ type: "DIFF_CONTENT_LOADING" });
590
662
  void (async () => {
591
663
  try {
592
- if (tool) {
664
+ if (file.isUntracked) {
665
+ // Untracked files aren't in `git diff` output — fake a "full
666
+ // addition" diff via --no-index against /dev/null. git exits 1
667
+ // when files differ; that's expected, so capture stdout via
668
+ // spawnAsync rather than execAsync (which throws on non-zero).
669
+ const { output } = await spawnAsync("git", ["-C", cwd, "diff", "--no-color", "--no-index", "--", "/dev/null", file.path], { cwd });
670
+ dispatch({ type: "DIFF_CONTENT_LOADED", content: output });
671
+ }
672
+ else if (tool) {
593
673
  // Pipe git diff (with colors enabled so the tool can pass them
594
674
  // through if desired) into the configured tool. Use spawn pipes
595
675
  // rather than shell to avoid quoting concerns.
@@ -1275,6 +1355,42 @@ export default function Dashboard() {
1275
1355
  }
1276
1356
  // Diff overlay
1277
1357
  if (state.overlay === "diff") {
1358
+ // Pending discard modal — intercepts y/n/ESC/q so they don't
1359
+ // also close the diff overlay.
1360
+ if (state.diffPendingDiscard) {
1361
+ const pd = state.diffPendingDiscard;
1362
+ if (input === "y") {
1363
+ const cwd = state.diffWorktreePath;
1364
+ if (!cwd) {
1365
+ dispatch({ type: "DIFF_DISCARD_CANCEL" });
1366
+ return;
1367
+ }
1368
+ (async () => {
1369
+ try {
1370
+ await discardFile(cwd, pd.path, pd.isUntracked);
1371
+ dispatch({ type: "DIFF_DISCARD_CANCEL" });
1372
+ dispatch({ type: "DIFF_REFRESH_FILES" });
1373
+ dispatch({
1374
+ type: "SET_ACTION_MESSAGE",
1375
+ message: pd.isUntracked
1376
+ ? `Deleted ${pd.path}`
1377
+ : `Discarded changes in ${pd.path}`,
1378
+ });
1379
+ }
1380
+ catch (err) {
1381
+ const msg = err instanceof Error ? err.message : String(err);
1382
+ dispatch({ type: "DIFF_DISCARD_CANCEL" });
1383
+ dispatch({ type: "SET_ACTION_MESSAGE", message: `Discard failed: ${msg}` });
1384
+ }
1385
+ })();
1386
+ return;
1387
+ }
1388
+ if (input === "n" || key.escape || input === "q") {
1389
+ dispatch({ type: "DIFF_DISCARD_CANCEL" });
1390
+ return;
1391
+ }
1392
+ return;
1393
+ }
1278
1394
  if (key.escape || input === "q") {
1279
1395
  dispatch({ type: "DIFF_CLOSE" });
1280
1396
  return;
@@ -1292,6 +1408,7 @@ export default function Dashboard() {
1292
1408
  files: state.diffFiles,
1293
1409
  fileIndex: state.diffFileIndex,
1294
1410
  fileScrollOffset: state.diffFileScrollOffset,
1411
+ leftWidthOverride: diffLeftWidth ?? undefined,
1295
1412
  });
1296
1413
  const totalLines = state.diffContent ? state.diffContent.split("\n").length : 0;
1297
1414
  const maxScroll = Math.max(0, totalLines - layout.bodyHeight);
@@ -1329,6 +1446,98 @@ export default function Dashboard() {
1329
1446
  dispatch({ type: "DIFF_FILE_SELECT", index: prev });
1330
1447
  return;
1331
1448
  }
1449
+ // Stage / unstage / discard — only meaningful when the worktree
1450
+ // path is known. All ops dispatch DIFF_REFRESH_FILES so the
1451
+ // porcelain status (and selection) updates immediately.
1452
+ const cwd = state.diffWorktreePath;
1453
+ const currentFile = state.diffFiles[state.diffFileIndex];
1454
+ if (input === " " && cwd && currentFile) {
1455
+ // Toggle: if anything is staged for this file, unstage it;
1456
+ // otherwise stage. Files with no uncommitted state (only
1457
+ // committed changes vs base) have no XY → no-op. Updates XY
1458
+ // in place via porcelain re-fetch — no full reload, no spinner.
1459
+ const xRaw = currentFile.indexStatus;
1460
+ const yRaw = currentFile.workingStatus;
1461
+ if (xRaw === undefined && yRaw === undefined) {
1462
+ dispatch({
1463
+ type: "SET_ACTION_MESSAGE",
1464
+ message: "No uncommitted changes to stage on this file",
1465
+ });
1466
+ return;
1467
+ }
1468
+ const x = xRaw ?? " ";
1469
+ const isStaged = x !== " " && x !== "?";
1470
+ const path = currentFile.path;
1471
+ (async () => {
1472
+ try {
1473
+ if (isStaged)
1474
+ await unstageFile(cwd, path);
1475
+ else
1476
+ await stageFile(cwd, path);
1477
+ const porcelain = await getWorktreeStatus(cwd);
1478
+ dispatch({ type: "DIFF_STATUS_UPDATED", porcelain });
1479
+ }
1480
+ catch (err) {
1481
+ const msg = err instanceof Error ? err.message : String(err);
1482
+ dispatch({
1483
+ type: "SET_ACTION_MESSAGE",
1484
+ message: `${isStaged ? "Unstage" : "Stage"} failed: ${msg}`,
1485
+ });
1486
+ }
1487
+ })();
1488
+ return;
1489
+ }
1490
+ if (input === "a" && cwd) {
1491
+ // Stage-all if anything is unstaged or untracked; otherwise
1492
+ // unstage everything. Untracked files have Y === "?" so they
1493
+ // fall under "unstaged" — staging them adds them to the index.
1494
+ // Same in-place porcelain refresh as `space`.
1495
+ const anyUnstaged = state.diffFiles.some((f) => {
1496
+ const y = f.workingStatus;
1497
+ return y !== undefined && y !== " ";
1498
+ });
1499
+ (async () => {
1500
+ try {
1501
+ if (anyUnstaged)
1502
+ await stageAll(cwd);
1503
+ else
1504
+ await unstageAll(cwd);
1505
+ const porcelain = await getWorktreeStatus(cwd);
1506
+ dispatch({ type: "DIFF_STATUS_UPDATED", porcelain });
1507
+ dispatch({
1508
+ type: "SET_ACTION_MESSAGE",
1509
+ message: anyUnstaged ? "Staged all changes" : "Unstaged all changes",
1510
+ });
1511
+ }
1512
+ catch (err) {
1513
+ const msg = err instanceof Error ? err.message : String(err);
1514
+ dispatch({ type: "SET_ACTION_MESSAGE", message: `Failed: ${msg}` });
1515
+ }
1516
+ })();
1517
+ return;
1518
+ }
1519
+ if (input === "d" && currentFile) {
1520
+ if (currentFile.indexStatus === undefined && currentFile.workingStatus === undefined) {
1521
+ dispatch({
1522
+ type: "SET_ACTION_MESSAGE",
1523
+ message: "No uncommitted changes to discard",
1524
+ });
1525
+ return;
1526
+ }
1527
+ dispatch({
1528
+ type: "DIFF_DISCARD_OPEN",
1529
+ path: currentFile.path,
1530
+ isUntracked: !!currentFile.isUntracked,
1531
+ });
1532
+ return;
1533
+ }
1534
+ // Open the selected file in the user's editor — useful when
1535
+ // the diff alone isn't enough context. Editor resolution
1536
+ // matches the rest of santree (SANTREE_EDITOR > "code").
1537
+ if (input === "e" && cwd && currentFile) {
1538
+ openInEditor(path.join(cwd, currentFile.path));
1539
+ return;
1540
+ }
1332
1541
  return;
1333
1542
  }
1334
1543
  // Confirm delete overlay
@@ -1851,17 +2060,22 @@ export default function Dashboard() {
1851
2060
  const defaultBranch = getDefaultBranch();
1852
2061
  const label = branch === defaultBranch ? `${branch} (default)` : branch;
1853
2062
  return (_jsx(Text, { children: _jsxs(Text, { color: selected ? "cyan" : undefined, bold: selected, children: [selected ? "> " : " ", label] }) }, branch));
1854
- }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "j/k to navigate, Enter to select, ESC to cancel" })] }) })) : state.overlay === "confirm-delete" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Remove worktree?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: selectedIssue?.worktree?.branch ?? "" }), selectedIssue?.worktree?.dirty && (_jsx(Text, { color: "yellow", children: "Warning: worktree has uncommitted changes" })), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: "y" }), " Confirm"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "n" }), " Cancel"] })] }) })) : state.overlay === "diff" ? (_jsx(DiffOverlay, { width: innerWidth, height: contentHeight, ticketId: state.diffTicketId ?? "", baseBranch: state.diffBaseBranch ?? "", files: state.diffFiles, fileIndex: state.diffFileIndex, fileScrollOffset: state.diffFileScrollOffset, content: state.diffContent, contentScrollOffset: state.diffContentScrollOffset, loadingFiles: state.diffLoadingFiles, loadingContent: state.diffLoadingContent, error: state.diffError, selectionBg: theme.selectionBg })) : state.overlay === "confirm-setup" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Run setup script?" }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: ".santree/init.sh" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " Run setup"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", bold: true, children: "n" }), " Skip"] })] }) })) : (_jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: leftWidth, children: state.activeTab === "reviews" ? (_jsx(ReviewList, { flatReviews: state.flatReviews, selectedIndex: state.reviewSelectedIndex, scrollOffset: state.reviewListScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg })) : state.flatIssues.length === 0 ? (_jsx(Box, { width: leftWidth, height: contentHeight, justifyContent: "center", alignItems: "center", children: _jsx(Text, { color: "yellow", children: "No active issues" }) })) : (_jsx(IssueList, { groups: state.groups, flatIssues: state.flatIssues, selectedIndex: state.selectedIndex, scrollOffset: state.listScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg })) }), _jsx(Box, { flexDirection: "column", width: 3, children: Array.from({ length: contentHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: " │ " }, i))) }), _jsx(Box, { width: rightWidth, children: state.activeTab === "reviews" && state.creatingForTicket ? (_jsxs(Box, { flexDirection: "column", width: rightWidth, height: contentHeight, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Setting up worktree for ", state.creatingForTicket, "..."] }), state.creationLogs
2063
+ }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "j/k to navigate, Enter to select, ESC to cancel" })] }) })) : state.overlay === "confirm-delete" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Remove worktree?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: selectedIssue?.worktree?.branch ?? "" }), selectedIssue?.worktree?.dirty && (_jsx(Text, { color: "yellow", children: "Warning: worktree has uncommitted changes" })), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: "y" }), " Confirm"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "n" }), " Cancel"] })] }) })) : state.overlay === "diff" ? (_jsx(DiffOverlay, { width: innerWidth, height: contentHeight, ticketId: state.diffTicketId ?? "", baseBranch: state.diffBaseBranch ?? "", files: state.diffFiles, fileIndex: state.diffFileIndex, fileScrollOffset: state.diffFileScrollOffset, content: state.diffContent, contentScrollOffset: state.diffContentScrollOffset, loadingFiles: state.diffLoadingFiles, loadingContent: state.diffLoadingContent, error: state.diffError, selectionBg: theme.selectionBg, leftWidthOverride: diffLeftWidth ?? undefined, pendingDiscard: state.diffPendingDiscard })) : state.overlay === "confirm-setup" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Run setup script?" }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: ".santree/init.sh" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " Run setup"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", bold: true, children: "n" }), " Skip"] })] }) })) : (_jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: leftWidth, children: state.activeTab === "reviews" ? (_jsx(ReviewList, { flatReviews: state.flatReviews, selectedIndex: state.reviewSelectedIndex, scrollOffset: state.reviewListScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg })) : state.flatIssues.length === 0 ? (_jsx(Box, { width: leftWidth, height: contentHeight, justifyContent: "center", alignItems: "center", children: _jsx(Text, { color: "yellow", children: "No active issues" }) })) : (_jsx(IssueList, { groups: state.groups, flatIssues: state.flatIssues, selectedIndex: state.selectedIndex, scrollOffset: state.listScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg })) }), _jsx(Box, { flexDirection: "column", width: 3, children: Array.from({ length: contentHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: " │ " }, i))) }), _jsx(Box, { width: rightWidth, children: state.activeTab === "reviews" && state.creatingForTicket ? (_jsxs(Box, { flexDirection: "column", width: rightWidth, height: contentHeight, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Setting up worktree for ", state.creatingForTicket, "..."] }), state.creationLogs
1855
2064
  .split("\n")
1856
2065
  .slice(-(contentHeight - 1))
1857
- .map((line, i) => (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: line }) }, i)))] })) : state.activeTab === "reviews" ? (_jsx(ReviewDetailPanel, { item: selectedReview, scrollOffset: state.reviewDetailScrollOffset, height: contentHeight, width: rightWidth })) : state.overlay === "commit" ? (_jsx(CommitOverlay, { width: rightWidth, height: contentHeight, branch: state.commitBranch, ticketId: state.commitTicketId, gitStatus: state.commitGitStatus, phase: state.commitPhase, message: state.commitMessage, error: state.commitError, dispatch: dispatch, onSubmit: handleCommitSubmit })) : state.overlay === "pr-create" ? (_jsx(PrCreateOverlay, { width: rightWidth, height: contentHeight, branch: state.prCreateBranch, ticketId: state.prCreateTicketId, phase: state.prCreatePhase, error: state.prCreateError, url: state.prCreateUrl, body: state.prCreateBody, title: state.prCreateTitle, dispatch: dispatch })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] })), _jsxs(Box, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview }) })] })] })] }));
2066
+ .map((line, i) => (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: line }) }, i)))] })) : state.activeTab === "reviews" ? (_jsx(ReviewDetailPanel, { item: selectedReview, scrollOffset: state.reviewDetailScrollOffset, height: contentHeight, width: rightWidth })) : state.overlay === "commit" ? (_jsx(CommitOverlay, { width: rightWidth, height: contentHeight, branch: state.commitBranch, ticketId: state.commitTicketId, gitStatus: state.commitGitStatus, phase: state.commitPhase, message: state.commitMessage, error: state.commitError, dispatch: dispatch, onSubmit: handleCommitSubmit })) : state.overlay === "pr-create" ? (_jsx(PrCreateOverlay, { width: rightWidth, height: contentHeight, branch: state.prCreateBranch, ticketId: state.prCreateTicketId, phase: state.prCreatePhase, error: state.prCreateError, url: state.prCreateUrl, body: state.prCreateBody, title: state.prCreateTitle, dispatch: dispatch })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] })), _jsx(Box, { children: state.overlay === "diff" ? (_jsx(Box, { width: innerWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "diff" }) })) : (_jsxs(_Fragment, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: "default" }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview, overlay: state.overlay }) })] })) })] })] }));
1858
2067
  }
1859
2068
  /**
1860
2069
  * Renders the per-issue action key hints (Resume / Editor / View diff / …)
1861
2070
  * lifted out of the detail panels so they sit on the same row as the global
1862
2071
  * command bar. Empty when nothing is selected.
1863
2072
  */
1864
- function ActionRow({ activeTab, selectedIssue, selectedReview, }) {
2073
+ function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, }) {
2074
+ // During the diff overlay, none of the per-issue actions apply (View diff
2075
+ // is what got us here, Commit/PR/etc. need the detail panel context). Keep
2076
+ // the row blank so the diff-specific CommandBar reads cleanly.
2077
+ if (overlay === "diff")
2078
+ return _jsx(Text, { children: " " });
1865
2079
  const items = activeTab === "reviews"
1866
2080
  ? selectedReview
1867
2081
  ? buildReviewActions(selectedReview)
@@ -14,6 +14,19 @@ interface Props {
14
14
  error: string | null;
15
15
  /** Theme-adapted selection background. Falls back to dark navy. */
16
16
  selectionBg?: string;
17
+ /**
18
+ * User-set left pane width (from divider drag). Falls back to the default
19
+ * formula when undefined. Always clamped against pane minimums.
20
+ */
21
+ leftWidthOverride?: number;
22
+ /**
23
+ * When non-null, render a confirmation modal over the body asking the user
24
+ * to confirm discarding tracked changes or deleting an untracked file.
25
+ */
26
+ pendingDiscard?: {
27
+ path: string;
28
+ isUntracked: boolean;
29
+ } | null;
17
30
  }
18
31
  interface RenderedRow {
19
32
  prefix: string;
@@ -22,6 +35,10 @@ interface RenderedRow {
22
35
  dim?: boolean;
23
36
  bold?: boolean;
24
37
  fileIndex: number | null;
38
+ xy?: {
39
+ index: string;
40
+ working: string;
41
+ };
25
42
  }
26
43
  export declare function flattenTreeFiles(files: DiffFile[]): DiffFile[];
27
44
  export interface DiffLayout {
@@ -39,12 +56,18 @@ export interface DiffLayout {
39
56
  * Shared between DiffOverlay (rendering) and the dashboard mouse handler
40
57
  * (mapping click coords back to file indices).
41
58
  */
59
+ export declare const DIFF_LEFT_MIN = 20;
60
+ export declare const DIFF_RIGHT_MIN = 20;
61
+ export declare const DIFF_DIVIDER_WIDTH = 1;
62
+ export declare function defaultDiffLeftWidth(width: number): number;
63
+ export declare function clampDiffLeftWidth(leftWidth: number, width: number): number;
42
64
  export declare function computeDiffLayout(opts: {
43
65
  width: number;
44
66
  height: number;
45
67
  files: DiffFile[];
46
68
  fileIndex: number;
47
69
  fileScrollOffset: number;
70
+ leftWidthOverride?: number;
48
71
  }): DiffLayout;
49
- export default function DiffOverlay({ width, height, ticketId, baseBranch, files, fileIndex, fileScrollOffset, content, contentScrollOffset, loadingFiles, loadingContent, error, selectionBg, }: Props): import("react/jsx-runtime").JSX.Element;
72
+ export default function DiffOverlay({ width, height, ticketId, baseBranch, files, fileIndex, fileScrollOffset, content, contentScrollOffset, loadingFiles, loadingContent, error, selectionBg, leftWidthOverride, pendingDiscard, }: Props): import("react/jsx-runtime").JSX.Element;
50
73
  export {};
@@ -75,12 +75,37 @@ function renderTree(dir, depth, rows, fileCounter) {
75
75
  }
76
76
  else {
77
77
  const idx = fileCounter.value++;
78
- rows.push({
79
- prefix: indent,
80
- label: `${child.file.status} ${child.name}`,
81
- color: statusColor(child.file.status),
82
- fileIndex: idx,
83
- });
78
+ const file = child.file;
79
+ const hasUncommitted = file.indexStatus !== undefined || file.workingStatus !== undefined;
80
+ if (hasUncommitted) {
81
+ // Lazygit-style XY display — XY conveys the staged/unstaged state,
82
+ // so we drop the merge-base status letter from the label to avoid
83
+ // redundant "M M foo.ts"-style rows. The XY chars are colored at
84
+ // render time (green for index, red for working).
85
+ rows.push({
86
+ prefix: indent,
87
+ label: child.name,
88
+ fileIndex: idx,
89
+ xy: {
90
+ index: file.indexStatus ?? " ",
91
+ working: file.workingStatus ?? " ",
92
+ },
93
+ });
94
+ }
95
+ else {
96
+ // Committed-only files (no working-tree state vs HEAD). Dimmed
97
+ // so the user can tell at a glance that stage/unstage/discard
98
+ // don't apply — only files showing a colored XY column are
99
+ // actionable. The merge-base status letter still tells the
100
+ // reviewer what changed vs base.
101
+ rows.push({
102
+ prefix: indent,
103
+ label: `${file.status} ${child.name}`,
104
+ color: statusColor(file.status),
105
+ dim: true,
106
+ fileIndex: idx,
107
+ });
108
+ }
84
109
  }
85
110
  }
86
111
  }
@@ -112,12 +137,24 @@ export function flattenTreeFiles(files) {
112
137
  * Shared between DiffOverlay (rendering) and the dashboard mouse handler
113
138
  * (mapping click coords back to file indices).
114
139
  */
140
+ export const DIFF_LEFT_MIN = 20;
141
+ export const DIFF_RIGHT_MIN = 20;
142
+ export const DIFF_DIVIDER_WIDTH = 1;
143
+ export function defaultDiffLeftWidth(width) {
144
+ return Math.min(48, Math.max(24, Math.floor(width * 0.32)));
145
+ }
146
+ export function clampDiffLeftWidth(leftWidth, width) {
147
+ const max = Math.max(DIFF_LEFT_MIN, width - DIFF_DIVIDER_WIDTH - DIFF_RIGHT_MIN);
148
+ return Math.max(DIFF_LEFT_MIN, Math.min(leftWidth, max));
149
+ }
115
150
  export function computeDiffLayout(opts) {
116
151
  const headerHeight = 2;
117
- const footerHeight = 1;
118
- const bodyHeight = Math.max(3, opts.height - headerHeight - footerHeight);
119
- const leftWidth = Math.min(48, Math.max(24, Math.floor(opts.width * 0.32)));
120
- const rightWidth = Math.max(20, opts.width - leftWidth - 1);
152
+ // Keymap footer lives in the dashboard's global CommandBar — don't reserve
153
+ // a row here or we'd render two stacked keymap rows.
154
+ const bodyHeight = Math.max(3, opts.height - headerHeight);
155
+ const requestedLeft = opts.leftWidthOverride ?? defaultDiffLeftWidth(opts.width);
156
+ const leftWidth = clampDiffLeftWidth(requestedLeft, opts.width);
157
+ const rightWidth = Math.max(DIFF_RIGHT_MIN, opts.width - leftWidth - DIFF_DIVIDER_WIDTH);
121
158
  const rows = [];
122
159
  const tree = buildTree(opts.files);
123
160
  renderTree(tree, 0, rows, { value: 0 });
@@ -194,8 +231,15 @@ function truncateVisible(s, max) {
194
231
  return out + "…";
195
232
  }
196
233
  // ── Component ─────────────────────────────────────────────────────────
197
- export default function DiffOverlay({ width, height, ticketId, baseBranch, files, fileIndex, fileScrollOffset, content, contentScrollOffset, loadingFiles, loadingContent, error, selectionBg = "#1e3a5f", }) {
198
- const layout = computeDiffLayout({ width, height, files, fileIndex, fileScrollOffset });
234
+ export default function DiffOverlay({ width, height, ticketId, baseBranch, files, fileIndex, fileScrollOffset, content, contentScrollOffset, loadingFiles, loadingContent, error, selectionBg = "#1e3a5f", leftWidthOverride, pendingDiscard = null, }) {
235
+ const layout = computeDiffLayout({
236
+ width,
237
+ height,
238
+ files,
239
+ fileIndex,
240
+ fileScrollOffset,
241
+ leftWidthOverride,
242
+ });
199
243
  const { bodyHeight, leftWidth, rightWidth, rows, effectiveScroll, selectedRowIdx } = layout;
200
244
  const visibleRows = rows.slice(effectiveScroll, effectiveScroll + bodyHeight);
201
245
  // Right pane: split content into lines and slice for scroll. If the content
@@ -226,18 +270,30 @@ export default function DiffOverlay({ width, height, ticketId, baseBranch, files
226
270
  const p = currentFile.path;
227
271
  truncatedPath = p.length > pathRoom ? "…" + p.slice(-Math.max(0, pathRoom - 1)) : p;
228
272
  }
229
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, overflow: "hidden", children: [_jsxs(Box, { flexShrink: 0, width: width, children: [_jsx(Text, { bold: true, color: "cyan", children: "Diff" }), _jsx(Text, { dimColor: true, children: meta }), currentFile && pathRoom > 0 && _jsx(Text, { dimColor: true, children: sep }), currentFile && pathRoom > 0 && _jsx(Text, { children: truncatedPath })] }), _jsx(Box, { flexShrink: 0, width: width, children: _jsx(Text, { dimColor: true, wrap: "truncate", children: "─".repeat(width) }) }), _jsxs(Box, { height: bodyHeight, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", width: leftWidth, height: bodyHeight, overflow: "hidden", paddingRight: 1, children: loadingFiles ? (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " Loading files..." })] })) : error ? (_jsx(Text, { color: "red", children: error })) : files.length === 0 ? (_jsx(Text, { dimColor: true, children: "No changes" })) : (visibleRows.map((row, i) => {
273
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, overflow: "hidden", children: [_jsxs(Box, { flexShrink: 0, width: width, children: [_jsx(Text, { bold: true, color: "cyan", children: "Diff" }), _jsx(Text, { dimColor: true, children: meta }), currentFile && pathRoom > 0 && _jsx(Text, { dimColor: true, children: sep }), currentFile && pathRoom > 0 && _jsx(Text, { children: truncatedPath })] }), _jsx(Box, { flexShrink: 0, width: width, children: _jsx(Text, { dimColor: true, wrap: "truncate", children: "─".repeat(width) }) }), pendingDiscard ? (_jsx(Box, { height: bodyHeight, flexShrink: 0, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: pendingDiscard.isUntracked ? "Delete file?" : "Discard changes?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: pendingDiscard.path }), pendingDiscard.isUntracked && (_jsx(Text, { color: "yellow", children: "Warning: untracked file will be permanently deleted" })), !pendingDiscard.isUntracked && (_jsx(Text, { color: "yellow", children: "Warning: uncommitted changes will be lost" })), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: "y" }), " Confirm"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "n" }), " Cancel"] })] }) })) : (_jsxs(Box, { height: bodyHeight, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", width: leftWidth, height: bodyHeight, overflow: "hidden", paddingRight: 1, children: loadingFiles ? (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " Loading files..." })] })) : error ? (_jsx(Text, { color: "red", children: error })) : files.length === 0 ? (_jsx(Text, { dimColor: true, children: "No changes" })) : (visibleRows.map((row, i) => {
230
274
  const absIdx = effectiveScroll + i;
231
275
  const isSelected = absIdx === selectedRowIdx;
276
+ const bg = isSelected ? selectionBg : undefined;
277
+ // Lazygit-style XY rendering — index char (green) + working
278
+ // char (red), then a separator and the file name. Each
279
+ // nested <Text> gets the same backgroundColor so the
280
+ // selection highlight covers the whole row uniformly.
281
+ if (row.xy) {
282
+ const x = row.xy.index || " ";
283
+ const y = row.xy.working || " ";
284
+ const xColor = x.trim() ? "green" : "gray";
285
+ const yColor = y.trim() ? "red" : "gray";
286
+ return (_jsxs(Text, { backgroundColor: bg, bold: row.bold || isSelected, wrap: "truncate", children: [_jsx(Text, { backgroundColor: bg, children: row.prefix }), _jsx(Text, { color: xColor, backgroundColor: bg, bold: true, children: x }), _jsx(Text, { color: yColor, backgroundColor: bg, bold: true, children: y }), _jsx(Text, { backgroundColor: bg, children: ` ${row.label}` })] }, i));
287
+ }
232
288
  const text = `${row.prefix}${row.label}`;
233
289
  // Selected row keeps its own color (file-status hue or directory
234
290
  // blue) but gets the theme-aware selection bg + bold so it stays
235
291
  // readable in light and dark modes alike.
236
- return (_jsx(Text, { color: row.color, backgroundColor: isSelected ? selectionBg : undefined, bold: row.bold || isSelected, dimColor: row.dim, wrap: "truncate", children: text }, i));
292
+ return (_jsx(Text, { color: row.color, backgroundColor: bg, bold: row.bold || isSelected, dimColor: row.dim, wrap: "truncate", children: text }, i));
237
293
  })) }), _jsx(Box, { flexDirection: "column", height: bodyHeight, children: Array.from({ length: bodyHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: "\u2502" }, i))) }), _jsx(Box, { flexDirection: "column", width: rightWidth, height: bodyHeight, overflow: "hidden", paddingLeft: 1, children: loadingContent ? (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " Loading diff..." })] })) : !currentFile ? (_jsx(Text, { dimColor: true, children: "Select a file" })) : visibleLines.length === 0 ? (_jsx(Text, { dimColor: true, children: "(empty diff)" })) : (visibleLines.map((line, i) => {
238
294
  // rightWidth includes the paddingLeft={1} of the wrapper Box,
239
295
  // so usable column count is rightWidth - 1.
240
296
  const cell = truncateVisible(line.text || " ", Math.max(1, rightWidth - 1));
241
297
  return (_jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, children: cell }, i));
242
- })) })] }), _jsx(Box, { flexShrink: 0, width: width, children: _jsx(Text, { dimColor: true, wrap: "truncate", children: "j/k file \u2022 J/K scroll \u2022 g/G top/bot \u2022 click select \u2022 wheel scroll \u2022 q close" }) })] }));
298
+ })) })] }))] }));
243
299
  }
@@ -9,20 +9,20 @@ function checksIndicator(checks) {
9
9
  return { text: "\u2713", color: "green" };
10
10
  return { text: "\u25cf", color: "yellow" };
11
11
  }
12
- const FOOTER_HEIGHT = 2;
13
12
  const HEADER_ROWS = 1;
14
13
  export function getReviewListRowCount(flatReviews) {
15
14
  return HEADER_ROWS + flatReviews.length;
16
15
  }
17
16
  export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, height, width, selectionBg = "#1e3a5f", }) {
18
- const listHeight = height - FOOTER_HEIGHT;
17
+ // Keymap footer lives in the dashboard's global CommandBar \u2014 use the full
18
+ // pane height for the list so we don't render two stacked keymap rows.
19
+ const listHeight = height;
19
20
  const numColWidth = 6;
20
21
  const authorColWidth = 12;
21
22
  const changesColWidth = 10;
22
23
  const checksColWidth = 2;
23
24
  const fixedWidth = 2 + numColWidth + 1 + authorColWidth + 1 + changesColWidth + 1 + checksColWidth;
24
25
  const titleMaxWidth = Math.max(width - fixedWidth, 10);
25
- const footerRule = "\u2500".repeat(width);
26
26
  const totalRows = HEADER_ROWS + flatReviews.length;
27
27
  const visibleStart = scrollOffset;
28
28
  const visibleEnd = Math.min(visibleStart + listHeight, totalRows);
@@ -49,5 +49,5 @@ export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, h
49
49
  const bg = selected ? selectionBg : undefined;
50
50
  rows.push(_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg, bold: selected, children: [cursor, " "] }), _jsx(Text, { backgroundColor: bg, color: pr.isDraft ? "gray" : "green", children: num.padEnd(numColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, bold: selected, children: title.padEnd(titleMaxWidth) }), _jsx(Text, { backgroundColor: bg, dimColor: true, children: author.padStart(authorColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsxs(Text, { backgroundColor: bg, children: [_jsx(Text, { color: "green", children: `+${item.additions}` }), _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { color: "red", children: `-${item.deletions}` }), "".padStart(Math.max(0, changesColWidth - changes.length))] }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (ci.color === "gray" ? "gray" : ci.color) : ci.color, children: ci.text.padStart(checksColWidth) })] }, `${pr.number}`));
51
51
  }
52
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Box, { flexDirection: "column", height: listHeight, children: rows }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: footerRule }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { color: "white", children: " Navigate" }), " ", _jsxs(Text, { color: "cyan", bold: true, children: ["Shift + ", "\u2191\u2193"] }), _jsx(Text, { color: "white", children: " Scroll detail" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "o" }), _jsx(Text, { color: "white", children: " Open PR" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "Tab" }), _jsx(Text, { color: "white", children: " Issues" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { color: "white", children: " Quit" })] })] })] }));
52
+ return (_jsx(Box, { flexDirection: "column", width: width, height: height, children: _jsx(Box, { flexDirection: "column", height: listHeight, children: rows }) }));
53
53
  }
@@ -69,6 +69,9 @@ export interface DiffFile {
69
69
  path: string;
70
70
  status: DiffFileStatus;
71
71
  oldPath?: string;
72
+ indexStatus?: string;
73
+ workingStatus?: string;
74
+ isUntracked?: boolean;
72
75
  }
73
76
  export type CommitPhase = "idle" | "confirm-stage" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
74
77
  export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "filling" | "review" | "confirm" | "creating" | "done" | "error";
@@ -126,6 +129,11 @@ export interface DashboardState {
126
129
  diffLoadingFiles: boolean;
127
130
  diffLoadingContent: boolean;
128
131
  diffError: string | null;
132
+ diffPendingDiscard: {
133
+ path: string;
134
+ isUntracked: boolean;
135
+ } | null;
136
+ diffRefreshTick: number;
129
137
  }
130
138
  export type DashboardAction = {
131
139
  type: "SET_DATA";
@@ -286,6 +294,21 @@ export type DashboardAction = {
286
294
  } | {
287
295
  type: "DIFF_CONTENT_SCROLL";
288
296
  offset: number;
297
+ } | {
298
+ type: "DIFF_REFRESH_FILES";
299
+ } | {
300
+ type: "DIFF_STATUS_UPDATED";
301
+ porcelain: {
302
+ path: string;
303
+ index: string;
304
+ working: string;
305
+ }[];
306
+ } | {
307
+ type: "DIFF_DISCARD_OPEN";
308
+ path: string;
309
+ isUntracked: boolean;
310
+ } | {
311
+ type: "DIFF_DISCARD_CANCEL";
289
312
  } | {
290
313
  type: "DIFF_CLOSE";
291
314
  };
@@ -53,6 +53,8 @@ export const initialState = {
53
53
  diffLoadingFiles: false,
54
54
  diffLoadingContent: false,
55
55
  diffError: null,
56
+ diffPendingDiscard: null,
57
+ diffRefreshTick: 0,
56
58
  };
57
59
  export function reducer(state, action) {
58
60
  switch (action.type) {
@@ -301,17 +303,73 @@ export function reducer(state, action) {
301
303
  diffLoadingFiles: true,
302
304
  diffLoadingContent: false,
303
305
  diffError: null,
306
+ diffPendingDiscard: null,
307
+ diffRefreshTick: 0,
304
308
  };
305
- case "DIFF_FILES_LOADED":
309
+ case "DIFF_FILES_LOADED": {
310
+ // Preserve the user's selection across reloads (after stage/unstage/
311
+ // discard) by matching the previously-selected file's path. Falls back
312
+ // to the clamped index when the path is gone (e.g. file was discarded).
313
+ const prevPath = state.diffFiles[state.diffFileIndex]?.path;
314
+ let newIndex = 0;
315
+ if (prevPath) {
316
+ const found = action.files.findIndex((f) => f.path === prevPath);
317
+ if (found >= 0)
318
+ newIndex = found;
319
+ else
320
+ newIndex = Math.min(state.diffFileIndex, Math.max(0, action.files.length - 1));
321
+ }
306
322
  return {
307
323
  ...state,
308
324
  diffFiles: action.files,
309
325
  diffMergeBase: action.mergeBase,
310
- diffFileIndex: 0,
311
- diffFileScrollOffset: 0,
326
+ diffFileIndex: newIndex,
312
327
  diffLoadingFiles: false,
313
328
  diffError: null,
314
329
  };
330
+ }
331
+ case "DIFF_REFRESH_FILES":
332
+ // Silent re-fetch — bumps the tick the loader effect depends on,
333
+ // without flipping diffLoadingFiles. The current file list stays
334
+ // rendered until the new one arrives, so there's no spinner blink.
335
+ return { ...state, diffRefreshTick: state.diffRefreshTick + 1 };
336
+ case "DIFF_STATUS_UPDATED": {
337
+ // In-place XY patch — used by stage/unstage where the file SET
338
+ // doesn't change, only the per-file porcelain status. Avoids the
339
+ // full reload's network/git latency and the spinner that goes
340
+ // with it.
341
+ const byPath = new Map();
342
+ for (const p of action.porcelain)
343
+ byPath.set(p.path, p);
344
+ const next = state.diffFiles.map((f) => {
345
+ const p = byPath.get(f.path);
346
+ if (!p) {
347
+ // File no longer has any working-tree state — back to
348
+ // committed-only. Strip the XY fields.
349
+ if (f.indexStatus === undefined && f.workingStatus === undefined)
350
+ return f;
351
+ const cleared = { ...f };
352
+ delete cleared.indexStatus;
353
+ delete cleared.workingStatus;
354
+ delete cleared.isUntracked;
355
+ return cleared;
356
+ }
357
+ return {
358
+ ...f,
359
+ indexStatus: p.index,
360
+ workingStatus: p.working,
361
+ isUntracked: p.index === "?" && p.working === "?",
362
+ };
363
+ });
364
+ return { ...state, diffFiles: next };
365
+ }
366
+ case "DIFF_DISCARD_OPEN":
367
+ return {
368
+ ...state,
369
+ diffPendingDiscard: { path: action.path, isUntracked: action.isUntracked },
370
+ };
371
+ case "DIFF_DISCARD_CANCEL":
372
+ return { ...state, diffPendingDiscard: null };
315
373
  case "DIFF_FILES_ERROR":
316
374
  return {
317
375
  ...state,
@@ -353,6 +411,7 @@ export function reducer(state, action) {
353
411
  diffLoadingFiles: false,
354
412
  diffLoadingContent: false,
355
413
  diffError: null,
414
+ diffPendingDiscard: null,
356
415
  };
357
416
  default:
358
417
  return state;
package/dist/lib/git.d.ts CHANGED
@@ -271,6 +271,45 @@ export declare function getDiffStat(baseBranch: string): string | null;
271
271
  * Returns null if there are no changes or on failure.
272
272
  */
273
273
  export declare function getDiffContent(baseBranch: string): string | null;
274
+ /**
275
+ * One entry from `git status --porcelain=v1 -z`.
276
+ * `index` (X) = staged state; `working` (Y) = unstaged state. Each is a single
277
+ * char per the porcelain format: ' ', 'M', 'A', 'D', 'R', 'C', 'U', '?', '!'.
278
+ */
279
+ export interface PorcelainEntry {
280
+ path: string;
281
+ index: string;
282
+ working: string;
283
+ oldPath?: string;
284
+ }
285
+ /**
286
+ * Read working-tree status as a list of porcelain entries. Uses NUL-delimited
287
+ * output so paths with spaces, quotes, or newlines parse unambiguously.
288
+ */
289
+ export declare function getWorktreeStatus(cwd: string): Promise<PorcelainEntry[]>;
290
+ /**
291
+ * Stage a single file. Works for new (untracked) and modified files.
292
+ */
293
+ export declare function stageFile(cwd: string, filePath: string): Promise<void>;
294
+ /**
295
+ * Unstage a single file. Uses `git restore --staged` (porcelain command,
296
+ * available since git 2.23 — already required elsewhere in santree).
297
+ */
298
+ export declare function unstageFile(cwd: string, filePath: string): Promise<void>;
299
+ /**
300
+ * Stage every uncommitted change in the worktree (new files, modifications, deletions).
301
+ */
302
+ export declare function stageAll(cwd: string): Promise<void>;
303
+ /**
304
+ * Unstage everything in the index (mixed reset on HEAD; working tree untouched).
305
+ */
306
+ export declare function unstageAll(cwd: string): Promise<void>;
307
+ /**
308
+ * Discard uncommitted changes for a single file.
309
+ * - Tracked: `git checkout HEAD -- <path>` restores both index and working tree.
310
+ * - Untracked: deletes the file from disk via fs.unlink.
311
+ */
312
+ export declare function discardFile(cwd: string, filePath: string, isUntracked: boolean): Promise<void>;
274
313
  /**
275
314
  * Read the session state file for a given ticket.
276
315
  * Returns null if missing or "exited".
package/dist/lib/git.js CHANGED
@@ -2,7 +2,7 @@ import { execSync, exec } from "child_process";
2
2
  import { promisify } from "util";
3
3
  import * as path from "path";
4
4
  import * as fs from "fs";
5
- import { run, runAsync } from "./exec.js";
5
+ import { run, runAsync, spawnAsync } from "./exec.js";
6
6
  import { getMultiplexer } from "./multiplexer/index.js";
7
7
  const execAsync = promisify(exec);
8
8
  /**
@@ -623,6 +623,94 @@ export function getDiffStat(baseBranch) {
623
623
  export function getDiffContent(baseBranch) {
624
624
  return run(`git diff ${baseBranch}..HEAD`, { maxBuffer: 10 * 1024 * 1024 }) || null;
625
625
  }
626
+ /**
627
+ * Read working-tree status as a list of porcelain entries. Uses NUL-delimited
628
+ * output so paths with spaces, quotes, or newlines parse unambiguously.
629
+ */
630
+ export async function getWorktreeStatus(cwd) {
631
+ const { code, output } = await spawnAsync("git", ["-C", cwd, "status", "--porcelain=v1", "-z"], {
632
+ cwd,
633
+ });
634
+ if (code !== 0) {
635
+ throw new Error(`git status failed: ${output.trim()}`);
636
+ }
637
+ const entries = [];
638
+ const records = output.split("\0");
639
+ for (let i = 0; i < records.length; i++) {
640
+ const rec = records[i];
641
+ if (!rec)
642
+ continue;
643
+ // Format: "XY <path>" — first 2 chars are status, then a space.
644
+ if (rec.length < 3)
645
+ continue;
646
+ const index = rec.charAt(0);
647
+ const working = rec.charAt(1);
648
+ const main = rec.slice(3);
649
+ // Renames/copies are followed by a NUL-terminated oldPath in the next record.
650
+ if (index === "R" || index === "C" || working === "R" || working === "C") {
651
+ const oldPath = records[i + 1] ?? "";
652
+ i += 1;
653
+ entries.push({ index, working, path: main, oldPath });
654
+ }
655
+ else {
656
+ entries.push({ index, working, path: main });
657
+ }
658
+ }
659
+ return entries;
660
+ }
661
+ /**
662
+ * Stage a single file. Works for new (untracked) and modified files.
663
+ */
664
+ export async function stageFile(cwd, filePath) {
665
+ const { code, output } = await spawnAsync("git", ["-C", cwd, "add", "--", filePath], { cwd });
666
+ if (code !== 0) {
667
+ throw new Error(`git add failed: ${output.trim()}`);
668
+ }
669
+ }
670
+ /**
671
+ * Unstage a single file. Uses `git restore --staged` (porcelain command,
672
+ * available since git 2.23 — already required elsewhere in santree).
673
+ */
674
+ export async function unstageFile(cwd, filePath) {
675
+ const { code, output } = await spawnAsync("git", ["-C", cwd, "restore", "--staged", "--", filePath], { cwd });
676
+ if (code !== 0) {
677
+ throw new Error(`git restore --staged failed: ${output.trim()}`);
678
+ }
679
+ }
680
+ /**
681
+ * Stage every uncommitted change in the worktree (new files, modifications, deletions).
682
+ */
683
+ export async function stageAll(cwd) {
684
+ const { code, output } = await spawnAsync("git", ["-C", cwd, "add", "-A"], { cwd });
685
+ if (code !== 0) {
686
+ throw new Error(`git add -A failed: ${output.trim()}`);
687
+ }
688
+ }
689
+ /**
690
+ * Unstage everything in the index (mixed reset on HEAD; working tree untouched).
691
+ */
692
+ export async function unstageAll(cwd) {
693
+ const { code, output } = await spawnAsync("git", ["-C", cwd, "reset"], { cwd });
694
+ if (code !== 0) {
695
+ throw new Error(`git reset failed: ${output.trim()}`);
696
+ }
697
+ }
698
+ /**
699
+ * Discard uncommitted changes for a single file.
700
+ * - Tracked: `git checkout HEAD -- <path>` restores both index and working tree.
701
+ * - Untracked: deletes the file from disk via fs.unlink.
702
+ */
703
+ export async function discardFile(cwd, filePath, isUntracked) {
704
+ if (isUntracked) {
705
+ const absolute = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
706
+ await fs.promises.unlink(absolute);
707
+ return;
708
+ }
709
+ const { code, output } = await spawnAsync("git", ["-C", cwd, "checkout", "HEAD", "--", filePath], { cwd });
710
+ if (code !== 0) {
711
+ throw new Error(`git checkout HEAD failed: ${output.trim()}`);
712
+ }
713
+ }
626
714
  /**
627
715
  * Get the path to the .santree/session-states directory.
628
716
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",