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.
- package/dist/commands/dashboard.js +236 -22
- package/dist/lib/dashboard/DiffOverlay.d.ts +24 -1
- package/dist/lib/dashboard/DiffOverlay.js +71 -15
- package/dist/lib/dashboard/ReviewList.js +4 -4
- package/dist/lib/dashboard/types.d.ts +23 -0
- package/dist/lib/dashboard/types.js +62 -3
- package/dist/lib/git.d.ts +39 -0
- package/dist/lib/git.js +89 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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 })) })] })),
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
const
|
|
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({
|
|
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:
|
|
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
|
-
})) })] })
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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
|
*/
|