santree 0.5.1 → 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 +188 -12
- package/dist/lib/dashboard/DiffOverlay.d.ts +13 -1
- package/dist/lib/dashboard/DiffOverlay.js +47 -10
- 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
|
@@ -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";
|
|
@@ -200,7 +200,7 @@ function CommandBar({ showWorkspace, mode = "default", }) {
|
|
|
200
200
|
const dot = _jsx(Text, { dimColor: true, children: " · " });
|
|
201
201
|
const Key = ({ k }) => (_jsx(Text, { color: "cyan", bold: true, children: k }));
|
|
202
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: "
|
|
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
204
|
}
|
|
205
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" })] }));
|
|
206
206
|
}
|
|
@@ -519,8 +519,16 @@ export default function Dashboard() {
|
|
|
519
519
|
await refresh(true);
|
|
520
520
|
};
|
|
521
521
|
init();
|
|
522
|
-
// Auto-refresh every 30s
|
|
523
|
-
|
|
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);
|
|
524
532
|
return () => {
|
|
525
533
|
if (refreshTimerRef.current)
|
|
526
534
|
clearInterval(refreshTimerRef.current);
|
|
@@ -586,17 +594,49 @@ export default function Dashboard() {
|
|
|
586
594
|
useEffect(() => {
|
|
587
595
|
if (state.overlay !== "diff" || !state.diffWorktreePath || !state.diffBaseBranch)
|
|
588
596
|
return;
|
|
589
|
-
if (!state.diffLoadingFiles)
|
|
590
|
-
return;
|
|
591
597
|
const cwd = state.diffWorktreePath;
|
|
592
598
|
const base = state.diffBaseBranch;
|
|
593
599
|
void (async () => {
|
|
594
600
|
try {
|
|
595
601
|
const { stdout: mergeBaseOut } = await execAsync(`git -C "${cwd}" merge-base "${base}" HEAD`);
|
|
596
602
|
const mergeBase = mergeBaseOut.trim() || base;
|
|
597
|
-
const { stdout } = await
|
|
603
|
+
const [{ stdout }, porcelain] = await Promise.all([
|
|
604
|
+
execAsync(`git -C "${cwd}" diff --name-status "${mergeBase}"`),
|
|
605
|
+
getWorktreeStatus(cwd).catch(() => []),
|
|
606
|
+
]);
|
|
598
607
|
const files = parseNameStatus(stdout);
|
|
599
|
-
|
|
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);
|
|
600
640
|
dispatch({ type: "DIFF_FILES_LOADED", files: ordered, mergeBase });
|
|
601
641
|
}
|
|
602
642
|
catch (err) {
|
|
@@ -604,7 +644,7 @@ export default function Dashboard() {
|
|
|
604
644
|
dispatch({ type: "DIFF_FILES_ERROR", error: msg });
|
|
605
645
|
}
|
|
606
646
|
})();
|
|
607
|
-
}, [state.overlay, state.diffWorktreePath, state.diffBaseBranch, state.
|
|
647
|
+
}, [state.overlay, state.diffWorktreePath, state.diffBaseBranch, state.diffRefreshTick]);
|
|
608
648
|
// ── Diff overlay: load content for selected file ──────────────────
|
|
609
649
|
// If SANTREE_DIFF_TOOL is set, pipe `git diff` output through the tool so
|
|
610
650
|
// the user's preferred renderer (delta, diff-so-fancy, etc.) handles
|
|
@@ -621,7 +661,15 @@ export default function Dashboard() {
|
|
|
621
661
|
dispatch({ type: "DIFF_CONTENT_LOADING" });
|
|
622
662
|
void (async () => {
|
|
623
663
|
try {
|
|
624
|
-
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) {
|
|
625
673
|
// Pipe git diff (with colors enabled so the tool can pass them
|
|
626
674
|
// through if desired) into the configured tool. Use spawn pipes
|
|
627
675
|
// rather than shell to avoid quoting concerns.
|
|
@@ -1307,6 +1355,42 @@ export default function Dashboard() {
|
|
|
1307
1355
|
}
|
|
1308
1356
|
// Diff overlay
|
|
1309
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
|
+
}
|
|
1310
1394
|
if (key.escape || input === "q") {
|
|
1311
1395
|
dispatch({ type: "DIFF_CLOSE" });
|
|
1312
1396
|
return;
|
|
@@ -1362,6 +1446,98 @@ export default function Dashboard() {
|
|
|
1362
1446
|
dispatch({ type: "DIFF_FILE_SELECT", index: prev });
|
|
1363
1447
|
return;
|
|
1364
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
|
+
}
|
|
1365
1541
|
return;
|
|
1366
1542
|
}
|
|
1367
1543
|
// Confirm delete overlay
|
|
@@ -1884,10 +2060,10 @@ export default function Dashboard() {
|
|
|
1884
2060
|
const defaultBranch = getDefaultBranch();
|
|
1885
2061
|
const label = branch === defaultBranch ? `${branch} (default)` : branch;
|
|
1886
2062
|
return (_jsx(Text, { children: _jsxs(Text, { color: selected ? "cyan" : undefined, bold: selected, children: [selected ? "> " : " ", label] }) }, branch));
|
|
1887
|
-
}), _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 })) : 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
|
|
1888
2064
|
.split("\n")
|
|
1889
2065
|
.slice(-(contentHeight - 1))
|
|
1890
|
-
.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 }) })] })) })] })] }));
|
|
1891
2067
|
}
|
|
1892
2068
|
/**
|
|
1893
2069
|
* Renders the per-issue action key hints (Resume / Editor / View diff / …)
|
|
@@ -19,6 +19,14 @@ interface Props {
|
|
|
19
19
|
* formula when undefined. Always clamped against pane minimums.
|
|
20
20
|
*/
|
|
21
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;
|
|
22
30
|
}
|
|
23
31
|
interface RenderedRow {
|
|
24
32
|
prefix: string;
|
|
@@ -27,6 +35,10 @@ interface RenderedRow {
|
|
|
27
35
|
dim?: boolean;
|
|
28
36
|
bold?: boolean;
|
|
29
37
|
fileIndex: number | null;
|
|
38
|
+
xy?: {
|
|
39
|
+
index: string;
|
|
40
|
+
working: string;
|
|
41
|
+
};
|
|
30
42
|
}
|
|
31
43
|
export declare function flattenTreeFiles(files: DiffFile[]): DiffFile[];
|
|
32
44
|
export interface DiffLayout {
|
|
@@ -57,5 +69,5 @@ export declare function computeDiffLayout(opts: {
|
|
|
57
69
|
fileScrollOffset: number;
|
|
58
70
|
leftWidthOverride?: number;
|
|
59
71
|
}): DiffLayout;
|
|
60
|
-
export default function DiffOverlay({ width, height, ticketId, baseBranch, files, fileIndex, fileScrollOffset, content, contentScrollOffset, loadingFiles, loadingContent, error, selectionBg, leftWidthOverride, }: 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;
|
|
61
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
|
}
|
|
@@ -206,7 +231,7 @@ function truncateVisible(s, max) {
|
|
|
206
231
|
return out + "…";
|
|
207
232
|
}
|
|
208
233
|
// ── Component ─────────────────────────────────────────────────────────
|
|
209
|
-
export default function DiffOverlay({ width, height, ticketId, baseBranch, files, fileIndex, fileScrollOffset, content, contentScrollOffset, loadingFiles, loadingContent, error, selectionBg = "#1e3a5f", leftWidthOverride, }) {
|
|
234
|
+
export default function DiffOverlay({ width, height, ticketId, baseBranch, files, fileIndex, fileScrollOffset, content, contentScrollOffset, loadingFiles, loadingContent, error, selectionBg = "#1e3a5f", leftWidthOverride, pendingDiscard = null, }) {
|
|
210
235
|
const layout = computeDiffLayout({
|
|
211
236
|
width,
|
|
212
237
|
height,
|
|
@@ -245,18 +270,30 @@ export default function DiffOverlay({ width, height, ticketId, baseBranch, files
|
|
|
245
270
|
const p = currentFile.path;
|
|
246
271
|
truncatedPath = p.length > pathRoom ? "…" + p.slice(-Math.max(0, pathRoom - 1)) : p;
|
|
247
272
|
}
|
|
248
|
-
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) => {
|
|
249
274
|
const absIdx = effectiveScroll + i;
|
|
250
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
|
+
}
|
|
251
288
|
const text = `${row.prefix}${row.label}`;
|
|
252
289
|
// Selected row keeps its own color (file-status hue or directory
|
|
253
290
|
// blue) but gets the theme-aware selection bg + bold so it stays
|
|
254
291
|
// readable in light and dark modes alike.
|
|
255
|
-
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));
|
|
256
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) => {
|
|
257
294
|
// rightWidth includes the paddingLeft={1} of the wrapper Box,
|
|
258
295
|
// so usable column count is rightWidth - 1.
|
|
259
296
|
const cell = truncateVisible(line.text || " ", Math.max(1, rightWidth - 1));
|
|
260
297
|
return (_jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, children: cell }, i));
|
|
261
|
-
})) })] })] }));
|
|
298
|
+
})) })] }))] }));
|
|
262
299
|
}
|
|
@@ -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
|
*/
|