santree 0.4.0 → 0.5.1
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 +465 -97
- 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 +61 -0
- package/dist/lib/dashboard/DiffOverlay.js +262 -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 +7 -7
- 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
|
@@ -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, clampDiffLeftWidth, DIFF_DIVIDER_WIDTH, } 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,21 +174,81 @@ 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). When the diff overlay is active, the keymap switches to
|
|
197
|
+
* diff-specific bindings since the global ones don't apply.
|
|
198
|
+
*/
|
|
199
|
+
function CommandBar({ showWorkspace, mode = "default", }) {
|
|
200
|
+
const dot = _jsx(Text, { dimColor: true, children: " · " });
|
|
201
|
+
const Key = ({ k }) => (_jsx(Text, { color: "cyan", bold: true, children: k }));
|
|
202
|
+
if (mode === "diff") {
|
|
203
|
+
return (_jsxs(Text, { children: [_jsx(Key, { k: "j/k" }), _jsx(Text, { dimColor: true, children: " file" }), dot, _jsx(Key, { k: "\u21E7\u2191\u2193" }), _jsx(Text, { dimColor: true, children: " scroll" }), dot, _jsx(Key, { k: "g/G" }), _jsx(Text, { dimColor: true, children: " top/bot" }), dot, _jsx(Key, { k: "q" }), _jsx(Text, { dimColor: true, children: " close" })] }));
|
|
204
|
+
}
|
|
205
|
+
return (_jsxs(Text, { children: [_jsx(Key, { k: "j/k" }), _jsx(Text, { dimColor: true, children: " nav" }), dot, _jsx(Key, { k: "\u21E7\u2191\u2193" }), _jsx(Text, { dimColor: true, children: " scroll" }), dot, _jsx(Key, { k: "1/2" }), _jsx(Text, { dimColor: true, children: " tabs" }), showWorkspace ? (_jsxs(_Fragment, { children: [dot, _jsx(Key, { k: "E" }), _jsx(Text, { dimColor: true, children: " workspace" })] })) : null, dot, _jsx(Key, { k: "R" }), _jsx(Text, { dimColor: true, children: " refresh" }), dot, _jsx(Key, { k: "q" }), _jsx(Text, { dimColor: true, children: " quit" })] }));
|
|
206
|
+
}
|
|
147
207
|
// ── Component ─────────────────────────────────────────────────────────
|
|
148
208
|
export default function Dashboard() {
|
|
149
209
|
ensureAltScreen();
|
|
150
210
|
const { exit } = useApp();
|
|
151
211
|
const { stdout } = useStdout();
|
|
152
212
|
const [state, dispatch] = useReducer(reducer, initialState);
|
|
213
|
+
// Theme is a visual concern only — kept outside the reducer so re-detection
|
|
214
|
+
// on refresh doesn't churn data flow. Defaults to dark; replaced by OSC 11
|
|
215
|
+
// detection on mount and on every refresh.
|
|
216
|
+
const [theme, setTheme] = useState(getThemeForMode("dark"));
|
|
217
|
+
// `E workspace` is only meaningful when the user's editor accepts a
|
|
218
|
+
// `.code-workspace` file (VSCode/Cursor) AND such a file exists in the
|
|
219
|
+
// repo root. Recomputed alongside the data refresh.
|
|
220
|
+
const [hasWorkspaceFile, setHasWorkspaceFile] = useState(false);
|
|
153
221
|
const refreshTimerRef = useRef(null);
|
|
154
222
|
const repoRootRef = useRef(null);
|
|
155
223
|
const stateRef = useRef(state);
|
|
156
224
|
stateRef.current = state;
|
|
157
|
-
const draggingRef = useRef(
|
|
225
|
+
const draggingRef = useRef(null);
|
|
158
226
|
const [termSize, setTermSize] = useState({
|
|
159
227
|
columns: stdout?.columns ?? 80,
|
|
160
228
|
rows: stdout?.rows ?? 24,
|
|
161
229
|
});
|
|
230
|
+
// Show cached values immediately so the banner appears on first paint when
|
|
231
|
+
// known-stale; refresh in the background without blocking dashboard load.
|
|
232
|
+
const [latestVersion, setLatestVersion] = useState(getCachedLatestVersion);
|
|
233
|
+
const [latestClaudeVersion, setLatestClaudeVersion] = useState(() => getCachedLatestVersionFor(CLAUDE_CODE_PACKAGE));
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
let cancelled = false;
|
|
236
|
+
getLatestVersion().then((v) => {
|
|
237
|
+
if (!cancelled && v)
|
|
238
|
+
setLatestVersion(v);
|
|
239
|
+
});
|
|
240
|
+
getLatestVersionFor(CLAUDE_CODE_PACKAGE).then((v) => {
|
|
241
|
+
if (!cancelled && v)
|
|
242
|
+
setLatestClaudeVersion(v);
|
|
243
|
+
});
|
|
244
|
+
return () => {
|
|
245
|
+
cancelled = true;
|
|
246
|
+
};
|
|
247
|
+
}, []);
|
|
248
|
+
const updateAvailable = latestVersion ? isUpdateAvailable(CURRENT_VERSION, latestVersion) : false;
|
|
249
|
+
const claudeUpdateAvailable = !!CLAUDE_VERSION &&
|
|
250
|
+
!!latestClaudeVersion &&
|
|
251
|
+
isUpdateAvailable(CLAUDE_VERSION, latestClaudeVersion);
|
|
162
252
|
useEffect(() => {
|
|
163
253
|
const onResize = () => {
|
|
164
254
|
setTermSize({
|
|
@@ -173,12 +263,21 @@ export default function Dashboard() {
|
|
|
173
263
|
}, [stdout]);
|
|
174
264
|
const { columns, rows } = termSize;
|
|
175
265
|
const separatorWidth = 3;
|
|
176
|
-
const
|
|
266
|
+
const innerWidth = Math.max(40, columns - 2); // outer border consumes 1 col on each side
|
|
267
|
+
const [leftWidth, setLeftWidth] = useState(Math.floor(innerWidth * 0.42));
|
|
177
268
|
const leftWidthRef = useRef(leftWidth);
|
|
178
269
|
leftWidthRef.current = leftWidth;
|
|
179
|
-
const rightWidth =
|
|
180
|
-
|
|
181
|
-
|
|
270
|
+
const rightWidth = innerWidth - leftWidth - separatorWidth;
|
|
271
|
+
// Diff overlay's left pane width — null means "use the default formula"
|
|
272
|
+
// (computed inside computeDiffLayout). Becomes a number once the user drags
|
|
273
|
+
// the divider, and persists across overlay open/close while the dashboard
|
|
274
|
+
// session is alive.
|
|
275
|
+
const [diffLeftWidth, setDiffLeftWidth] = useState(null);
|
|
276
|
+
const diffLeftWidthRef = useRef(diffLeftWidth);
|
|
277
|
+
diffLeftWidthRef.current = diffLeftWidth;
|
|
278
|
+
// Header (1) + tab strip (1) + 2 borders + command bar (1, inside box) = 5 rows
|
|
279
|
+
const contentHeight = Math.max(3, rows - 5);
|
|
280
|
+
const LIST_FOOTER_HEIGHT = 0;
|
|
182
281
|
// ── Data loading ──────────────────────────────────────────────────
|
|
183
282
|
const refresh = useCallback(async (isInitial = false) => {
|
|
184
283
|
if (!isInitial)
|
|
@@ -190,10 +289,29 @@ export default function Dashboard() {
|
|
|
190
289
|
}
|
|
191
290
|
repoRootRef.current = repoRoot;
|
|
192
291
|
try {
|
|
193
|
-
|
|
292
|
+
// Re-detect terminal theme alongside data fetch so light↔dark switches
|
|
293
|
+
// propagate within one refresh cycle (≤30s).
|
|
294
|
+
const [data, reviewData, themeMode] = await Promise.all([
|
|
194
295
|
loadDashboardData(repoRoot),
|
|
195
296
|
loadReviewsData(repoRoot),
|
|
297
|
+
detectTerminalTheme(),
|
|
196
298
|
]);
|
|
299
|
+
setTheme(getThemeForMode(themeMode));
|
|
300
|
+
// Workspace file presence — only meaningful when the editor consumes
|
|
301
|
+
// `.code-workspace` files. Cheap directory read; recomputed each cycle
|
|
302
|
+
// in case the user adds/removes one.
|
|
303
|
+
const editor = (process.env.SANTREE_EDITOR ?? "code").toLowerCase();
|
|
304
|
+
const editorAcceptsWorkspace = editor === "code" || editor === "cursor";
|
|
305
|
+
let hasWs = false;
|
|
306
|
+
if (editorAcceptsWorkspace) {
|
|
307
|
+
try {
|
|
308
|
+
hasWs = fs.readdirSync(repoRoot).some((f) => f.endsWith(".code-workspace"));
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
hasWs = false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
setHasWorkspaceFile(hasWs);
|
|
197
315
|
dispatch({ type: "SET_DATA", ...data });
|
|
198
316
|
dispatch({ type: "SET_REVIEWS_DATA", flatReviews: reviewData.flatReviews });
|
|
199
317
|
}
|
|
@@ -226,13 +344,23 @@ export default function Dashboard() {
|
|
|
226
344
|
const sepW = 3;
|
|
227
345
|
// Release — stop dragging
|
|
228
346
|
if (isRelease && draggingRef.current) {
|
|
229
|
-
draggingRef.current =
|
|
347
|
+
draggingRef.current = null;
|
|
230
348
|
return;
|
|
231
349
|
}
|
|
232
350
|
// Drag — resize if actively dragging
|
|
233
351
|
if (isDrag && draggingRef.current) {
|
|
234
|
-
|
|
235
|
-
|
|
352
|
+
if (draggingRef.current === "diff") {
|
|
353
|
+
// DiffOverlay starts at abs col 2 with width=innerWidth; its
|
|
354
|
+
// 1-col divider sits at relative col (leftWidth+1) → abs col
|
|
355
|
+
// (leftWidth+2). Setting newLeft = col - 2 keeps it under the
|
|
356
|
+
// cursor; clampDiffLeftWidth enforces pane minimums.
|
|
357
|
+
const innerW = Math.max(40, cols - 2);
|
|
358
|
+
setDiffLeftWidth(clampDiffLeftWidth(col - 2, innerW));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// col is 1-based; outer border consumes col 1, so left pane spans cols 2..(lw+1).
|
|
362
|
+
// Setting newLeft = col - 1 keeps the divider at the user's cursor.
|
|
363
|
+
const newLeft = Math.max(minW, Math.min(col - 1, cols - 2 - sepW - minW));
|
|
236
364
|
setLeftWidth(newLeft);
|
|
237
365
|
return;
|
|
238
366
|
}
|
|
@@ -241,8 +369,45 @@ export default function Dashboard() {
|
|
|
241
369
|
const s = stateRef.current;
|
|
242
370
|
const lw = leftWidthRef.current;
|
|
243
371
|
const delta = button === 65 ? 3 : -3;
|
|
372
|
+
// Diff overlay: file navigation (left pane) or content scroll (right pane)
|
|
373
|
+
if (s.overlay === "diff") {
|
|
374
|
+
const cols = stdout?.columns ?? 80;
|
|
375
|
+
const rowsRem = stdout?.rows ?? 24;
|
|
376
|
+
// contentHeight = total - dashboard header (1) - tab bar (1) - bottom margin (0)
|
|
377
|
+
const contentHeight = Math.max(3, rowsRem - 5);
|
|
378
|
+
const layout = computeDiffLayout({
|
|
379
|
+
width: Math.max(40, cols - 2), // outer box border eats 1 col on each side
|
|
380
|
+
height: contentHeight,
|
|
381
|
+
files: s.diffFiles,
|
|
382
|
+
fileIndex: s.diffFileIndex,
|
|
383
|
+
fileScrollOffset: s.diffFileScrollOffset,
|
|
384
|
+
leftWidthOverride: diffLeftWidthRef.current ?? undefined,
|
|
385
|
+
});
|
|
386
|
+
// Body's first line is at absolute row 6 (title + tab + top border + overlay title + rule)
|
|
387
|
+
const bodyRow = row - 6;
|
|
388
|
+
if (bodyRow < 0 || bodyRow >= layout.bodyHeight)
|
|
389
|
+
return;
|
|
390
|
+
// DiffOverlay starts at abs col 2; left pane occupies abs cols
|
|
391
|
+
// 2..(leftWidth+1).
|
|
392
|
+
if (col <= layout.leftWidth + 1) {
|
|
393
|
+
const maxIdx = s.diffFiles.length - 1;
|
|
394
|
+
if (maxIdx < 0)
|
|
395
|
+
return;
|
|
396
|
+
const next = Math.max(0, Math.min(s.diffFileIndex + delta, maxIdx));
|
|
397
|
+
dispatch({ type: "DIFF_FILE_SELECT", index: next });
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
const totalLines = s.diffContent ? s.diffContent.split("\n").length : 0;
|
|
401
|
+
const maxScroll = Math.max(0, totalLines - layout.bodyHeight);
|
|
402
|
+
const next = Math.max(0, Math.min(maxScroll, s.diffContentScrollOffset + delta));
|
|
403
|
+
dispatch({ type: "DIFF_CONTENT_SCROLL", offset: next });
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
// Outer border at col 1; left pane spans cols 2..(lw+1).
|
|
408
|
+
const inLeftPane = col >= 2 && col <= lw + 1;
|
|
244
409
|
if (s.activeTab === "reviews") {
|
|
245
|
-
if (
|
|
410
|
+
if (inLeftPane) {
|
|
246
411
|
const maxIdx = s.flatReviews.length - 1;
|
|
247
412
|
if (maxIdx < 0)
|
|
248
413
|
return;
|
|
@@ -255,7 +420,7 @@ export default function Dashboard() {
|
|
|
255
420
|
}
|
|
256
421
|
return;
|
|
257
422
|
}
|
|
258
|
-
if (
|
|
423
|
+
if (inLeftPane) {
|
|
259
424
|
// Scroll left pane (issue list)
|
|
260
425
|
const maxIdx = s.flatIssues.length - 1;
|
|
261
426
|
if (maxIdx < 0)
|
|
@@ -272,22 +437,59 @@ export default function Dashboard() {
|
|
|
272
437
|
}
|
|
273
438
|
if (!isPress)
|
|
274
439
|
return;
|
|
440
|
+
// Diff overlay click: drag divider, or select file row in left pane
|
|
441
|
+
{
|
|
442
|
+
const s = stateRef.current;
|
|
443
|
+
if (s.overlay === "diff") {
|
|
444
|
+
const cols = stdout?.columns ?? 80;
|
|
445
|
+
const rowsRem = stdout?.rows ?? 24;
|
|
446
|
+
const contentHeight = Math.max(3, rowsRem - 5);
|
|
447
|
+
const layout = computeDiffLayout({
|
|
448
|
+
width: Math.max(40, cols - 2), // outer box border eats 1 col on each side
|
|
449
|
+
height: contentHeight,
|
|
450
|
+
files: s.diffFiles,
|
|
451
|
+
fileIndex: s.diffFileIndex,
|
|
452
|
+
fileScrollOffset: s.diffFileScrollOffset,
|
|
453
|
+
leftWidthOverride: diffLeftWidthRef.current ?? undefined,
|
|
454
|
+
});
|
|
455
|
+
// Divider sits at abs col leftWidth+2 (DiffOverlay starts at
|
|
456
|
+
// abs col 2; divider at relative col leftWidth+1). Allow ±1
|
|
457
|
+
// tolerance — a 1-col target is hard to hit precisely.
|
|
458
|
+
const diffDivAbsCol = layout.leftWidth + 2;
|
|
459
|
+
if (col >= diffDivAbsCol - 1 && col <= diffDivAbsCol - 1 + DIFF_DIVIDER_WIDTH + 1) {
|
|
460
|
+
draggingRef.current = "diff";
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (col > layout.leftWidth + 1)
|
|
464
|
+
return;
|
|
465
|
+
const bodyRow = row - 6;
|
|
466
|
+
if (bodyRow < 0 || bodyRow >= layout.bodyHeight)
|
|
467
|
+
return;
|
|
468
|
+
const absRowIdx = layout.effectiveScroll + bodyRow;
|
|
469
|
+
const clickedRow = layout.rows[absRowIdx];
|
|
470
|
+
if (clickedRow && clickedRow.fileIndex !== null) {
|
|
471
|
+
dispatch({ type: "DIFF_FILE_SELECT", index: clickedRow.fileIndex });
|
|
472
|
+
}
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
275
476
|
// Left-click press: check if on divider to start drag
|
|
477
|
+
// Outer border is at col 1; left pane spans cols 2..(lw+1); divider spans (lw+2)..(lw+1+sepW).
|
|
276
478
|
const lw = leftWidthRef.current;
|
|
277
|
-
const divStart = lw +
|
|
278
|
-
const divEnd = lw +
|
|
479
|
+
const divStart = lw + 2;
|
|
480
|
+
const divEnd = lw + 1 + sepW;
|
|
279
481
|
if (col >= divStart && col <= divEnd) {
|
|
280
|
-
draggingRef.current =
|
|
482
|
+
draggingRef.current = "main";
|
|
281
483
|
return;
|
|
282
484
|
}
|
|
283
|
-
// Left-click press: select item in left pane
|
|
485
|
+
// Left-click press: select item in left pane (cols 2..lw+1)
|
|
284
486
|
const s = stateRef.current;
|
|
285
487
|
if (s.loading || s.error)
|
|
286
488
|
return;
|
|
287
|
-
if (col > lw)
|
|
489
|
+
if (col < 2 || col > lw + 1)
|
|
288
490
|
return;
|
|
289
|
-
// Row 1
|
|
290
|
-
const contentRow = row -
|
|
491
|
+
// Row 1 = title, row 2 = tab strip, row 3 = top border, content starts at row 4 (1-based)
|
|
492
|
+
const contentRow = row - 4; // 0-based row within content area
|
|
291
493
|
if (contentRow < 0)
|
|
292
494
|
return;
|
|
293
495
|
if (s.activeTab === "reviews") {
|
|
@@ -304,7 +506,7 @@ export default function Dashboard() {
|
|
|
304
506
|
if (s.flatIssues.length === 0)
|
|
305
507
|
return;
|
|
306
508
|
const listRow = s.listScrollOffset + contentRow;
|
|
307
|
-
const flatIdx = getFlatIndexForListRow(s.groups, listRow);
|
|
509
|
+
const flatIdx = getFlatIndexForListRow(s.groups, s.flatIssues, listRow);
|
|
308
510
|
if (flatIdx !== null && flatIdx >= 0 && flatIdx < s.flatIssues.length) {
|
|
309
511
|
dispatch({ type: "SELECT", index: flatIdx });
|
|
310
512
|
}
|
|
@@ -332,7 +534,7 @@ export default function Dashboard() {
|
|
|
332
534
|
}, [refresh]);
|
|
333
535
|
// ── List scroll tracking ──────────────────────────────────────────
|
|
334
536
|
useEffect(() => {
|
|
335
|
-
const rowIdx = getRowIndexForFlatIndex(state.groups, state.selectedIndex);
|
|
537
|
+
const rowIdx = getRowIndexForFlatIndex(state.groups, state.flatIssues, state.selectedIndex);
|
|
336
538
|
const maxVisible = contentHeight - LIST_FOOTER_HEIGHT;
|
|
337
539
|
let offset = state.listScrollOffset;
|
|
338
540
|
if (rowIdx < offset) {
|
|
@@ -377,6 +579,73 @@ export default function Dashboard() {
|
|
|
377
579
|
process.stdout.write("\x1b[?1002h\x1b[?1006h");
|
|
378
580
|
};
|
|
379
581
|
}, [state.overlay, state.contextInputPhase, state.prCreatePhase]);
|
|
582
|
+
// ── Diff overlay: load file list when opened ──────────────────────
|
|
583
|
+
// Resolves merge-base against the configured base branch so upstream-only
|
|
584
|
+
// changes (commits on master we haven't pulled) are excluded — same semantics
|
|
585
|
+
// as a GitHub PR diff.
|
|
586
|
+
useEffect(() => {
|
|
587
|
+
if (state.overlay !== "diff" || !state.diffWorktreePath || !state.diffBaseBranch)
|
|
588
|
+
return;
|
|
589
|
+
if (!state.diffLoadingFiles)
|
|
590
|
+
return;
|
|
591
|
+
const cwd = state.diffWorktreePath;
|
|
592
|
+
const base = state.diffBaseBranch;
|
|
593
|
+
void (async () => {
|
|
594
|
+
try {
|
|
595
|
+
const { stdout: mergeBaseOut } = await execAsync(`git -C "${cwd}" merge-base "${base}" HEAD`);
|
|
596
|
+
const mergeBase = mergeBaseOut.trim() || base;
|
|
597
|
+
const { stdout } = await execAsync(`git -C "${cwd}" diff --name-status "${mergeBase}"`);
|
|
598
|
+
const files = parseNameStatus(stdout);
|
|
599
|
+
const ordered = flattenTreeFiles(files);
|
|
600
|
+
dispatch({ type: "DIFF_FILES_LOADED", files: ordered, mergeBase });
|
|
601
|
+
}
|
|
602
|
+
catch (err) {
|
|
603
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
604
|
+
dispatch({ type: "DIFF_FILES_ERROR", error: msg });
|
|
605
|
+
}
|
|
606
|
+
})();
|
|
607
|
+
}, [state.overlay, state.diffWorktreePath, state.diffBaseBranch, state.diffLoadingFiles]);
|
|
608
|
+
// ── Diff overlay: load content for selected file ──────────────────
|
|
609
|
+
// If SANTREE_DIFF_TOOL is set, pipe `git diff` output through the tool so
|
|
610
|
+
// the user's preferred renderer (delta, diff-so-fancy, etc.) handles
|
|
611
|
+
// colorization. The tool's ANSI output is then rendered as-is by Ink.
|
|
612
|
+
useEffect(() => {
|
|
613
|
+
if (state.overlay !== "diff" || !state.diffWorktreePath || !state.diffMergeBase)
|
|
614
|
+
return;
|
|
615
|
+
const file = state.diffFiles[state.diffFileIndex];
|
|
616
|
+
if (!file)
|
|
617
|
+
return;
|
|
618
|
+
const cwd = state.diffWorktreePath;
|
|
619
|
+
const mergeBase = state.diffMergeBase;
|
|
620
|
+
const tool = getDiffTool();
|
|
621
|
+
dispatch({ type: "DIFF_CONTENT_LOADING" });
|
|
622
|
+
void (async () => {
|
|
623
|
+
try {
|
|
624
|
+
if (tool) {
|
|
625
|
+
// Pipe git diff (with colors enabled so the tool can pass them
|
|
626
|
+
// through if desired) into the configured tool. Use spawn pipes
|
|
627
|
+
// rather than shell to avoid quoting concerns.
|
|
628
|
+
const content = await runPipedDiff(cwd, mergeBase, file.path, tool);
|
|
629
|
+
dispatch({ type: "DIFF_CONTENT_LOADED", content });
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
// No external tool — get raw unified diff and render colors ourselves.
|
|
633
|
+
const { stdout } = await execAsync(`git -C "${cwd}" diff --no-color "${mergeBase}" -- "${file.path}"`, { maxBuffer: 32 * 1024 * 1024 });
|
|
634
|
+
dispatch({ type: "DIFF_CONTENT_LOADED", content: stdout });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
catch (err) {
|
|
638
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
639
|
+
dispatch({ type: "DIFF_CONTENT_LOADED", content: `Error loading diff: ${msg}` });
|
|
640
|
+
}
|
|
641
|
+
})();
|
|
642
|
+
}, [
|
|
643
|
+
state.overlay,
|
|
644
|
+
state.diffWorktreePath,
|
|
645
|
+
state.diffMergeBase,
|
|
646
|
+
state.diffFileIndex,
|
|
647
|
+
state.diffFiles,
|
|
648
|
+
]);
|
|
380
649
|
// ── Actions ───────────────────────────────────────────────────────
|
|
381
650
|
const launchWorkInTmux = useCallback(async (di, mode, worktreePath, contextFile) => {
|
|
382
651
|
const windowName = di.issue.identifier;
|
|
@@ -1036,6 +1305,65 @@ export default function Dashboard() {
|
|
|
1036
1305
|
}
|
|
1037
1306
|
return;
|
|
1038
1307
|
}
|
|
1308
|
+
// Diff overlay
|
|
1309
|
+
if (state.overlay === "diff") {
|
|
1310
|
+
if (key.escape || input === "q") {
|
|
1311
|
+
dispatch({ type: "DIFF_CLOSE" });
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
const fileCount = state.diffFiles.length;
|
|
1315
|
+
if (fileCount === 0)
|
|
1316
|
+
return;
|
|
1317
|
+
// Compute max scroll so we never scroll past the end of the diff.
|
|
1318
|
+
const cols = stdout?.columns ?? 80;
|
|
1319
|
+
const rowsRem = stdout?.rows ?? 24;
|
|
1320
|
+
const contentHeight = Math.max(3, rowsRem - 2);
|
|
1321
|
+
const layout = computeDiffLayout({
|
|
1322
|
+
width: Math.max(40, cols - 2), // outer box border eats 1 col on each side
|
|
1323
|
+
height: contentHeight,
|
|
1324
|
+
files: state.diffFiles,
|
|
1325
|
+
fileIndex: state.diffFileIndex,
|
|
1326
|
+
fileScrollOffset: state.diffFileScrollOffset,
|
|
1327
|
+
leftWidthOverride: diffLeftWidth ?? undefined,
|
|
1328
|
+
});
|
|
1329
|
+
const totalLines = state.diffContent ? state.diffContent.split("\n").length : 0;
|
|
1330
|
+
const maxScroll = Math.max(0, totalLines - layout.bodyHeight);
|
|
1331
|
+
// Scroll diff content (J/K or shift+arrows)
|
|
1332
|
+
if ((key.shift && key.downArrow) || input === "J") {
|
|
1333
|
+
dispatch({
|
|
1334
|
+
type: "DIFF_CONTENT_SCROLL",
|
|
1335
|
+
offset: Math.min(maxScroll, state.diffContentScrollOffset + 5),
|
|
1336
|
+
});
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
if ((key.shift && key.upArrow) || input === "K") {
|
|
1340
|
+
dispatch({
|
|
1341
|
+
type: "DIFF_CONTENT_SCROLL",
|
|
1342
|
+
offset: Math.max(0, state.diffContentScrollOffset - 5),
|
|
1343
|
+
});
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
if (input === "g") {
|
|
1347
|
+
dispatch({ type: "DIFF_CONTENT_SCROLL", offset: 0 });
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
if (input === "G") {
|
|
1351
|
+
dispatch({ type: "DIFF_CONTENT_SCROLL", offset: maxScroll });
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
// Navigate file list (j/k or arrows)
|
|
1355
|
+
if (input === "j" || (key.downArrow && !key.shift)) {
|
|
1356
|
+
const next = Math.min(state.diffFileIndex + 1, fileCount - 1);
|
|
1357
|
+
dispatch({ type: "DIFF_FILE_SELECT", index: next });
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
if (input === "k" || (key.upArrow && !key.shift)) {
|
|
1361
|
+
const prev = Math.max(state.diffFileIndex - 1, 0);
|
|
1362
|
+
dispatch({ type: "DIFF_FILE_SELECT", index: prev });
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1039
1367
|
// Confirm delete overlay
|
|
1040
1368
|
if (state.overlay === "confirm-delete") {
|
|
1041
1369
|
if (input === "y") {
|
|
@@ -1452,9 +1780,12 @@ export default function Dashboard() {
|
|
|
1452
1780
|
openInEditor(di.worktree.path);
|
|
1453
1781
|
return;
|
|
1454
1782
|
}
|
|
1455
|
-
// Open workspace
|
|
1783
|
+
// Open workspace — no-op unless the editor accepts a .code-workspace
|
|
1784
|
+
// file and one exists. Keeps the keybinding from firing surprises on
|
|
1785
|
+
// editors like zed/nvim that don't have the concept.
|
|
1456
1786
|
if (input === "E") {
|
|
1457
|
-
|
|
1787
|
+
if (hasWorkspaceFile)
|
|
1788
|
+
openWorkspace();
|
|
1458
1789
|
return;
|
|
1459
1790
|
}
|
|
1460
1791
|
// Commit & push
|
|
@@ -1507,6 +1838,21 @@ export default function Dashboard() {
|
|
|
1507
1838
|
}
|
|
1508
1839
|
return;
|
|
1509
1840
|
}
|
|
1841
|
+
// View diff (inline overlay)
|
|
1842
|
+
if (input === "v") {
|
|
1843
|
+
if (!di.worktree) {
|
|
1844
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to diff" });
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
const baseBranch = getBaseBranch(di.worktree.branch);
|
|
1848
|
+
dispatch({
|
|
1849
|
+
type: "DIFF_OPEN",
|
|
1850
|
+
ticketId: di.issue.identifier,
|
|
1851
|
+
worktreePath: di.worktree.path,
|
|
1852
|
+
baseBranch,
|
|
1853
|
+
});
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1510
1856
|
// Delete worktree
|
|
1511
1857
|
if (input === "d") {
|
|
1512
1858
|
if (!di.worktree) {
|
|
@@ -1530,16 +1876,38 @@ export default function Dashboard() {
|
|
|
1530
1876
|
}
|
|
1531
1877
|
const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
|
|
1532
1878
|
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: "
|
|
1879
|
+
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)")
|
|
1880
|
+
.split("\n")
|
|
1881
|
+
.slice(0, 12)
|
|
1882
|
+
.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) => {
|
|
1883
|
+
const selected = idx === state.baseSelectIndex;
|
|
1884
|
+
const defaultBranch = getDefaultBranch();
|
|
1885
|
+
const label = branch === defaultBranch ? `${branch} (default)` : branch;
|
|
1886
|
+
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
|
|
1534
1888
|
.split("\n")
|
|
1535
|
-
.slice(
|
|
1536
|
-
.map((line, i) => (_jsx(
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1889
|
+
.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 })) })] })), _jsxs(Box, { children: [_jsx(Box, { width: leftWidth + separatorWidth, paddingX: 1, children: _jsx(CommandBar, { showWorkspace: hasWorkspaceFile, mode: state.overlay === "diff" ? "diff" : "default" }) }), _jsx(Box, { width: rightWidth, children: _jsx(ActionRow, { activeTab: state.activeTab, selectedIssue: selectedIssue, selectedReview: selectedReview, overlay: state.overlay }) })] })] })] }));
|
|
1891
|
+
}
|
|
1892
|
+
/**
|
|
1893
|
+
* Renders the per-issue action key hints (Resume / Editor / View diff / …)
|
|
1894
|
+
* lifted out of the detail panels so they sit on the same row as the global
|
|
1895
|
+
* command bar. Empty when nothing is selected.
|
|
1896
|
+
*/
|
|
1897
|
+
function ActionRow({ activeTab, selectedIssue, selectedReview, overlay, }) {
|
|
1898
|
+
// During the diff overlay, none of the per-issue actions apply (View diff
|
|
1899
|
+
// is what got us here, Commit/PR/etc. need the detail panel context). Keep
|
|
1900
|
+
// the row blank so the diff-specific CommandBar reads cleanly.
|
|
1901
|
+
if (overlay === "diff")
|
|
1902
|
+
return _jsx(Text, { children: " " });
|
|
1903
|
+
const items = activeTab === "reviews"
|
|
1904
|
+
? selectedReview
|
|
1905
|
+
? buildReviewActions(selectedReview)
|
|
1906
|
+
: []
|
|
1907
|
+
: selectedIssue
|
|
1908
|
+
? buildIssueActions(selectedIssue)
|
|
1909
|
+
: [];
|
|
1910
|
+
if (items.length === 0)
|
|
1911
|
+
return _jsx(Text, { children: " " });
|
|
1912
|
+
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
1913
|
}
|