santree 0.5.1 → 0.5.3
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 +257 -17
- package/dist/lib/dashboard/DiffOverlay.d.ts +13 -1
- package/dist/lib/dashboard/DiffOverlay.js +55 -11
- 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";
|
|
@@ -42,17 +42,72 @@ const CLAUDE_VERSION = getInstalledClaudeVersion() ?? "";
|
|
|
42
42
|
* R100\told/path\tnew/path
|
|
43
43
|
* For renames/copies, the status code has a similarity suffix we strip.
|
|
44
44
|
*/
|
|
45
|
+
/**
|
|
46
|
+
* Split combined-parameter SGR sequences (e.g. `\x1b[48;2;R;G;B;38;2;R;G;B m`)
|
|
47
|
+
* into separate single-attribute SGRs (`\x1b[48;2;...m\x1b[38;2;...m`).
|
|
48
|
+
*
|
|
49
|
+
* Why: Ink uses `slice-ansi` to clip text horizontally, and `slice-ansi`
|
|
50
|
+
* miscounts visible width on combined RGB bg+fg SGRs — it cuts the line at
|
|
51
|
+
* roughly half the requested visible width. Delta emits exactly this combined
|
|
52
|
+
* form on every styled token, so the diff pane was rendering content cut at
|
|
53
|
+
* arbitrary points (e.g. `from datetime i` instead of `from datetime import
|
|
54
|
+
* timedelta`). Splitting them sidesteps the slice-ansi bug without losing any
|
|
55
|
+
* styling — the terminal renders the two SGRs identically to the combined one.
|
|
56
|
+
*/
|
|
57
|
+
function splitCombinedSgr(s) {
|
|
58
|
+
return s.replace(/\x1b\[([0-9;]+)m/g, (_match, params) => {
|
|
59
|
+
const tokens = params.split(";");
|
|
60
|
+
const groups = [];
|
|
61
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
62
|
+
const t = tokens[i];
|
|
63
|
+
if ((t === "38" || t === "48") && tokens[i + 1] === "2") {
|
|
64
|
+
groups.push([t, "2", tokens[i + 2], tokens[i + 3], tokens[i + 4]].join(";"));
|
|
65
|
+
i += 4;
|
|
66
|
+
}
|
|
67
|
+
else if ((t === "38" || t === "48") && tokens[i + 1] === "5") {
|
|
68
|
+
groups.push([t, "5", tokens[i + 2]].join(";"));
|
|
69
|
+
i += 2;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
groups.push(t);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (groups.length <= 1)
|
|
76
|
+
return `\x1b[${params}m`;
|
|
77
|
+
return groups.map((g) => `\x1b[${g}m`).join("");
|
|
78
|
+
});
|
|
79
|
+
}
|
|
45
80
|
/**
|
|
46
81
|
* Pipe `git diff` output through an external tool (e.g. delta) and return the
|
|
47
82
|
* combined ANSI output. Uses spawn pipes — no shell — so the tool name is safe
|
|
48
83
|
* even though we already validate it in getDiffTool().
|
|
49
84
|
*/
|
|
50
|
-
function runPipedDiff(cwd,
|
|
85
|
+
function runPipedDiff(cwd, gitArgs, tool, themeMode) {
|
|
51
86
|
return new Promise((resolve, reject) => {
|
|
52
|
-
const git = spawn("git", ["-C", cwd,
|
|
87
|
+
const git = spawn("git", ["-C", cwd, ...gitArgs], {
|
|
53
88
|
stdio: ["ignore", "pipe", "pipe"],
|
|
54
89
|
});
|
|
55
|
-
|
|
90
|
+
// Delta's syntax theme defaults are tuned for dark backgrounds — pale
|
|
91
|
+
// Monokai foreground on a light terminal becomes invisible. Force the
|
|
92
|
+
// theme flag matching santree's detected mode so colors stay readable.
|
|
93
|
+
const pagerArgs = tool === "delta" ? [themeMode === "light" ? "--light" : "--dark"] : [];
|
|
94
|
+
// Disable hyperlinks for delta: OSC 8 sequences (`\x1b]8;...`) are not
|
|
95
|
+
// handled by truncateVisible() — its CSI-only regex counts the URL
|
|
96
|
+
// bytes as visible characters, mangling line truncation and breaking
|
|
97
|
+
// terminal rendering of the wrapped text. Delta's CLI rejects an
|
|
98
|
+
// inline `--hyperlinks=false`, so override via GIT_CONFIG_PARAMETERS
|
|
99
|
+
// (delta reads its config from git). Also drop line-numbers — they
|
|
100
|
+
// eat ~6 cols of an already-narrow right pane.
|
|
101
|
+
const pagerEnv = tool === "delta"
|
|
102
|
+
? {
|
|
103
|
+
...process.env,
|
|
104
|
+
GIT_CONFIG_PARAMETERS: "'delta.hyperlinks=false' 'delta.line-numbers=false'",
|
|
105
|
+
}
|
|
106
|
+
: process.env;
|
|
107
|
+
const pager = spawn(tool, pagerArgs, {
|
|
108
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
109
|
+
env: pagerEnv,
|
|
110
|
+
});
|
|
56
111
|
let out = "";
|
|
57
112
|
let err = "";
|
|
58
113
|
git.stdout.pipe(pager.stdin);
|
|
@@ -72,7 +127,7 @@ function runPipedDiff(cwd, mergeBase, filePath, tool) {
|
|
|
72
127
|
reject(new Error(err || `${tool} exited with code ${code}`));
|
|
73
128
|
}
|
|
74
129
|
else {
|
|
75
|
-
resolve(out);
|
|
130
|
+
resolve(splitCombinedSgr(out));
|
|
76
131
|
}
|
|
77
132
|
});
|
|
78
133
|
});
|
|
@@ -200,7 +255,7 @@ function CommandBar({ showWorkspace, mode = "default", }) {
|
|
|
200
255
|
const dot = _jsx(Text, { dimColor: true, children: " · " });
|
|
201
256
|
const Key = ({ k }) => (_jsx(Text, { color: "cyan", bold: true, children: k }));
|
|
202
257
|
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: "
|
|
258
|
+
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
259
|
}
|
|
205
260
|
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
261
|
}
|
|
@@ -519,8 +574,16 @@ export default function Dashboard() {
|
|
|
519
574
|
await refresh(true);
|
|
520
575
|
};
|
|
521
576
|
init();
|
|
522
|
-
// Auto-refresh every 30s
|
|
523
|
-
|
|
577
|
+
// Auto-refresh every 30s. While the diff overlay is open, also bump
|
|
578
|
+
// the diff refresh tick so new/removed files (created or deleted
|
|
579
|
+
// outside the dashboard) eventually show up. Stage/unstage already
|
|
580
|
+
// patch XY in place, so this is purely about file-set drift.
|
|
581
|
+
refreshTimerRef.current = setInterval(() => {
|
|
582
|
+
refresh();
|
|
583
|
+
if (stateRef.current.overlay === "diff") {
|
|
584
|
+
dispatch({ type: "DIFF_REFRESH_FILES" });
|
|
585
|
+
}
|
|
586
|
+
}, 30_000);
|
|
524
587
|
return () => {
|
|
525
588
|
if (refreshTimerRef.current)
|
|
526
589
|
clearInterval(refreshTimerRef.current);
|
|
@@ -586,17 +649,49 @@ export default function Dashboard() {
|
|
|
586
649
|
useEffect(() => {
|
|
587
650
|
if (state.overlay !== "diff" || !state.diffWorktreePath || !state.diffBaseBranch)
|
|
588
651
|
return;
|
|
589
|
-
if (!state.diffLoadingFiles)
|
|
590
|
-
return;
|
|
591
652
|
const cwd = state.diffWorktreePath;
|
|
592
653
|
const base = state.diffBaseBranch;
|
|
593
654
|
void (async () => {
|
|
594
655
|
try {
|
|
595
656
|
const { stdout: mergeBaseOut } = await execAsync(`git -C "${cwd}" merge-base "${base}" HEAD`);
|
|
596
657
|
const mergeBase = mergeBaseOut.trim() || base;
|
|
597
|
-
const { stdout } = await
|
|
658
|
+
const [{ stdout }, porcelain] = await Promise.all([
|
|
659
|
+
execAsync(`git -C "${cwd}" diff --name-status "${mergeBase}"`),
|
|
660
|
+
getWorktreeStatus(cwd).catch(() => []),
|
|
661
|
+
]);
|
|
598
662
|
const files = parseNameStatus(stdout);
|
|
599
|
-
|
|
663
|
+
// Merge porcelain (working-tree state) into the merge-base file list.
|
|
664
|
+
// XY status drives stage/unstage UX; untracked files (`??`) only show
|
|
665
|
+
// up here since `git diff` ignores them.
|
|
666
|
+
const porcelainByPath = new Map();
|
|
667
|
+
for (const p of porcelain)
|
|
668
|
+
porcelainByPath.set(p.path, p);
|
|
669
|
+
const enriched = files.map((f) => {
|
|
670
|
+
const p = porcelainByPath.get(f.path);
|
|
671
|
+
if (!p)
|
|
672
|
+
return f;
|
|
673
|
+
porcelainByPath.delete(f.path);
|
|
674
|
+
return {
|
|
675
|
+
...f,
|
|
676
|
+
indexStatus: p.index,
|
|
677
|
+
workingStatus: p.working,
|
|
678
|
+
isUntracked: p.index === "?" && p.working === "?",
|
|
679
|
+
};
|
|
680
|
+
});
|
|
681
|
+
// Untracked entries left over → add as new DiffFile rows so they
|
|
682
|
+
// appear in the tree and can be staged.
|
|
683
|
+
for (const p of porcelainByPath.values()) {
|
|
684
|
+
if (p.index === "?" && p.working === "?") {
|
|
685
|
+
enriched.push({
|
|
686
|
+
path: p.path,
|
|
687
|
+
status: "?",
|
|
688
|
+
indexStatus: p.index,
|
|
689
|
+
workingStatus: p.working,
|
|
690
|
+
isUntracked: true,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const ordered = flattenTreeFiles(enriched);
|
|
600
695
|
dispatch({ type: "DIFF_FILES_LOADED", files: ordered, mergeBase });
|
|
601
696
|
}
|
|
602
697
|
catch (err) {
|
|
@@ -604,7 +699,7 @@ export default function Dashboard() {
|
|
|
604
699
|
dispatch({ type: "DIFF_FILES_ERROR", error: msg });
|
|
605
700
|
}
|
|
606
701
|
})();
|
|
607
|
-
}, [state.overlay, state.diffWorktreePath, state.diffBaseBranch, state.
|
|
702
|
+
}, [state.overlay, state.diffWorktreePath, state.diffBaseBranch, state.diffRefreshTick]);
|
|
608
703
|
// ── Diff overlay: load content for selected file ──────────────────
|
|
609
704
|
// If SANTREE_DIFF_TOOL is set, pipe `git diff` output through the tool so
|
|
610
705
|
// the user's preferred renderer (delta, diff-so-fancy, etc.) handles
|
|
@@ -621,11 +716,27 @@ export default function Dashboard() {
|
|
|
621
716
|
dispatch({ type: "DIFF_CONTENT_LOADING" });
|
|
622
717
|
void (async () => {
|
|
623
718
|
try {
|
|
624
|
-
if (
|
|
719
|
+
if (file.isUntracked) {
|
|
720
|
+
// Untracked files aren't in `git diff` output — fake a "full
|
|
721
|
+
// addition" diff via --no-index against /dev/null. git exits 1
|
|
722
|
+
// when files differ; that's expected, so we capture stdout
|
|
723
|
+
// regardless. Pipe through the configured tool when set so
|
|
724
|
+
// untracked files get the same syntax highlighting as tracked
|
|
725
|
+
// ones; otherwise fall back to spawnAsync + manual colorize.
|
|
726
|
+
if (tool) {
|
|
727
|
+
const content = await runPipedDiff(cwd, ["diff", "--color=always", "--no-index", "--", "/dev/null", file.path], tool, theme.mode);
|
|
728
|
+
dispatch({ type: "DIFF_CONTENT_LOADED", content });
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
const { output } = await spawnAsync("git", ["-C", cwd, "diff", "--no-color", "--no-index", "--", "/dev/null", file.path], { cwd });
|
|
732
|
+
dispatch({ type: "DIFF_CONTENT_LOADED", content: output });
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
else if (tool) {
|
|
625
736
|
// Pipe git diff (with colors enabled so the tool can pass them
|
|
626
737
|
// through if desired) into the configured tool. Use spawn pipes
|
|
627
738
|
// rather than shell to avoid quoting concerns.
|
|
628
|
-
const content = await runPipedDiff(cwd, mergeBase, file.path, tool);
|
|
739
|
+
const content = await runPipedDiff(cwd, ["diff", "--color=always", mergeBase, "--", file.path], tool, theme.mode);
|
|
629
740
|
dispatch({ type: "DIFF_CONTENT_LOADED", content });
|
|
630
741
|
}
|
|
631
742
|
else {
|
|
@@ -645,6 +756,7 @@ export default function Dashboard() {
|
|
|
645
756
|
state.diffMergeBase,
|
|
646
757
|
state.diffFileIndex,
|
|
647
758
|
state.diffFiles,
|
|
759
|
+
theme.mode,
|
|
648
760
|
]);
|
|
649
761
|
// ── Actions ───────────────────────────────────────────────────────
|
|
650
762
|
const launchWorkInTmux = useCallback(async (di, mode, worktreePath, contextFile) => {
|
|
@@ -1307,6 +1419,42 @@ export default function Dashboard() {
|
|
|
1307
1419
|
}
|
|
1308
1420
|
// Diff overlay
|
|
1309
1421
|
if (state.overlay === "diff") {
|
|
1422
|
+
// Pending discard modal — intercepts y/n/ESC/q so they don't
|
|
1423
|
+
// also close the diff overlay.
|
|
1424
|
+
if (state.diffPendingDiscard) {
|
|
1425
|
+
const pd = state.diffPendingDiscard;
|
|
1426
|
+
if (input === "y") {
|
|
1427
|
+
const cwd = state.diffWorktreePath;
|
|
1428
|
+
if (!cwd) {
|
|
1429
|
+
dispatch({ type: "DIFF_DISCARD_CANCEL" });
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
(async () => {
|
|
1433
|
+
try {
|
|
1434
|
+
await discardFile(cwd, pd.path, pd.isUntracked);
|
|
1435
|
+
dispatch({ type: "DIFF_DISCARD_CANCEL" });
|
|
1436
|
+
dispatch({ type: "DIFF_REFRESH_FILES" });
|
|
1437
|
+
dispatch({
|
|
1438
|
+
type: "SET_ACTION_MESSAGE",
|
|
1439
|
+
message: pd.isUntracked
|
|
1440
|
+
? `Deleted ${pd.path}`
|
|
1441
|
+
: `Discarded changes in ${pd.path}`,
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
catch (err) {
|
|
1445
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1446
|
+
dispatch({ type: "DIFF_DISCARD_CANCEL" });
|
|
1447
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: `Discard failed: ${msg}` });
|
|
1448
|
+
}
|
|
1449
|
+
})();
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
if (input === "n" || key.escape || input === "q") {
|
|
1453
|
+
dispatch({ type: "DIFF_DISCARD_CANCEL" });
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1310
1458
|
if (key.escape || input === "q") {
|
|
1311
1459
|
dispatch({ type: "DIFF_CLOSE" });
|
|
1312
1460
|
return;
|
|
@@ -1362,6 +1510,98 @@ export default function Dashboard() {
|
|
|
1362
1510
|
dispatch({ type: "DIFF_FILE_SELECT", index: prev });
|
|
1363
1511
|
return;
|
|
1364
1512
|
}
|
|
1513
|
+
// Stage / unstage / discard — only meaningful when the worktree
|
|
1514
|
+
// path is known. All ops dispatch DIFF_REFRESH_FILES so the
|
|
1515
|
+
// porcelain status (and selection) updates immediately.
|
|
1516
|
+
const cwd = state.diffWorktreePath;
|
|
1517
|
+
const currentFile = state.diffFiles[state.diffFileIndex];
|
|
1518
|
+
if (input === " " && cwd && currentFile) {
|
|
1519
|
+
// Toggle: if anything is staged for this file, unstage it;
|
|
1520
|
+
// otherwise stage. Files with no uncommitted state (only
|
|
1521
|
+
// committed changes vs base) have no XY → no-op. Updates XY
|
|
1522
|
+
// in place via porcelain re-fetch — no full reload, no spinner.
|
|
1523
|
+
const xRaw = currentFile.indexStatus;
|
|
1524
|
+
const yRaw = currentFile.workingStatus;
|
|
1525
|
+
if (xRaw === undefined && yRaw === undefined) {
|
|
1526
|
+
dispatch({
|
|
1527
|
+
type: "SET_ACTION_MESSAGE",
|
|
1528
|
+
message: "No uncommitted changes to stage on this file",
|
|
1529
|
+
});
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
const x = xRaw ?? " ";
|
|
1533
|
+
const isStaged = x !== " " && x !== "?";
|
|
1534
|
+
const path = currentFile.path;
|
|
1535
|
+
(async () => {
|
|
1536
|
+
try {
|
|
1537
|
+
if (isStaged)
|
|
1538
|
+
await unstageFile(cwd, path);
|
|
1539
|
+
else
|
|
1540
|
+
await stageFile(cwd, path);
|
|
1541
|
+
const porcelain = await getWorktreeStatus(cwd);
|
|
1542
|
+
dispatch({ type: "DIFF_STATUS_UPDATED", porcelain });
|
|
1543
|
+
}
|
|
1544
|
+
catch (err) {
|
|
1545
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1546
|
+
dispatch({
|
|
1547
|
+
type: "SET_ACTION_MESSAGE",
|
|
1548
|
+
message: `${isStaged ? "Unstage" : "Stage"} failed: ${msg}`,
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
})();
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
if (input === "a" && cwd) {
|
|
1555
|
+
// Stage-all if anything is unstaged or untracked; otherwise
|
|
1556
|
+
// unstage everything. Untracked files have Y === "?" so they
|
|
1557
|
+
// fall under "unstaged" — staging them adds them to the index.
|
|
1558
|
+
// Same in-place porcelain refresh as `space`.
|
|
1559
|
+
const anyUnstaged = state.diffFiles.some((f) => {
|
|
1560
|
+
const y = f.workingStatus;
|
|
1561
|
+
return y !== undefined && y !== " ";
|
|
1562
|
+
});
|
|
1563
|
+
(async () => {
|
|
1564
|
+
try {
|
|
1565
|
+
if (anyUnstaged)
|
|
1566
|
+
await stageAll(cwd);
|
|
1567
|
+
else
|
|
1568
|
+
await unstageAll(cwd);
|
|
1569
|
+
const porcelain = await getWorktreeStatus(cwd);
|
|
1570
|
+
dispatch({ type: "DIFF_STATUS_UPDATED", porcelain });
|
|
1571
|
+
dispatch({
|
|
1572
|
+
type: "SET_ACTION_MESSAGE",
|
|
1573
|
+
message: anyUnstaged ? "Staged all changes" : "Unstaged all changes",
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
catch (err) {
|
|
1577
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1578
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: `Failed: ${msg}` });
|
|
1579
|
+
}
|
|
1580
|
+
})();
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
if (input === "d" && currentFile) {
|
|
1584
|
+
if (currentFile.indexStatus === undefined && currentFile.workingStatus === undefined) {
|
|
1585
|
+
dispatch({
|
|
1586
|
+
type: "SET_ACTION_MESSAGE",
|
|
1587
|
+
message: "No uncommitted changes to discard",
|
|
1588
|
+
});
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
dispatch({
|
|
1592
|
+
type: "DIFF_DISCARD_OPEN",
|
|
1593
|
+
path: currentFile.path,
|
|
1594
|
+
isUntracked: !!currentFile.isUntracked,
|
|
1595
|
+
});
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
// Open the selected file in the user's editor — useful when
|
|
1599
|
+
// the diff alone isn't enough context. Editor resolution
|
|
1600
|
+
// matches the rest of santree (SANTREE_EDITOR > "code").
|
|
1601
|
+
if (input === "e" && cwd && currentFile) {
|
|
1602
|
+
openInEditor(path.join(cwd, currentFile.path));
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1365
1605
|
return;
|
|
1366
1606
|
}
|
|
1367
1607
|
// Confirm delete overlay
|
|
@@ -1884,10 +2124,10 @@ export default function Dashboard() {
|
|
|
1884
2124
|
const defaultBranch = getDefaultBranch();
|
|
1885
2125
|
const label = branch === defaultBranch ? `${branch} (default)` : branch;
|
|
1886
2126
|
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
|
|
2127
|
+
}), _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
2128
|
.split("\n")
|
|
1889
2129
|
.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 })) })] })),
|
|
2130
|
+
.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
2131
|
}
|
|
1892
2132
|
/**
|
|
1893
2133
|
* 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,37 @@ 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
|
-
|
|
261
|
-
|
|
297
|
+
// wrap="truncate" prevents Ink from soft-wrapping. Default
|
|
298
|
+
// `wrap` mode measures byte length (counting ANSI escape
|
|
299
|
+
// bytes as visible chars), which makes color-heavy lines
|
|
300
|
+
// like syntax-highlighted code wrap *very* early — visible
|
|
301
|
+
// content gets clobbered by the next row. truncateVisible
|
|
302
|
+
// has already sized the cell, so `truncate` is a no-op for
|
|
303
|
+
// already-fitting lines.
|
|
304
|
+
return (_jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, wrap: "truncate", children: cell }, i));
|
|
305
|
+
})) })] }))] }));
|
|
262
306
|
}
|
|
@@ -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
|
*/
|