santree 0.4.0 → 0.5.0
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/README.md +46 -2
- package/dist/commands/dashboard.js +425 -95
- package/dist/commands/doctor.js +103 -17
- package/dist/commands/helpers/statusline.js +10 -2
- package/dist/commands/update.d.ts +15 -0
- package/dist/commands/update.js +72 -0
- package/dist/commands/worktree/diff.d.ts +13 -0
- package/dist/commands/worktree/diff.js +76 -0
- package/dist/lib/ai.d.ts +12 -2
- package/dist/lib/ai.js +37 -6
- package/dist/lib/dashboard/DetailPanel.d.ts +9 -0
- package/dist/lib/dashboard/DetailPanel.js +235 -89
- package/dist/lib/dashboard/DiffOverlay.d.ts +50 -0
- package/dist/lib/dashboard/DiffOverlay.js +243 -0
- package/dist/lib/dashboard/IssueList.d.ts +20 -3
- package/dist/lib/dashboard/IssueList.js +74 -103
- package/dist/lib/dashboard/ReviewDetailPanel.d.ts +6 -0
- package/dist/lib/dashboard/ReviewDetailPanel.js +4 -7
- package/dist/lib/dashboard/ReviewList.d.ts +3 -1
- package/dist/lib/dashboard/ReviewList.js +3 -3
- package/dist/lib/dashboard/data.js +10 -4
- package/dist/lib/dashboard/theme.d.ts +24 -0
- package/dist/lib/dashboard/theme.js +113 -0
- package/dist/lib/dashboard/types.d.ts +52 -1
- package/dist/lib/dashboard/types.js +81 -0
- package/dist/lib/git.d.ts +20 -0
- package/dist/lib/git.js +37 -0
- package/dist/lib/version.d.ts +55 -0
- package/dist/lib/version.js +224 -0
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useReducer, useCallback, useRef, useState } from "react";
|
|
3
3
|
import { Text, Box, useInput, useStdout, useApp } from "ink";
|
|
4
4
|
import Spinner from "ink-spinner";
|
|
@@ -9,9 +9,10 @@ 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, } from "../lib/git.js";
|
|
12
|
+
import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasInitScript, getInitScriptPath, removeWorktree, getDiffTool, } from "../lib/git.js";
|
|
13
13
|
import { run, spawnAsync } from "../lib/exec.js";
|
|
14
14
|
import { resolveAgentBinary } from "../lib/ai.js";
|
|
15
|
+
import { getInstalledClaudeVersion } from "../lib/version.js";
|
|
15
16
|
import { extractTicketId } from "../lib/git.js";
|
|
16
17
|
import { getMultiplexer } from "../lib/multiplexer/index.js";
|
|
17
18
|
import { getPRTemplate } from "../lib/github.js";
|
|
@@ -20,26 +21,83 @@ import { getTicketContent } from "../lib/linear.js";
|
|
|
20
21
|
import * as os from "os";
|
|
21
22
|
import { initialState, reducer } from "../lib/dashboard/types.js";
|
|
22
23
|
import { loadDashboardData, loadReviewsData } from "../lib/dashboard/data.js";
|
|
23
|
-
import IssueList from "../lib/dashboard/IssueList.js";
|
|
24
|
-
import
|
|
24
|
+
import IssueList, { buildIssueListRows } from "../lib/dashboard/IssueList.js";
|
|
25
|
+
import { detectTerminalTheme, getThemeForMode, } from "../lib/dashboard/theme.js";
|
|
26
|
+
import DetailPanel, { buildIssueActions } from "../lib/dashboard/DetailPanel.js";
|
|
25
27
|
import ReviewList from "../lib/dashboard/ReviewList.js";
|
|
26
|
-
import ReviewDetailPanel from "../lib/dashboard/ReviewDetailPanel.js";
|
|
28
|
+
import ReviewDetailPanel, { buildReviewActions } from "../lib/dashboard/ReviewDetailPanel.js";
|
|
27
29
|
import { CommitOverlay, PrCreateOverlay } from "../lib/dashboard/Overlays.js";
|
|
28
30
|
import { MultilineTextArea } from "../lib/dashboard/MultilineTextArea.js";
|
|
31
|
+
import DiffOverlay, { flattenTreeFiles, computeDiffLayout } from "../lib/dashboard/DiffOverlay.js";
|
|
32
|
+
import { CURRENT_VERSION, CLAUDE_CODE_PACKAGE, getLatestVersion, getCachedLatestVersion, getLatestVersionFor, getCachedLatestVersionFor, isUpdateAvailable, } from "../lib/version.js";
|
|
29
33
|
export const description = "Interactive dashboard of your Linear issues";
|
|
30
34
|
const execAsync = promisify(exec);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return (execSync(`${bin} --version`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] })
|
|
35
|
-
.trim()
|
|
36
|
-
.split(" ")[0] ?? "");
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
return "";
|
|
40
|
-
}
|
|
41
|
-
})();
|
|
35
|
+
// Resolved at module load — cheap. Honors cmux's bundled binary when running
|
|
36
|
+
// inside cmux so the header reflects the binary santree will actually use.
|
|
37
|
+
const CLAUDE_VERSION = getInstalledClaudeVersion() ?? "";
|
|
42
38
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
39
|
+
/**
|
|
40
|
+
* Parse `git diff --name-status` output. Each line is a tab-separated record:
|
|
41
|
+
* M\tpath/to/file.ts
|
|
42
|
+
* R100\told/path\tnew/path
|
|
43
|
+
* For renames/copies, the status code has a similarity suffix we strip.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* Pipe `git diff` output through an external tool (e.g. delta) and return the
|
|
47
|
+
* combined ANSI output. Uses spawn pipes — no shell — so the tool name is safe
|
|
48
|
+
* even though we already validate it in getDiffTool().
|
|
49
|
+
*/
|
|
50
|
+
function runPipedDiff(cwd, mergeBase, filePath, tool) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const git = spawn("git", ["-C", cwd, "diff", "--color=always", mergeBase, "--", filePath], {
|
|
53
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
54
|
+
});
|
|
55
|
+
const pager = spawn(tool, [], { stdio: ["pipe", "pipe", "pipe"] });
|
|
56
|
+
let out = "";
|
|
57
|
+
let err = "";
|
|
58
|
+
git.stdout.pipe(pager.stdin);
|
|
59
|
+
git.stderr.on("data", (d) => {
|
|
60
|
+
err += d.toString();
|
|
61
|
+
});
|
|
62
|
+
pager.stdout.on("data", (d) => {
|
|
63
|
+
out += d.toString();
|
|
64
|
+
});
|
|
65
|
+
pager.stderr.on("data", (d) => {
|
|
66
|
+
err += d.toString();
|
|
67
|
+
});
|
|
68
|
+
pager.on("error", reject);
|
|
69
|
+
git.on("error", reject);
|
|
70
|
+
pager.on("close", (code) => {
|
|
71
|
+
if (code !== 0 && !out) {
|
|
72
|
+
reject(new Error(err || `${tool} exited with code ${code}`));
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
resolve(out);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function parseNameStatus(raw) {
|
|
81
|
+
const files = [];
|
|
82
|
+
for (const line of raw.split("\n")) {
|
|
83
|
+
if (!line.trim())
|
|
84
|
+
continue;
|
|
85
|
+
const parts = line.split("\t");
|
|
86
|
+
if (parts.length < 2)
|
|
87
|
+
continue;
|
|
88
|
+
const code = parts[0].charAt(0).toUpperCase();
|
|
89
|
+
const status = code === "M" || code === "A" || code === "D" || code === "R" || code === "C" || code === "U"
|
|
90
|
+
? code
|
|
91
|
+
: "?";
|
|
92
|
+
if ((status === "R" || status === "C") && parts.length >= 3) {
|
|
93
|
+
files.push({ status, path: parts[2], oldPath: parts[1] });
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
files.push({ status, path: parts[1] });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return files;
|
|
100
|
+
}
|
|
43
101
|
function slugify(title) {
|
|
44
102
|
return title
|
|
45
103
|
.toLowerCase()
|
|
@@ -48,58 +106,30 @@ function slugify(title) {
|
|
|
48
106
|
.slice(0, 40);
|
|
49
107
|
}
|
|
50
108
|
// ── Scroll helpers ────────────────────────────────────────────────────
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
let issuesSeen = 0;
|
|
63
|
-
for (const g of groups) {
|
|
64
|
-
row++; // project header
|
|
65
|
-
for (const sg of g.statusGroups) {
|
|
66
|
-
row++; // status header
|
|
67
|
-
for (const di of sg.issues) {
|
|
68
|
-
const total = countWithChildren(di);
|
|
69
|
-
if (flatIndex >= issuesSeen && flatIndex < issuesSeen + total) {
|
|
70
|
-
// The target is within this issue or its children
|
|
71
|
-
return row + (flatIndex - issuesSeen);
|
|
72
|
-
}
|
|
73
|
-
row += total;
|
|
74
|
-
issuesSeen += total;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
109
|
+
/**
|
|
110
|
+
* Walk the rendered list rows and return the absolute row index of the
|
|
111
|
+
* issue's main row (not its detail sub-rows). Used to keep the selected
|
|
112
|
+
* issue scrolled into view as `j`/`k` moves selection.
|
|
113
|
+
*/
|
|
114
|
+
function getRowIndexForFlatIndex(groups, flatIssues, flatIndex) {
|
|
115
|
+
const rows = buildIssueListRows(groups, flatIssues);
|
|
116
|
+
for (let i = 0; i < rows.length; i++) {
|
|
117
|
+
const r = rows[i];
|
|
118
|
+
if (r.kind === "issue" && r.flatIndex === flatIndex)
|
|
119
|
+
return i;
|
|
77
120
|
}
|
|
78
121
|
return 0;
|
|
79
122
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (row === listRow)
|
|
91
|
-
return null; // status header row
|
|
92
|
-
row++;
|
|
93
|
-
for (const di of sg.issues) {
|
|
94
|
-
const total = countWithChildren(di);
|
|
95
|
-
if (listRow >= row && listRow < row + total) {
|
|
96
|
-
return issuesSeen + (listRow - row);
|
|
97
|
-
}
|
|
98
|
-
row += total;
|
|
99
|
-
issuesSeen += total;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
123
|
+
/**
|
|
124
|
+
* Map a clicked list row back to its parent issue's flat index, if any.
|
|
125
|
+
*/
|
|
126
|
+
function getFlatIndexForListRow(groups, flatIssues, listRow) {
|
|
127
|
+
const rows = buildIssueListRows(groups, flatIssues);
|
|
128
|
+
const row = rows[listRow];
|
|
129
|
+
if (!row)
|
|
130
|
+
return null;
|
|
131
|
+
if (row.kind === "issue")
|
|
132
|
+
return row.flatIndex;
|
|
103
133
|
return null;
|
|
104
134
|
}
|
|
105
135
|
// ── Terminal escape sequences ─────────────────────────────────────────
|
|
@@ -144,12 +174,46 @@ function leaveAltScreen() {
|
|
|
144
174
|
process.stdout.write("\x1b[?1049l"); // Leave alternate screen buffer
|
|
145
175
|
process.stdout.write("\x1b[?25h"); // Show cursor
|
|
146
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Tab pill — active tab uses an explicit hex bg + fg so contrast doesn't
|
|
179
|
+
* depend on the user's ANSI palette interpretation (terminal "cyan" can be a
|
|
180
|
+
* pale teal in light themes that doesn't read against ANSI "black"). Light
|
|
181
|
+
* mode gets a darker teal pill with white text; dark mode keeps a bright
|
|
182
|
+
* cyan pill with black text. Inactive tabs use default foreground.
|
|
183
|
+
*/
|
|
184
|
+
function Tab({ active, label, mode }) {
|
|
185
|
+
if (active) {
|
|
186
|
+
const bg = mode === "light" ? "#0e7490" : "#22d3ee";
|
|
187
|
+
const fg = mode === "light" ? "white" : "black";
|
|
188
|
+
return (_jsx(Text, { backgroundColor: bg, color: fg, bold: true, children: ` ${label} ` }));
|
|
189
|
+
}
|
|
190
|
+
return _jsx(Text, { children: ` ${label} ` });
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Single-line global keymap shown at the bottom-left of the dashboard. The
|
|
194
|
+
* `E workspace` hint only appears when the action is meaningful
|
|
195
|
+
* (`SANTREE_EDITOR` is `code`/`cursor` and a `.code-workspace` file exists in
|
|
196
|
+
* the repo root).
|
|
197
|
+
*/
|
|
198
|
+
function CommandBar({ showWorkspace }) {
|
|
199
|
+
const dot = _jsx(Text, { dimColor: true, children: " · " });
|
|
200
|
+
const Key = ({ k }) => (_jsx(Text, { color: "cyan", bold: true, children: k }));
|
|
201
|
+
return (_jsxs(Text, { children: [_jsx(Key, { k: "j/k" }), _jsx(Text, { dimColor: true, children: " nav" }), dot, _jsx(Key, { k: "\u21E7\u2191\u2193" }), _jsx(Text, { dimColor: true, children: " scroll" }), dot, _jsx(Key, { k: "1/2" }), _jsx(Text, { dimColor: true, children: " tabs" }), showWorkspace ? (_jsxs(_Fragment, { children: [dot, _jsx(Key, { k: "E" }), _jsx(Text, { dimColor: true, children: " workspace" })] })) : null, dot, _jsx(Key, { k: "R" }), _jsx(Text, { dimColor: true, children: " refresh" }), dot, _jsx(Key, { k: "q" }), _jsx(Text, { dimColor: true, children: " quit" })] }));
|
|
202
|
+
}
|
|
147
203
|
// ── Component ─────────────────────────────────────────────────────────
|
|
148
204
|
export default function Dashboard() {
|
|
149
205
|
ensureAltScreen();
|
|
150
206
|
const { exit } = useApp();
|
|
151
207
|
const { stdout } = useStdout();
|
|
152
208
|
const [state, dispatch] = useReducer(reducer, initialState);
|
|
209
|
+
// Theme is a visual concern only — kept outside the reducer so re-detection
|
|
210
|
+
// on refresh doesn't churn data flow. Defaults to dark; replaced by OSC 11
|
|
211
|
+
// detection on mount and on every refresh.
|
|
212
|
+
const [theme, setTheme] = useState(getThemeForMode("dark"));
|
|
213
|
+
// `E workspace` is only meaningful when the user's editor accepts a
|
|
214
|
+
// `.code-workspace` file (VSCode/Cursor) AND such a file exists in the
|
|
215
|
+
// repo root. Recomputed alongside the data refresh.
|
|
216
|
+
const [hasWorkspaceFile, setHasWorkspaceFile] = useState(false);
|
|
153
217
|
const refreshTimerRef = useRef(null);
|
|
154
218
|
const repoRootRef = useRef(null);
|
|
155
219
|
const stateRef = useRef(state);
|
|
@@ -159,6 +223,28 @@ export default function Dashboard() {
|
|
|
159
223
|
columns: stdout?.columns ?? 80,
|
|
160
224
|
rows: stdout?.rows ?? 24,
|
|
161
225
|
});
|
|
226
|
+
// Show cached values immediately so the banner appears on first paint when
|
|
227
|
+
// known-stale; refresh in the background without blocking dashboard load.
|
|
228
|
+
const [latestVersion, setLatestVersion] = useState(getCachedLatestVersion);
|
|
229
|
+
const [latestClaudeVersion, setLatestClaudeVersion] = useState(() => getCachedLatestVersionFor(CLAUDE_CODE_PACKAGE));
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
let cancelled = false;
|
|
232
|
+
getLatestVersion().then((v) => {
|
|
233
|
+
if (!cancelled && v)
|
|
234
|
+
setLatestVersion(v);
|
|
235
|
+
});
|
|
236
|
+
getLatestVersionFor(CLAUDE_CODE_PACKAGE).then((v) => {
|
|
237
|
+
if (!cancelled && v)
|
|
238
|
+
setLatestClaudeVersion(v);
|
|
239
|
+
});
|
|
240
|
+
return () => {
|
|
241
|
+
cancelled = true;
|
|
242
|
+
};
|
|
243
|
+
}, []);
|
|
244
|
+
const updateAvailable = latestVersion ? isUpdateAvailable(CURRENT_VERSION, latestVersion) : false;
|
|
245
|
+
const claudeUpdateAvailable = !!CLAUDE_VERSION &&
|
|
246
|
+
!!latestClaudeVersion &&
|
|
247
|
+
isUpdateAvailable(CLAUDE_VERSION, latestClaudeVersion);
|
|
162
248
|
useEffect(() => {
|
|
163
249
|
const onResize = () => {
|
|
164
250
|
setTermSize({
|
|
@@ -173,12 +259,14 @@ export default function Dashboard() {
|
|
|
173
259
|
}, [stdout]);
|
|
174
260
|
const { columns, rows } = termSize;
|
|
175
261
|
const separatorWidth = 3;
|
|
176
|
-
const
|
|
262
|
+
const innerWidth = Math.max(40, columns - 2); // outer border consumes 1 col on each side
|
|
263
|
+
const [leftWidth, setLeftWidth] = useState(Math.floor(innerWidth * 0.42));
|
|
177
264
|
const leftWidthRef = useRef(leftWidth);
|
|
178
265
|
leftWidthRef.current = leftWidth;
|
|
179
|
-
const rightWidth =
|
|
180
|
-
|
|
181
|
-
const
|
|
266
|
+
const rightWidth = innerWidth - leftWidth - separatorWidth;
|
|
267
|
+
// Header (1) + tab strip (1) + 2 borders + command bar (1, inside box) = 5 rows
|
|
268
|
+
const contentHeight = Math.max(3, rows - 5);
|
|
269
|
+
const LIST_FOOTER_HEIGHT = 0;
|
|
182
270
|
// ── Data loading ──────────────────────────────────────────────────
|
|
183
271
|
const refresh = useCallback(async (isInitial = false) => {
|
|
184
272
|
if (!isInitial)
|
|
@@ -190,10 +278,29 @@ export default function Dashboard() {
|
|
|
190
278
|
}
|
|
191
279
|
repoRootRef.current = repoRoot;
|
|
192
280
|
try {
|
|
193
|
-
|
|
281
|
+
// Re-detect terminal theme alongside data fetch so light↔dark switches
|
|
282
|
+
// propagate within one refresh cycle (≤30s).
|
|
283
|
+
const [data, reviewData, themeMode] = await Promise.all([
|
|
194
284
|
loadDashboardData(repoRoot),
|
|
195
285
|
loadReviewsData(repoRoot),
|
|
286
|
+
detectTerminalTheme(),
|
|
196
287
|
]);
|
|
288
|
+
setTheme(getThemeForMode(themeMode));
|
|
289
|
+
// Workspace file presence — only meaningful when the editor consumes
|
|
290
|
+
// `.code-workspace` files. Cheap directory read; recomputed each cycle
|
|
291
|
+
// in case the user adds/removes one.
|
|
292
|
+
const editor = (process.env.SANTREE_EDITOR ?? "code").toLowerCase();
|
|
293
|
+
const editorAcceptsWorkspace = editor === "code" || editor === "cursor";
|
|
294
|
+
let hasWs = false;
|
|
295
|
+
if (editorAcceptsWorkspace) {
|
|
296
|
+
try {
|
|
297
|
+
hasWs = fs.readdirSync(repoRoot).some((f) => f.endsWith(".code-workspace"));
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
hasWs = false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
setHasWorkspaceFile(hasWs);
|
|
197
304
|
dispatch({ type: "SET_DATA", ...data });
|
|
198
305
|
dispatch({ type: "SET_REVIEWS_DATA", flatReviews: reviewData.flatReviews });
|
|
199
306
|
}
|
|
@@ -231,8 +338,9 @@ export default function Dashboard() {
|
|
|
231
338
|
}
|
|
232
339
|
// Drag — resize if actively dragging
|
|
233
340
|
if (isDrag && draggingRef.current) {
|
|
234
|
-
// col is 1-based;
|
|
235
|
-
|
|
341
|
+
// col is 1-based; outer border consumes col 1, so left pane spans cols 2..(lw+1).
|
|
342
|
+
// Setting newLeft = col - 1 keeps the divider at the user's cursor.
|
|
343
|
+
const newLeft = Math.max(minW, Math.min(col - 1, cols - 2 - sepW - minW));
|
|
236
344
|
setLeftWidth(newLeft);
|
|
237
345
|
return;
|
|
238
346
|
}
|
|
@@ -241,8 +349,42 @@ export default function Dashboard() {
|
|
|
241
349
|
const s = stateRef.current;
|
|
242
350
|
const lw = leftWidthRef.current;
|
|
243
351
|
const delta = button === 65 ? 3 : -3;
|
|
352
|
+
// Diff overlay: file navigation (left pane) or content scroll (right pane)
|
|
353
|
+
if (s.overlay === "diff") {
|
|
354
|
+
const cols = stdout?.columns ?? 80;
|
|
355
|
+
const rowsRem = stdout?.rows ?? 24;
|
|
356
|
+
// contentHeight = total - dashboard header (1) - tab bar (1) - bottom margin (0)
|
|
357
|
+
const contentHeight = Math.max(3, rowsRem - 5);
|
|
358
|
+
const layout = computeDiffLayout({
|
|
359
|
+
width: Math.max(40, cols - 2), // outer box border eats 1 col on each side
|
|
360
|
+
height: contentHeight,
|
|
361
|
+
files: s.diffFiles,
|
|
362
|
+
fileIndex: s.diffFileIndex,
|
|
363
|
+
fileScrollOffset: s.diffFileScrollOffset,
|
|
364
|
+
});
|
|
365
|
+
// Body's first line is at absolute row 6 (title + tab + top border + overlay title + rule)
|
|
366
|
+
const bodyRow = row - 6;
|
|
367
|
+
if (bodyRow < 0 || bodyRow >= layout.bodyHeight)
|
|
368
|
+
return;
|
|
369
|
+
if (col <= layout.leftWidth) {
|
|
370
|
+
const maxIdx = s.diffFiles.length - 1;
|
|
371
|
+
if (maxIdx < 0)
|
|
372
|
+
return;
|
|
373
|
+
const next = Math.max(0, Math.min(s.diffFileIndex + delta, maxIdx));
|
|
374
|
+
dispatch({ type: "DIFF_FILE_SELECT", index: next });
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
const totalLines = s.diffContent ? s.diffContent.split("\n").length : 0;
|
|
378
|
+
const maxScroll = Math.max(0, totalLines - layout.bodyHeight);
|
|
379
|
+
const next = Math.max(0, Math.min(maxScroll, s.diffContentScrollOffset + delta));
|
|
380
|
+
dispatch({ type: "DIFF_CONTENT_SCROLL", offset: next });
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
// Outer border at col 1; left pane spans cols 2..(lw+1).
|
|
385
|
+
const inLeftPane = col >= 2 && col <= lw + 1;
|
|
244
386
|
if (s.activeTab === "reviews") {
|
|
245
|
-
if (
|
|
387
|
+
if (inLeftPane) {
|
|
246
388
|
const maxIdx = s.flatReviews.length - 1;
|
|
247
389
|
if (maxIdx < 0)
|
|
248
390
|
return;
|
|
@@ -255,7 +397,7 @@ export default function Dashboard() {
|
|
|
255
397
|
}
|
|
256
398
|
return;
|
|
257
399
|
}
|
|
258
|
-
if (
|
|
400
|
+
if (inLeftPane) {
|
|
259
401
|
// Scroll left pane (issue list)
|
|
260
402
|
const maxIdx = s.flatIssues.length - 1;
|
|
261
403
|
if (maxIdx < 0)
|
|
@@ -272,22 +414,50 @@ export default function Dashboard() {
|
|
|
272
414
|
}
|
|
273
415
|
if (!isPress)
|
|
274
416
|
return;
|
|
417
|
+
// Diff overlay click: select file row in left pane
|
|
418
|
+
{
|
|
419
|
+
const s = stateRef.current;
|
|
420
|
+
if (s.overlay === "diff") {
|
|
421
|
+
const cols = stdout?.columns ?? 80;
|
|
422
|
+
const rowsRem = stdout?.rows ?? 24;
|
|
423
|
+
const contentHeight = Math.max(3, rowsRem - 5);
|
|
424
|
+
const layout = computeDiffLayout({
|
|
425
|
+
width: Math.max(40, cols - 2), // outer box border eats 1 col on each side
|
|
426
|
+
height: contentHeight,
|
|
427
|
+
files: s.diffFiles,
|
|
428
|
+
fileIndex: s.diffFileIndex,
|
|
429
|
+
fileScrollOffset: s.diffFileScrollOffset,
|
|
430
|
+
});
|
|
431
|
+
if (col > layout.leftWidth)
|
|
432
|
+
return;
|
|
433
|
+
const bodyRow = row - 6;
|
|
434
|
+
if (bodyRow < 0 || bodyRow >= layout.bodyHeight)
|
|
435
|
+
return;
|
|
436
|
+
const absRowIdx = layout.effectiveScroll + bodyRow;
|
|
437
|
+
const clickedRow = layout.rows[absRowIdx];
|
|
438
|
+
if (clickedRow && clickedRow.fileIndex !== null) {
|
|
439
|
+
dispatch({ type: "DIFF_FILE_SELECT", index: clickedRow.fileIndex });
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
275
444
|
// Left-click press: check if on divider to start drag
|
|
445
|
+
// Outer border is at col 1; left pane spans cols 2..(lw+1); divider spans (lw+2)..(lw+1+sepW).
|
|
276
446
|
const lw = leftWidthRef.current;
|
|
277
|
-
const divStart = lw +
|
|
278
|
-
const divEnd = lw +
|
|
447
|
+
const divStart = lw + 2;
|
|
448
|
+
const divEnd = lw + 1 + sepW;
|
|
279
449
|
if (col >= divStart && col <= divEnd) {
|
|
280
450
|
draggingRef.current = true;
|
|
281
451
|
return;
|
|
282
452
|
}
|
|
283
|
-
// Left-click press: select item in left pane
|
|
453
|
+
// Left-click press: select item in left pane (cols 2..lw+1)
|
|
284
454
|
const s = stateRef.current;
|
|
285
455
|
if (s.loading || s.error)
|
|
286
456
|
return;
|
|
287
|
-
if (col > lw)
|
|
457
|
+
if (col < 2 || col > lw + 1)
|
|
288
458
|
return;
|
|
289
|
-
// Row 1
|
|
290
|
-
const contentRow = row -
|
|
459
|
+
// Row 1 = title, row 2 = tab strip, row 3 = top border, content starts at row 4 (1-based)
|
|
460
|
+
const contentRow = row - 4; // 0-based row within content area
|
|
291
461
|
if (contentRow < 0)
|
|
292
462
|
return;
|
|
293
463
|
if (s.activeTab === "reviews") {
|
|
@@ -304,7 +474,7 @@ export default function Dashboard() {
|
|
|
304
474
|
if (s.flatIssues.length === 0)
|
|
305
475
|
return;
|
|
306
476
|
const listRow = s.listScrollOffset + contentRow;
|
|
307
|
-
const flatIdx = getFlatIndexForListRow(s.groups, listRow);
|
|
477
|
+
const flatIdx = getFlatIndexForListRow(s.groups, s.flatIssues, listRow);
|
|
308
478
|
if (flatIdx !== null && flatIdx >= 0 && flatIdx < s.flatIssues.length) {
|
|
309
479
|
dispatch({ type: "SELECT", index: flatIdx });
|
|
310
480
|
}
|
|
@@ -332,7 +502,7 @@ export default function Dashboard() {
|
|
|
332
502
|
}, [refresh]);
|
|
333
503
|
// ── List scroll tracking ──────────────────────────────────────────
|
|
334
504
|
useEffect(() => {
|
|
335
|
-
const rowIdx = getRowIndexForFlatIndex(state.groups, state.selectedIndex);
|
|
505
|
+
const rowIdx = getRowIndexForFlatIndex(state.groups, state.flatIssues, state.selectedIndex);
|
|
336
506
|
const maxVisible = contentHeight - LIST_FOOTER_HEIGHT;
|
|
337
507
|
let offset = state.listScrollOffset;
|
|
338
508
|
if (rowIdx < offset) {
|
|
@@ -377,6 +547,73 @@ export default function Dashboard() {
|
|
|
377
547
|
process.stdout.write("\x1b[?1002h\x1b[?1006h");
|
|
378
548
|
};
|
|
379
549
|
}, [state.overlay, state.contextInputPhase, state.prCreatePhase]);
|
|
550
|
+
// ── Diff overlay: load file list when opened ──────────────────────
|
|
551
|
+
// Resolves merge-base against the configured base branch so upstream-only
|
|
552
|
+
// changes (commits on master we haven't pulled) are excluded — same semantics
|
|
553
|
+
// as a GitHub PR diff.
|
|
554
|
+
useEffect(() => {
|
|
555
|
+
if (state.overlay !== "diff" || !state.diffWorktreePath || !state.diffBaseBranch)
|
|
556
|
+
return;
|
|
557
|
+
if (!state.diffLoadingFiles)
|
|
558
|
+
return;
|
|
559
|
+
const cwd = state.diffWorktreePath;
|
|
560
|
+
const base = state.diffBaseBranch;
|
|
561
|
+
void (async () => {
|
|
562
|
+
try {
|
|
563
|
+
const { stdout: mergeBaseOut } = await execAsync(`git -C "${cwd}" merge-base "${base}" HEAD`);
|
|
564
|
+
const mergeBase = mergeBaseOut.trim() || base;
|
|
565
|
+
const { stdout } = await execAsync(`git -C "${cwd}" diff --name-status "${mergeBase}"`);
|
|
566
|
+
const files = parseNameStatus(stdout);
|
|
567
|
+
const ordered = flattenTreeFiles(files);
|
|
568
|
+
dispatch({ type: "DIFF_FILES_LOADED", files: ordered, mergeBase });
|
|
569
|
+
}
|
|
570
|
+
catch (err) {
|
|
571
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
572
|
+
dispatch({ type: "DIFF_FILES_ERROR", error: msg });
|
|
573
|
+
}
|
|
574
|
+
})();
|
|
575
|
+
}, [state.overlay, state.diffWorktreePath, state.diffBaseBranch, state.diffLoadingFiles]);
|
|
576
|
+
// ── Diff overlay: load content for selected file ──────────────────
|
|
577
|
+
// If SANTREE_DIFF_TOOL is set, pipe `git diff` output through the tool so
|
|
578
|
+
// the user's preferred renderer (delta, diff-so-fancy, etc.) handles
|
|
579
|
+
// colorization. The tool's ANSI output is then rendered as-is by Ink.
|
|
580
|
+
useEffect(() => {
|
|
581
|
+
if (state.overlay !== "diff" || !state.diffWorktreePath || !state.diffMergeBase)
|
|
582
|
+
return;
|
|
583
|
+
const file = state.diffFiles[state.diffFileIndex];
|
|
584
|
+
if (!file)
|
|
585
|
+
return;
|
|
586
|
+
const cwd = state.diffWorktreePath;
|
|
587
|
+
const mergeBase = state.diffMergeBase;
|
|
588
|
+
const tool = getDiffTool();
|
|
589
|
+
dispatch({ type: "DIFF_CONTENT_LOADING" });
|
|
590
|
+
void (async () => {
|
|
591
|
+
try {
|
|
592
|
+
if (tool) {
|
|
593
|
+
// Pipe git diff (with colors enabled so the tool can pass them
|
|
594
|
+
// through if desired) into the configured tool. Use spawn pipes
|
|
595
|
+
// rather than shell to avoid quoting concerns.
|
|
596
|
+
const content = await runPipedDiff(cwd, mergeBase, file.path, tool);
|
|
597
|
+
dispatch({ type: "DIFF_CONTENT_LOADED", content });
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
// No external tool — get raw unified diff and render colors ourselves.
|
|
601
|
+
const { stdout } = await execAsync(`git -C "${cwd}" diff --no-color "${mergeBase}" -- "${file.path}"`, { maxBuffer: 32 * 1024 * 1024 });
|
|
602
|
+
dispatch({ type: "DIFF_CONTENT_LOADED", content: stdout });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
catch (err) {
|
|
606
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
607
|
+
dispatch({ type: "DIFF_CONTENT_LOADED", content: `Error loading diff: ${msg}` });
|
|
608
|
+
}
|
|
609
|
+
})();
|
|
610
|
+
}, [
|
|
611
|
+
state.overlay,
|
|
612
|
+
state.diffWorktreePath,
|
|
613
|
+
state.diffMergeBase,
|
|
614
|
+
state.diffFileIndex,
|
|
615
|
+
state.diffFiles,
|
|
616
|
+
]);
|
|
380
617
|
// ── Actions ───────────────────────────────────────────────────────
|
|
381
618
|
const launchWorkInTmux = useCallback(async (di, mode, worktreePath, contextFile) => {
|
|
382
619
|
const windowName = di.issue.identifier;
|
|
@@ -1036,6 +1273,64 @@ export default function Dashboard() {
|
|
|
1036
1273
|
}
|
|
1037
1274
|
return;
|
|
1038
1275
|
}
|
|
1276
|
+
// Diff overlay
|
|
1277
|
+
if (state.overlay === "diff") {
|
|
1278
|
+
if (key.escape || input === "q") {
|
|
1279
|
+
dispatch({ type: "DIFF_CLOSE" });
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
const fileCount = state.diffFiles.length;
|
|
1283
|
+
if (fileCount === 0)
|
|
1284
|
+
return;
|
|
1285
|
+
// Compute max scroll so we never scroll past the end of the diff.
|
|
1286
|
+
const cols = stdout?.columns ?? 80;
|
|
1287
|
+
const rowsRem = stdout?.rows ?? 24;
|
|
1288
|
+
const contentHeight = Math.max(3, rowsRem - 2);
|
|
1289
|
+
const layout = computeDiffLayout({
|
|
1290
|
+
width: Math.max(40, cols - 2), // outer box border eats 1 col on each side
|
|
1291
|
+
height: contentHeight,
|
|
1292
|
+
files: state.diffFiles,
|
|
1293
|
+
fileIndex: state.diffFileIndex,
|
|
1294
|
+
fileScrollOffset: state.diffFileScrollOffset,
|
|
1295
|
+
});
|
|
1296
|
+
const totalLines = state.diffContent ? state.diffContent.split("\n").length : 0;
|
|
1297
|
+
const maxScroll = Math.max(0, totalLines - layout.bodyHeight);
|
|
1298
|
+
// Scroll diff content (J/K or shift+arrows)
|
|
1299
|
+
if ((key.shift && key.downArrow) || input === "J") {
|
|
1300
|
+
dispatch({
|
|
1301
|
+
type: "DIFF_CONTENT_SCROLL",
|
|
1302
|
+
offset: Math.min(maxScroll, state.diffContentScrollOffset + 5),
|
|
1303
|
+
});
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
if ((key.shift && key.upArrow) || input === "K") {
|
|
1307
|
+
dispatch({
|
|
1308
|
+
type: "DIFF_CONTENT_SCROLL",
|
|
1309
|
+
offset: Math.max(0, state.diffContentScrollOffset - 5),
|
|
1310
|
+
});
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
if (input === "g") {
|
|
1314
|
+
dispatch({ type: "DIFF_CONTENT_SCROLL", offset: 0 });
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
if (input === "G") {
|
|
1318
|
+
dispatch({ type: "DIFF_CONTENT_SCROLL", offset: maxScroll });
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
// Navigate file list (j/k or arrows)
|
|
1322
|
+
if (input === "j" || (key.downArrow && !key.shift)) {
|
|
1323
|
+
const next = Math.min(state.diffFileIndex + 1, fileCount - 1);
|
|
1324
|
+
dispatch({ type: "DIFF_FILE_SELECT", index: next });
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
if (input === "k" || (key.upArrow && !key.shift)) {
|
|
1328
|
+
const prev = Math.max(state.diffFileIndex - 1, 0);
|
|
1329
|
+
dispatch({ type: "DIFF_FILE_SELECT", index: prev });
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1039
1334
|
// Confirm delete overlay
|
|
1040
1335
|
if (state.overlay === "confirm-delete") {
|
|
1041
1336
|
if (input === "y") {
|
|
@@ -1452,9 +1747,12 @@ export default function Dashboard() {
|
|
|
1452
1747
|
openInEditor(di.worktree.path);
|
|
1453
1748
|
return;
|
|
1454
1749
|
}
|
|
1455
|
-
// Open workspace
|
|
1750
|
+
// Open workspace — no-op unless the editor accepts a .code-workspace
|
|
1751
|
+
// file and one exists. Keeps the keybinding from firing surprises on
|
|
1752
|
+
// editors like zed/nvim that don't have the concept.
|
|
1456
1753
|
if (input === "E") {
|
|
1457
|
-
|
|
1754
|
+
if (hasWorkspaceFile)
|
|
1755
|
+
openWorkspace();
|
|
1458
1756
|
return;
|
|
1459
1757
|
}
|
|
1460
1758
|
// Commit & push
|
|
@@ -1507,6 +1805,21 @@ export default function Dashboard() {
|
|
|
1507
1805
|
}
|
|
1508
1806
|
return;
|
|
1509
1807
|
}
|
|
1808
|
+
// View diff (inline overlay)
|
|
1809
|
+
if (input === "v") {
|
|
1810
|
+
if (!di.worktree) {
|
|
1811
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to diff" });
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
const baseBranch = getBaseBranch(di.worktree.branch);
|
|
1815
|
+
dispatch({
|
|
1816
|
+
type: "DIFF_OPEN",
|
|
1817
|
+
ticketId: di.issue.identifier,
|
|
1818
|
+
worktreePath: di.worktree.path,
|
|
1819
|
+
baseBranch,
|
|
1820
|
+
});
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1510
1823
|
// Delete worktree
|
|
1511
1824
|
if (input === "d") {
|
|
1512
1825
|
if (!di.worktree) {
|
|
@@ -1530,16 +1843,33 @@ export default function Dashboard() {
|
|
|
1530
1843
|
}
|
|
1531
1844
|
const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
|
|
1532
1845
|
const selectedReview = state.flatReviews[state.reviewSelectedIndex] ?? null;
|
|
1533
|
-
return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "
|
|
1846
|
+
return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "santree" }), _jsxs(Text, { dimColor: true, children: [" ", "v", version] }), updateAvailable && latestVersion ? (_jsxs(Text, { color: "yellow", children: [" ⬆ v", latestVersion, " available — `santree update`"] })) : null, CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" · claude ", CLAUDE_VERSION] })) : null, claudeUpdateAvailable && latestClaudeVersion ? (_jsxs(Text, { color: "yellow", children: [" ⬆ ", latestClaudeVersion] })) : null, state.refreshing ? _jsx(Text, { dimColor: true, children: " · refreshing…" }) : null, state.actionMessage ? (_jsxs(Text, { color: "yellow", children: [" · ", state.actionMessage] })) : null] }), _jsxs(Box, { paddingX: 1, children: [_jsx(Tab, { active: state.activeTab === "issues", label: `1 Issues (${state.flatIssues.length})`, mode: theme.mode }), _jsx(Text, { children: " " }), _jsx(Tab, { active: state.activeTab === "reviews", label: `2 Reviews (${state.flatReviews.length})`, mode: theme.mode })] }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", children: [state.overlay === "mode-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select mode:" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "p" }), " Plan"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "i" }), " Implement"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }) })) : state.overlay === "context-input" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", paddingX: 2, width: Math.min(columns - 8, 100), children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Extra context for ", state.contextInputMode] }), _jsx(Text, { dimColor: true, children: "Optional \u2014 appended to the prompt before launching Claude" }), _jsx(Text, { children: " " }), state.contextInputPhase === "editing" ? (_jsxs(_Fragment, { children: [_jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => dispatch({ type: "CONTEXT_INPUT_REVIEW" }), onCancel: () => dispatch({ type: "CONTEXT_INPUT_DONE" }), width: Math.min(columns - 8, 100), height: 10, placeholder: "Type or paste extra context\u2026" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " send · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+C" }), " cancel"] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, minHeight: 6, children: [(state.contextInputValue || "(no extra context)")
|
|
1847
|
+
.split("\n")
|
|
1848
|
+
.slice(0, 12)
|
|
1849
|
+
.map((line, i) => (_jsx(Text, { children: line || " " }, i))), state.contextInputValue.split("\n").length > 12 && (_jsxs(Text, { dimColor: true, children: ["\u2026+", state.contextInputValue.split("\n").length - 12, " more lines"] }))] }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Anything else to add?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " / ", _jsx(Text, { color: "green", bold: true, children: "Enter" }), " launch ", _jsx(Text, { color: "yellow", bold: true, children: "n" }), " / ", _jsx(Text, { color: "yellow", bold: true, children: "e" }), " keep editing ", _jsx(Text, { color: "red", bold: true, children: "ESC" }), " cancel"] })] }))] }) })) : state.overlay === "base-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select base branch:" }), _jsx(Text, { children: " " }), state.baseSelectOptions.map((branch, idx) => {
|
|
1850
|
+
const selected = idx === state.baseSelectIndex;
|
|
1851
|
+
const defaultBranch = getDefaultBranch();
|
|
1852
|
+
const label = branch === defaultBranch ? `${branch} (default)` : branch;
|
|
1853
|
+
return (_jsx(Text, { children: _jsxs(Text, { color: selected ? "cyan" : undefined, bold: selected, children: [selected ? "> " : " ", label] }) }, branch));
|
|
1854
|
+
}), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "j/k to navigate, Enter to select, ESC to cancel" })] }) })) : state.overlay === "confirm-delete" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Remove worktree?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: selectedIssue?.worktree?.branch ?? "" }), selectedIssue?.worktree?.dirty && (_jsx(Text, { color: "yellow", children: "Warning: worktree has uncommitted changes" })), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "red", bold: true, children: "y" }), " Confirm"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "n" }), " Cancel"] })] }) })) : state.overlay === "diff" ? (_jsx(DiffOverlay, { width: innerWidth, height: contentHeight, ticketId: state.diffTicketId ?? "", baseBranch: state.diffBaseBranch ?? "", files: state.diffFiles, fileIndex: state.diffFileIndex, fileScrollOffset: state.diffFileScrollOffset, content: state.diffContent, contentScrollOffset: state.diffContentScrollOffset, loadingFiles: state.diffLoadingFiles, loadingContent: state.diffLoadingContent, error: state.diffError, selectionBg: theme.selectionBg })) : state.overlay === "confirm-setup" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Run setup script?" }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: ".santree/init.sh" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " Run setup"] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", bold: true, children: "n" }), " Skip"] })] }) })) : (_jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: leftWidth, children: state.activeTab === "reviews" ? (_jsx(ReviewList, { flatReviews: state.flatReviews, selectedIndex: state.reviewSelectedIndex, scrollOffset: state.reviewListScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg })) : state.flatIssues.length === 0 ? (_jsx(Box, { width: leftWidth, height: contentHeight, justifyContent: "center", alignItems: "center", children: _jsx(Text, { color: "yellow", children: "No active issues" }) })) : (_jsx(IssueList, { groups: state.groups, flatIssues: state.flatIssues, selectedIndex: state.selectedIndex, scrollOffset: state.listScrollOffset, height: contentHeight, width: leftWidth, selectionBg: theme.selectionBg })) }), _jsx(Box, { flexDirection: "column", width: 3, children: Array.from({ length: contentHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: " │ " }, i))) }), _jsx(Box, { width: rightWidth, children: state.activeTab === "reviews" && state.creatingForTicket ? (_jsxs(Box, { flexDirection: "column", width: rightWidth, height: contentHeight, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Setting up worktree for ", state.creatingForTicket, "..."] }), state.creationLogs
|
|
1534
1855
|
.split("\n")
|
|
1535
|
-
.slice(
|
|
1536
|
-
.map((line, i) => (_jsx(
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1856
|
+
.slice(-(contentHeight - 1))
|
|
1857
|
+
.map((line, i) => (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: line }) }, i)))] })) : state.activeTab === "reviews" ? (_jsx(ReviewDetailPanel, { item: selectedReview, scrollOffset: state.reviewDetailScrollOffset, height: contentHeight, width: rightWidth })) : state.overlay === "commit" ? (_jsx(CommitOverlay, { width: rightWidth, height: contentHeight, branch: state.commitBranch, ticketId: state.commitTicketId, gitStatus: state.commitGitStatus, phase: state.commitPhase, message: state.commitMessage, error: state.commitError, dispatch: dispatch, onSubmit: handleCommitSubmit })) : state.overlay === "pr-create" ? (_jsx(PrCreateOverlay, { width: rightWidth, height: contentHeight, branch: state.prCreateBranch, ticketId: state.prCreateTicketId, phase: state.prCreatePhase, error: state.prCreateError, url: state.prCreateUrl, body: state.prCreateBody, title: state.prCreateTitle, dispatch: dispatch })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] })), _jsxs(Box, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview }) })] })] })] }));
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* Renders the per-issue action key hints (Resume / Editor / View diff / …)
|
|
1861
|
+
* lifted out of the detail panels so they sit on the same row as the global
|
|
1862
|
+
* command bar. Empty when nothing is selected.
|
|
1863
|
+
*/
|
|
1864
|
+
function ActionRow({ activeTab, selectedIssue, selectedReview, }) {
|
|
1865
|
+
const items = activeTab === "reviews"
|
|
1866
|
+
? selectedReview
|
|
1867
|
+
? buildReviewActions(selectedReview)
|
|
1868
|
+
: []
|
|
1869
|
+
: selectedIssue
|
|
1870
|
+
? buildIssueActions(selectedIssue)
|
|
1871
|
+
: [];
|
|
1872
|
+
if (items.length === 0)
|
|
1873
|
+
return _jsx(Text, { children: " " });
|
|
1874
|
+
return (_jsx(Text, { children: items.map((item, j) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: item.color, bold: true, children: item.key }), _jsxs(Text, { color: item.color === "gray" ? "gray" : undefined, children: [" ", item.label] })] }, j))) }));
|
|
1545
1875
|
}
|