santree 0.1.4 → 0.2.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 +36 -2
- package/dist/cli.js +0 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.js +900 -0
- package/dist/commands/doctor.js +2 -2
- package/dist/commands/helpers/template.d.ts +1 -1
- package/dist/commands/worktree/work.js +17 -1
- package/dist/lib/ai.d.ts +7 -0
- package/dist/lib/ai.js +10 -2
- package/dist/lib/dashboard/DetailPanel.d.ts +11 -0
- package/dist/lib/dashboard/DetailPanel.js +230 -0
- package/dist/lib/dashboard/IssueList.d.ts +13 -0
- package/dist/lib/dashboard/IssueList.js +112 -0
- package/dist/lib/dashboard/Overlays.d.ts +25 -0
- package/dist/lib/dashboard/Overlays.js +25 -0
- package/dist/lib/dashboard/data.d.ts +5 -0
- package/dist/lib/dashboard/data.js +75 -0
- package/dist/lib/dashboard/types.d.ts +150 -0
- package/dist/lib/dashboard/types.js +151 -0
- package/dist/lib/git.d.ts +19 -0
- package/dist/lib/git.js +32 -1
- package/dist/lib/github.d.ts +2 -1
- package/dist/lib/github.js +3 -2
- package/dist/lib/linear.d.ts +20 -0
- package/dist/lib/linear.js +53 -0
- package/package.json +2 -2
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useReducer, useCallback, useRef, useState } from "react";
|
|
3
|
+
import { Text, Box, useInput, useStdout, useApp } from "ink";
|
|
4
|
+
import Spinner from "ink-spinner";
|
|
5
|
+
import { exec, execSync, spawn } from "child_process";
|
|
6
|
+
import { promisify } from "util";
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import { findMainRepoRoot, createWorktree, getDefaultBranch, getBaseBranch, hasInitScript, getInitScriptPath, removeWorktree, } from "../lib/git.js";
|
|
10
|
+
import { spawnAsync } from "../lib/exec.js";
|
|
11
|
+
import { resolveAgentBinary } from "../lib/ai.js";
|
|
12
|
+
import { initialState, reducer } from "../lib/dashboard/types.js";
|
|
13
|
+
import { loadDashboardData } from "../lib/dashboard/data.js";
|
|
14
|
+
import IssueList from "../lib/dashboard/IssueList.js";
|
|
15
|
+
import DetailPanel from "../lib/dashboard/DetailPanel.js";
|
|
16
|
+
import { CommitOverlay, PrCreateOverlay } from "../lib/dashboard/Overlays.js";
|
|
17
|
+
export const description = "Interactive dashboard of your Linear issues";
|
|
18
|
+
const execAsync = promisify(exec);
|
|
19
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
20
|
+
function isInTmux() {
|
|
21
|
+
return !!process.env.TMUX;
|
|
22
|
+
}
|
|
23
|
+
function slugify(title) {
|
|
24
|
+
return title
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
27
|
+
.replace(/^-|-$/g, "")
|
|
28
|
+
.slice(0, 40);
|
|
29
|
+
}
|
|
30
|
+
// ── Scroll helpers ────────────────────────────────────────────────────
|
|
31
|
+
function getRowIndexForFlatIndex(groups, flatIndex) {
|
|
32
|
+
let row = 1; // skip column header row
|
|
33
|
+
let issuesSeen = 0;
|
|
34
|
+
for (const g of groups) {
|
|
35
|
+
row++; // group header
|
|
36
|
+
for (let i = 0; i < g.issues.length; i++) {
|
|
37
|
+
if (issuesSeen === flatIndex)
|
|
38
|
+
return row;
|
|
39
|
+
row++;
|
|
40
|
+
issuesSeen++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
function getFlatIndexForListRow(groups, listRow) {
|
|
46
|
+
if (listRow === 0)
|
|
47
|
+
return null; // column header row
|
|
48
|
+
let row = 1; // skip column header row
|
|
49
|
+
let issuesSeen = 0;
|
|
50
|
+
for (const g of groups) {
|
|
51
|
+
if (row === listRow)
|
|
52
|
+
return null; // group header row
|
|
53
|
+
row++;
|
|
54
|
+
for (let i = 0; i < g.issues.length; i++) {
|
|
55
|
+
if (row === listRow)
|
|
56
|
+
return issuesSeen;
|
|
57
|
+
row++;
|
|
58
|
+
issuesSeen++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
// ── Terminal escape sequences ─────────────────────────────────────────
|
|
64
|
+
//
|
|
65
|
+
// We control the terminal by writing ANSI escape sequences to stdout.
|
|
66
|
+
// These are special byte strings that terminals interpret as commands
|
|
67
|
+
// rather than displayable text.
|
|
68
|
+
//
|
|
69
|
+
// Format: \x1b[ starts a "CSI" (Control Sequence Introducer).
|
|
70
|
+
// \x1b is the ESC character (hex 0x1B, decimal 27).
|
|
71
|
+
// The `[` after ESC begins a CSI sequence.
|
|
72
|
+
// `?` marks a "private mode" (DEC-specific terminal feature).
|
|
73
|
+
// The number identifies which feature, and the letter at the end
|
|
74
|
+
// is the action: `h` = enable (high), `l` = disable (low).
|
|
75
|
+
//
|
|
76
|
+
// Sequences used:
|
|
77
|
+
// \x1b[?1049h / l — Enter/leave alternate screen buffer.
|
|
78
|
+
// The alt screen is a separate drawing area (like vim
|
|
79
|
+
// or less use). When you leave, the original terminal
|
|
80
|
+
// content is restored as if nothing happened.
|
|
81
|
+
// \x1b[?25h / l — Show/hide the text cursor.
|
|
82
|
+
// \x1b[?1002h / l — Enable/disable button-event mouse tracking.
|
|
83
|
+
// The terminal sends mouse press, release, drag, and
|
|
84
|
+
// scroll events as input sequences we can parse.
|
|
85
|
+
// \x1b[?1006h / l — Enable/disable SGR (Select Graphic Rendition)
|
|
86
|
+
// extended mouse format. Without this, mouse reporting
|
|
87
|
+
// breaks beyond column/row 223. SGR encodes events as
|
|
88
|
+
// \x1b[<button;col;row M/m (M=press, m=release).
|
|
89
|
+
// Must run before Ink renders the first frame to avoid leaking output
|
|
90
|
+
// to the main terminal buffer.
|
|
91
|
+
let altScreenEntered = false;
|
|
92
|
+
function ensureAltScreen() {
|
|
93
|
+
if (altScreenEntered)
|
|
94
|
+
return;
|
|
95
|
+
altScreenEntered = true;
|
|
96
|
+
if (isInTmux()) {
|
|
97
|
+
try {
|
|
98
|
+
execSync('tmux rename-window "santree"', { stdio: "ignore" });
|
|
99
|
+
}
|
|
100
|
+
catch { }
|
|
101
|
+
}
|
|
102
|
+
process.stdout.write("\x1b[?1049h"); // Enter alternate screen buffer
|
|
103
|
+
process.stdout.write("\x1b[?25l"); // Hide cursor
|
|
104
|
+
}
|
|
105
|
+
/** Leave alternate screen and restore cursor — used when exiting to shell */
|
|
106
|
+
function leaveAltScreen() {
|
|
107
|
+
process.stdout.write("\x1b[?1049l"); // Leave alternate screen buffer
|
|
108
|
+
process.stdout.write("\x1b[?25h"); // Show cursor
|
|
109
|
+
}
|
|
110
|
+
// ── Component ─────────────────────────────────────────────────────────
|
|
111
|
+
export default function Dashboard() {
|
|
112
|
+
ensureAltScreen();
|
|
113
|
+
const { exit } = useApp();
|
|
114
|
+
const { stdout } = useStdout();
|
|
115
|
+
const [state, dispatch] = useReducer(reducer, initialState);
|
|
116
|
+
const refreshTimerRef = useRef(null);
|
|
117
|
+
const repoRootRef = useRef(null);
|
|
118
|
+
const stateRef = useRef(state);
|
|
119
|
+
stateRef.current = state;
|
|
120
|
+
const draggingRef = useRef(false);
|
|
121
|
+
const [termSize, setTermSize] = useState({
|
|
122
|
+
columns: stdout?.columns ?? 80,
|
|
123
|
+
rows: stdout?.rows ?? 24,
|
|
124
|
+
});
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
const onResize = () => {
|
|
127
|
+
setTermSize({
|
|
128
|
+
columns: stdout?.columns ?? 80,
|
|
129
|
+
rows: stdout?.rows ?? 24,
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
stdout?.on("resize", onResize);
|
|
133
|
+
return () => {
|
|
134
|
+
stdout?.off("resize", onResize);
|
|
135
|
+
};
|
|
136
|
+
}, [stdout]);
|
|
137
|
+
const { columns, rows } = termSize;
|
|
138
|
+
const separatorWidth = 3;
|
|
139
|
+
const [leftWidth, setLeftWidth] = useState(Math.floor(columns * 0.42));
|
|
140
|
+
const leftWidthRef = useRef(leftWidth);
|
|
141
|
+
leftWidthRef.current = leftWidth;
|
|
142
|
+
const rightWidth = columns - leftWidth - separatorWidth;
|
|
143
|
+
const contentHeight = rows - 1; // 1 header
|
|
144
|
+
const LIST_FOOTER_HEIGHT = 2;
|
|
145
|
+
// ── Data loading ──────────────────────────────────────────────────
|
|
146
|
+
const refresh = useCallback(async (isInitial = false) => {
|
|
147
|
+
if (!isInitial)
|
|
148
|
+
dispatch({ type: "REFRESH_START" });
|
|
149
|
+
const repoRoot = repoRootRef.current ?? findMainRepoRoot();
|
|
150
|
+
if (!repoRoot) {
|
|
151
|
+
dispatch({ type: "SET_ERROR", error: "Not inside a git repository" });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
repoRootRef.current = repoRoot;
|
|
155
|
+
try {
|
|
156
|
+
const data = await loadDashboardData(repoRoot);
|
|
157
|
+
dispatch({ type: "SET_DATA", ...data });
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
dispatch({
|
|
161
|
+
type: "SET_ERROR",
|
|
162
|
+
error: e instanceof Error ? e.message : "Unknown error",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}, []);
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
// Enable button-event mouse tracking (?1002h) with SGR extended format (?1006h)
|
|
168
|
+
// This reports press, release, drag, and scroll wheel events
|
|
169
|
+
process.stdout.write("\x1b[?1002h\x1b[?1006h");
|
|
170
|
+
// Mouse handler on raw stdin — handles click-to-select and drag-to-resize
|
|
171
|
+
const onData = (data) => {
|
|
172
|
+
const str = data.toString("utf-8");
|
|
173
|
+
// SGR mouse format: \x1b[<button;col;rowM (press/drag) or ...m (release)
|
|
174
|
+
const match = str.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
|
|
175
|
+
if (!match)
|
|
176
|
+
return;
|
|
177
|
+
const button = parseInt(match[1], 10);
|
|
178
|
+
const col = parseInt(match[2], 10); // 1-based
|
|
179
|
+
const row = parseInt(match[3], 10); // 1-based
|
|
180
|
+
const isRelease = match[4] === "m";
|
|
181
|
+
const isPress = match[4] === "M" && button === 0;
|
|
182
|
+
const isDrag = match[4] === "M" && button === 32;
|
|
183
|
+
const cols = stdout?.columns ?? 80;
|
|
184
|
+
const minW = 20;
|
|
185
|
+
const sepW = 3;
|
|
186
|
+
// Release — stop dragging
|
|
187
|
+
if (isRelease && draggingRef.current) {
|
|
188
|
+
draggingRef.current = false;
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Drag — resize if actively dragging
|
|
192
|
+
if (isDrag && draggingRef.current) {
|
|
193
|
+
// col is 1-based; place divider center at mouse position
|
|
194
|
+
const newLeft = Math.max(minW, Math.min(col - 1, cols - sepW - minW));
|
|
195
|
+
setLeftWidth(newLeft);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// Scroll wheel — button 64 = up, 65 = down
|
|
199
|
+
if (match[4] === "M" && (button === 64 || button === 65)) {
|
|
200
|
+
const s = stateRef.current;
|
|
201
|
+
const lw = leftWidthRef.current;
|
|
202
|
+
const delta = button === 65 ? 3 : -3;
|
|
203
|
+
if (col <= lw) {
|
|
204
|
+
// Scroll left pane (issue list)
|
|
205
|
+
const maxIdx = s.flatIssues.length - 1;
|
|
206
|
+
if (maxIdx < 0)
|
|
207
|
+
return;
|
|
208
|
+
const next = Math.max(0, Math.min(s.selectedIndex + delta, maxIdx));
|
|
209
|
+
dispatch({ type: "SELECT", index: next });
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
// Scroll right pane (detail)
|
|
213
|
+
const next = Math.max(0, s.detailScrollOffset + delta);
|
|
214
|
+
dispatch({ type: "SCROLL_DETAIL", offset: next });
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!isPress)
|
|
219
|
+
return;
|
|
220
|
+
// Left-click press: check if on divider to start drag
|
|
221
|
+
const lw = leftWidthRef.current;
|
|
222
|
+
const divStart = lw + 1; // 1-based start of separator
|
|
223
|
+
const divEnd = lw + sepW; // 1-based end of separator
|
|
224
|
+
if (col >= divStart && col <= divEnd) {
|
|
225
|
+
draggingRef.current = true;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
// Left-click press: select issue in left pane
|
|
229
|
+
const s = stateRef.current;
|
|
230
|
+
if (s.loading || s.error || s.flatIssues.length === 0)
|
|
231
|
+
return;
|
|
232
|
+
if (col > lw)
|
|
233
|
+
return;
|
|
234
|
+
// Row 1 is the header line, content starts at row 2 (1-based)
|
|
235
|
+
const contentRow = row - 2; // 0-based row within content area
|
|
236
|
+
if (contentRow < 0)
|
|
237
|
+
return;
|
|
238
|
+
const listRow = s.listScrollOffset + contentRow;
|
|
239
|
+
const flatIdx = getFlatIndexForListRow(s.groups, listRow);
|
|
240
|
+
if (flatIdx !== null && flatIdx >= 0 && flatIdx < s.flatIssues.length) {
|
|
241
|
+
dispatch({ type: "SELECT", index: flatIdx });
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
if (process.stdin.isTTY) {
|
|
245
|
+
process.stdin.on("data", onData);
|
|
246
|
+
}
|
|
247
|
+
const init = async () => {
|
|
248
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
249
|
+
await refresh(true);
|
|
250
|
+
};
|
|
251
|
+
init();
|
|
252
|
+
// Auto-refresh every 30s
|
|
253
|
+
refreshTimerRef.current = setInterval(() => refresh(), 30_000);
|
|
254
|
+
return () => {
|
|
255
|
+
if (refreshTimerRef.current)
|
|
256
|
+
clearInterval(refreshTimerRef.current);
|
|
257
|
+
// Disable SGR extended format (?1006l) and button-event tracking (?1002l)
|
|
258
|
+
process.stdout.write("\x1b[?1006l\x1b[?1002l");
|
|
259
|
+
leaveAltScreen();
|
|
260
|
+
if (process.stdin.isTTY) {
|
|
261
|
+
process.stdin.removeListener("data", onData);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}, [refresh]);
|
|
265
|
+
// ── List scroll tracking ──────────────────────────────────────────
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
const rowIdx = getRowIndexForFlatIndex(state.groups, state.selectedIndex);
|
|
268
|
+
const maxVisible = contentHeight - LIST_FOOTER_HEIGHT;
|
|
269
|
+
let offset = state.listScrollOffset;
|
|
270
|
+
if (rowIdx < offset) {
|
|
271
|
+
offset = Math.max(0, rowIdx - 1);
|
|
272
|
+
}
|
|
273
|
+
else if (rowIdx >= offset + maxVisible) {
|
|
274
|
+
offset = rowIdx - maxVisible + 2;
|
|
275
|
+
}
|
|
276
|
+
if (offset !== state.listScrollOffset) {
|
|
277
|
+
dispatch({ type: "SCROLL_LIST", offset });
|
|
278
|
+
}
|
|
279
|
+
}, [state.selectedIndex, state.groups, contentHeight, state.listScrollOffset]);
|
|
280
|
+
// ── Actions ───────────────────────────────────────────────────────
|
|
281
|
+
const launchWorkInTmux = useCallback((di, mode, worktreePath) => {
|
|
282
|
+
const windowName = di.issue.identifier;
|
|
283
|
+
const sessionId = di.worktree?.sessionId;
|
|
284
|
+
const bin = resolveAgentBinary();
|
|
285
|
+
const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
|
|
286
|
+
const workCmd = mode === "plan" ? "st worktree work --plan" : "st worktree work";
|
|
287
|
+
try {
|
|
288
|
+
// Switch to existing window if it exists
|
|
289
|
+
execSync(`tmux select-window -t "${windowName}"`, { stdio: "ignore" });
|
|
290
|
+
if (resumeCmd) {
|
|
291
|
+
execSync(`tmux send-keys -t "${windowName}" "${resumeCmd}" Enter`, { stdio: "ignore" });
|
|
292
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: `Resumed session in: ${windowName}` });
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
execSync(`tmux send-keys -t "${windowName}" "${workCmd}" Enter`, { stdio: "ignore" });
|
|
296
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: `Launched ${mode} in: ${windowName}` });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// Window doesn't exist — create it
|
|
301
|
+
try {
|
|
302
|
+
execSync(`tmux new-window -n "${windowName}" -c "${worktreePath}"`, { stdio: "ignore" });
|
|
303
|
+
if (resumeCmd) {
|
|
304
|
+
execSync(`tmux send-keys -t "${windowName}" "${resumeCmd}" Enter`, { stdio: "ignore" });
|
|
305
|
+
dispatch({
|
|
306
|
+
type: "SET_ACTION_MESSAGE",
|
|
307
|
+
message: `Resumed session in new window: ${windowName}`,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
execSync(`tmux send-keys -t "${windowName}" "${workCmd}" Enter`, { stdio: "ignore" });
|
|
312
|
+
dispatch({
|
|
313
|
+
type: "SET_ACTION_MESSAGE",
|
|
314
|
+
message: `Launched ${mode} in tmux window: ${windowName}`,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to create tmux window" });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Delayed refresh to pick up session ID created by `st worktree work`
|
|
323
|
+
setTimeout(() => refresh(), 3000);
|
|
324
|
+
}, [refresh]);
|
|
325
|
+
const doWork = useCallback(async (mode) => {
|
|
326
|
+
const di = state.flatIssues[state.selectedIndex];
|
|
327
|
+
if (!di)
|
|
328
|
+
return;
|
|
329
|
+
const repoRoot = repoRootRef.current;
|
|
330
|
+
if (!repoRoot)
|
|
331
|
+
return;
|
|
332
|
+
dispatch({ type: "SET_OVERLAY", overlay: null });
|
|
333
|
+
if (di.worktree) {
|
|
334
|
+
// Worktree exists — launch work
|
|
335
|
+
if (isInTmux()) {
|
|
336
|
+
launchWorkInTmux(di, mode, di.worktree.path);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
leaveAltScreen();
|
|
340
|
+
console.log(`SANTREE_CD:${di.worktree.path}`);
|
|
341
|
+
console.log(`SANTREE_WORK:${mode}`);
|
|
342
|
+
exit();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
// No worktree — full creation flow (pull + create + init script)
|
|
347
|
+
// Guard against concurrent creation
|
|
348
|
+
if (stateRef.current.creatingForTicket)
|
|
349
|
+
return;
|
|
350
|
+
const ticketId = di.issue.identifier;
|
|
351
|
+
dispatch({ type: "CREATION_START", ticketId });
|
|
352
|
+
const slug = slugify(di.issue.title);
|
|
353
|
+
const branchName = `feature/${ticketId}-${slug}`;
|
|
354
|
+
const base = getDefaultBranch();
|
|
355
|
+
// 1. Pull latest (async to avoid blocking the event loop)
|
|
356
|
+
dispatch({ type: "CREATION_LOG", logs: `Fetching origin...\n` });
|
|
357
|
+
try {
|
|
358
|
+
await execAsync("git fetch origin", { cwd: repoRoot });
|
|
359
|
+
dispatch({ type: "CREATION_LOG", logs: `Checking out ${base}...\n` });
|
|
360
|
+
await execAsync(`git checkout ${base}`, { cwd: repoRoot });
|
|
361
|
+
dispatch({ type: "CREATION_LOG", logs: `Pulling ${base}...\n` });
|
|
362
|
+
await execAsync(`git pull origin ${base}`, { cwd: repoRoot });
|
|
363
|
+
dispatch({ type: "CREATION_LOG", logs: `Pulled latest ${base}\n` });
|
|
364
|
+
}
|
|
365
|
+
catch (e) {
|
|
366
|
+
const msg = e instanceof Error ? e.message : "Failed to pull latest";
|
|
367
|
+
dispatch({ type: "CREATION_LOG", logs: `Warning: ${msg}\n` });
|
|
368
|
+
}
|
|
369
|
+
// 2. Create worktree
|
|
370
|
+
dispatch({ type: "CREATION_LOG", logs: `Creating worktree ${branchName}...\n` });
|
|
371
|
+
const result = await createWorktree(branchName, base, repoRoot);
|
|
372
|
+
if (!result.success || !result.path) {
|
|
373
|
+
dispatch({ type: "CREATION_ERROR", error: result.error ?? "Unknown error" });
|
|
374
|
+
dispatch({
|
|
375
|
+
type: "SET_ACTION_MESSAGE",
|
|
376
|
+
message: `Failed: ${result.error ?? "Unknown error"}`,
|
|
377
|
+
});
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
dispatch({ type: "CREATION_LOG", logs: `Worktree created at ${result.path}\n` });
|
|
381
|
+
// 3. Run init script if it exists
|
|
382
|
+
if (hasInitScript(repoRoot)) {
|
|
383
|
+
const initScript = getInitScriptPath(repoRoot);
|
|
384
|
+
let canExecute = true;
|
|
385
|
+
try {
|
|
386
|
+
fs.accessSync(initScript, fs.constants.X_OK);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
dispatch({
|
|
390
|
+
type: "CREATION_LOG",
|
|
391
|
+
logs: "Warning: init.sh exists but is not executable, skipping\n",
|
|
392
|
+
});
|
|
393
|
+
canExecute = false;
|
|
394
|
+
}
|
|
395
|
+
if (canExecute) {
|
|
396
|
+
dispatch({ type: "CREATION_LOG", logs: "Running init.sh...\n" });
|
|
397
|
+
let lastLen = 0;
|
|
398
|
+
const initResult = await spawnAsync(initScript, [], {
|
|
399
|
+
cwd: result.path,
|
|
400
|
+
env: {
|
|
401
|
+
...process.env,
|
|
402
|
+
SANTREE_WORKTREE_PATH: result.path,
|
|
403
|
+
SANTREE_REPO_ROOT: repoRoot,
|
|
404
|
+
},
|
|
405
|
+
onOutput: (output) => {
|
|
406
|
+
const delta = output.slice(lastLen);
|
|
407
|
+
if (delta)
|
|
408
|
+
dispatch({ type: "CREATION_LOG", logs: delta });
|
|
409
|
+
lastLen = output.length;
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
if (initResult.code !== 0) {
|
|
413
|
+
dispatch({
|
|
414
|
+
type: "CREATION_LOG",
|
|
415
|
+
logs: `\nInit script exited with code ${initResult.code}\n`,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
dispatch({ type: "CREATION_LOG", logs: "\nSetup complete!\n" });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// 4. Done — launch work
|
|
424
|
+
dispatch({ type: "CREATION_DONE" });
|
|
425
|
+
if (isInTmux()) {
|
|
426
|
+
const windowName = ticketId;
|
|
427
|
+
const workCmd = mode === "plan" ? "st worktree work --plan" : "st worktree work";
|
|
428
|
+
try {
|
|
429
|
+
execSync(`tmux new-window -n "${windowName}" -c "${result.path}"`, { stdio: "ignore" });
|
|
430
|
+
execSync(`tmux send-keys -t "${windowName}" "${workCmd}" Enter`, { stdio: "ignore" });
|
|
431
|
+
dispatch({
|
|
432
|
+
type: "SET_ACTION_MESSAGE",
|
|
433
|
+
message: `Created worktree + launched ${mode} in: ${windowName}`,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Worktree created, but tmux failed" });
|
|
438
|
+
}
|
|
439
|
+
// Refresh to pick up new worktree + session
|
|
440
|
+
setTimeout(() => refresh(), 3000);
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
leaveAltScreen();
|
|
444
|
+
console.log(`SANTREE_CD:${result.path}`);
|
|
445
|
+
console.log(`SANTREE_WORK:${mode}`);
|
|
446
|
+
exit();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}, [state.flatIssues, state.selectedIndex, exit, refresh, launchWorkInTmux]);
|
|
450
|
+
// ── Commit flow ──────────────────────────────────────────────────
|
|
451
|
+
const handleStageAll = useCallback(async () => {
|
|
452
|
+
const wtPath = stateRef.current.commitWorktreePath;
|
|
453
|
+
const ticketId = stateRef.current.commitTicketId;
|
|
454
|
+
if (!wtPath)
|
|
455
|
+
return;
|
|
456
|
+
try {
|
|
457
|
+
await execAsync("git add -A", { cwd: wtPath });
|
|
458
|
+
dispatch({ type: "COMMIT_MESSAGE", message: `[${ticketId}] ` });
|
|
459
|
+
dispatch({ type: "COMMIT_PHASE", phase: "awaiting-message" });
|
|
460
|
+
}
|
|
461
|
+
catch (e) {
|
|
462
|
+
dispatch({
|
|
463
|
+
type: "COMMIT_ERROR",
|
|
464
|
+
error: e?.stderr?.trim() || e?.message || "Failed to stage",
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}, []);
|
|
468
|
+
const handleCommitSubmit = useCallback(async (value) => {
|
|
469
|
+
const s = stateRef.current;
|
|
470
|
+
if (!s.commitWorktreePath || !s.commitBranch)
|
|
471
|
+
return;
|
|
472
|
+
const trimmed = value.trim();
|
|
473
|
+
if (!trimmed) {
|
|
474
|
+
dispatch({ type: "COMMIT_ERROR", error: "Empty commit message" });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const msg = trimmed.includes(`[${s.commitTicketId}]`)
|
|
478
|
+
? trimmed
|
|
479
|
+
: `[${s.commitTicketId}] ${trimmed}`;
|
|
480
|
+
dispatch({ type: "COMMIT_PHASE", phase: "committing" });
|
|
481
|
+
try {
|
|
482
|
+
await execAsync(`git commit -m "${msg.replace(/"/g, '\\"')}"`, {
|
|
483
|
+
cwd: s.commitWorktreePath,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
catch (e) {
|
|
487
|
+
dispatch({
|
|
488
|
+
type: "COMMIT_ERROR",
|
|
489
|
+
error: e?.stderr?.trim() || e?.stdout?.trim() || e?.message || "Commit failed",
|
|
490
|
+
});
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
dispatch({ type: "COMMIT_PHASE", phase: "pushing" });
|
|
494
|
+
try {
|
|
495
|
+
await execAsync(`git push -u origin "${s.commitBranch}"`, { cwd: s.commitWorktreePath });
|
|
496
|
+
}
|
|
497
|
+
catch (e) {
|
|
498
|
+
dispatch({ type: "COMMIT_ERROR", error: e?.stderr?.trim() || e?.message || "Push failed" });
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
dispatch({ type: "COMMIT_DONE" });
|
|
502
|
+
setTimeout(() => {
|
|
503
|
+
dispatch({ type: "COMMIT_CANCEL" });
|
|
504
|
+
refresh();
|
|
505
|
+
}, 2000);
|
|
506
|
+
}, [refresh]);
|
|
507
|
+
// ── Editor actions ───────────────────────────────────────────────
|
|
508
|
+
const openInEditor = useCallback((wtPath) => {
|
|
509
|
+
const editor = process.env.SANTREE_EDITOR || "code";
|
|
510
|
+
spawn(editor, [wtPath], { detached: true, stdio: "ignore" }).unref();
|
|
511
|
+
dispatch({
|
|
512
|
+
type: "SET_ACTION_MESSAGE",
|
|
513
|
+
message: `Opened ${path.basename(wtPath)} in ${editor}`,
|
|
514
|
+
});
|
|
515
|
+
}, []);
|
|
516
|
+
const openWorkspace = useCallback(() => {
|
|
517
|
+
const repoRoot = repoRootRef.current;
|
|
518
|
+
if (!repoRoot)
|
|
519
|
+
return;
|
|
520
|
+
const editor = process.env.SANTREE_EDITOR || "code";
|
|
521
|
+
try {
|
|
522
|
+
const entries = fs.readdirSync(repoRoot);
|
|
523
|
+
const wsFile = entries.find((f) => f.endsWith(".code-workspace"));
|
|
524
|
+
if (!wsFile) {
|
|
525
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "No .code-workspace file found" });
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
spawn(editor, [path.join(repoRoot, wsFile)], { detached: true, stdio: "ignore" }).unref();
|
|
529
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: `Opened workspace in ${editor}` });
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to open workspace" });
|
|
533
|
+
}
|
|
534
|
+
}, []);
|
|
535
|
+
// ── PR create flow ───────────────────────────────────────────────
|
|
536
|
+
const doPrCreate = useCallback(async (fill) => {
|
|
537
|
+
const s = stateRef.current;
|
|
538
|
+
if (!s.prCreateWorktreePath || !s.prCreateBranch)
|
|
539
|
+
return;
|
|
540
|
+
const base = getBaseBranch(s.prCreateBranch);
|
|
541
|
+
// Push first
|
|
542
|
+
dispatch({ type: "PR_CREATE_PHASE", phase: "pushing" });
|
|
543
|
+
try {
|
|
544
|
+
await execAsync(`git -C "${s.prCreateWorktreePath}" push -u origin "${s.prCreateBranch}"`);
|
|
545
|
+
}
|
|
546
|
+
catch (e) {
|
|
547
|
+
const msg = e?.stderr?.trim() || e?.message || "Push failed";
|
|
548
|
+
dispatch({ type: "PR_CREATE_ERROR", error: msg });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
dispatch({ type: "PR_CREATE_PHASE", phase: "creating" });
|
|
552
|
+
try {
|
|
553
|
+
if (fill) {
|
|
554
|
+
const { stdout } = await execAsync(`gh pr create --fill --base "${base}" --head "${s.prCreateBranch}"`, { cwd: s.prCreateWorktreePath });
|
|
555
|
+
const url = stdout.trim();
|
|
556
|
+
dispatch({ type: "PR_CREATE_DONE", url });
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
await execAsync(`gh pr create --web --base "${base}" --head "${s.prCreateBranch}"`, {
|
|
560
|
+
cwd: s.prCreateWorktreePath,
|
|
561
|
+
});
|
|
562
|
+
dispatch({ type: "PR_CREATE_DONE", url: "" });
|
|
563
|
+
}
|
|
564
|
+
setTimeout(() => {
|
|
565
|
+
dispatch({ type: "PR_CREATE_CANCEL" });
|
|
566
|
+
refresh();
|
|
567
|
+
}, 2500);
|
|
568
|
+
}
|
|
569
|
+
catch (e) {
|
|
570
|
+
const msg = e?.stderr?.trim() || e?.message || "PR creation failed";
|
|
571
|
+
dispatch({ type: "PR_CREATE_ERROR", error: msg });
|
|
572
|
+
}
|
|
573
|
+
}, [refresh]);
|
|
574
|
+
// ── Keyboard ──────────────────────────────────────────────────────
|
|
575
|
+
useInput((input, key) => {
|
|
576
|
+
// Clear action messages on any keypress
|
|
577
|
+
if (state.actionMessage && input !== "q") {
|
|
578
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: null });
|
|
579
|
+
}
|
|
580
|
+
// Commit overlay
|
|
581
|
+
if (state.overlay === "commit") {
|
|
582
|
+
if (key.escape) {
|
|
583
|
+
dispatch({ type: "COMMIT_CANCEL" });
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (state.commitPhase === "confirm-stage") {
|
|
587
|
+
if (input === "y") {
|
|
588
|
+
handleStageAll();
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (input === "n") {
|
|
592
|
+
dispatch({ type: "COMMIT_CANCEL" });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
// awaiting-message is handled by TextInput, not useInput
|
|
598
|
+
// All other phases: swallow input
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
// PR create overlay
|
|
602
|
+
if (state.overlay === "pr-create") {
|
|
603
|
+
if (key.escape) {
|
|
604
|
+
dispatch({ type: "PR_CREATE_CANCEL" });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (state.prCreatePhase === "choose-mode") {
|
|
608
|
+
if (input === "f") {
|
|
609
|
+
doPrCreate(true);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (input === "w") {
|
|
613
|
+
doPrCreate(false);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
// Mode select overlay
|
|
620
|
+
if (state.overlay === "mode-select") {
|
|
621
|
+
if (input === "p" || input === "1") {
|
|
622
|
+
doWork("plan");
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (input === "i" || input === "2") {
|
|
626
|
+
doWork("implement");
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (key.escape || input === "q") {
|
|
630
|
+
dispatch({ type: "SET_OVERLAY", overlay: null });
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
// Confirm delete overlay
|
|
636
|
+
if (state.overlay === "confirm-delete") {
|
|
637
|
+
if (input === "y") {
|
|
638
|
+
dispatch({ type: "SET_OVERLAY", overlay: null });
|
|
639
|
+
const di = state.flatIssues[state.selectedIndex];
|
|
640
|
+
if (di?.worktree) {
|
|
641
|
+
const repoRoot = repoRootRef.current;
|
|
642
|
+
if (repoRoot) {
|
|
643
|
+
dispatch({ type: "DELETE_START", ticketId: di.issue.identifier });
|
|
644
|
+
const force = di.worktree.dirty;
|
|
645
|
+
removeWorktree(di.worktree.branch, repoRoot, force).then((result) => {
|
|
646
|
+
dispatch({ type: "DELETE_DONE" });
|
|
647
|
+
if (result.success) {
|
|
648
|
+
dispatch({
|
|
649
|
+
type: "SET_ACTION_MESSAGE",
|
|
650
|
+
message: `Removed worktree for ${di.issue.identifier}`,
|
|
651
|
+
});
|
|
652
|
+
refresh();
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
dispatch({
|
|
656
|
+
type: "SET_ACTION_MESSAGE",
|
|
657
|
+
message: `Failed: ${result.error ?? "Unknown error"}`,
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (input === "n" || key.escape || input === "q") {
|
|
666
|
+
dispatch({ type: "SET_OVERLAY", overlay: null });
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
// Quit
|
|
672
|
+
if (input === "q") {
|
|
673
|
+
exit();
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const maxIndex = state.flatIssues.length - 1;
|
|
677
|
+
// Navigation
|
|
678
|
+
if (input === "j" || (key.downArrow && !key.shift)) {
|
|
679
|
+
const next = Math.min(state.selectedIndex + 1, maxIndex);
|
|
680
|
+
dispatch({ type: "SELECT", index: next });
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (input === "k" || (key.upArrow && !key.shift)) {
|
|
684
|
+
const prev = Math.max(state.selectedIndex - 1, 0);
|
|
685
|
+
dispatch({ type: "SELECT", index: prev });
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// Detail scroll
|
|
689
|
+
if (key.shift && key.downArrow) {
|
|
690
|
+
dispatch({ type: "SCROLL_DETAIL", offset: state.detailScrollOffset + 3 });
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (key.shift && key.upArrow) {
|
|
694
|
+
dispatch({
|
|
695
|
+
type: "SCROLL_DETAIL",
|
|
696
|
+
offset: Math.max(0, state.detailScrollOffset - 3),
|
|
697
|
+
});
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
const di = state.flatIssues[state.selectedIndex];
|
|
701
|
+
if (!di)
|
|
702
|
+
return;
|
|
703
|
+
// Work
|
|
704
|
+
if (input === "w") {
|
|
705
|
+
if (di.worktree?.sessionId) {
|
|
706
|
+
dispatch({
|
|
707
|
+
type: "SET_ACTION_MESSAGE",
|
|
708
|
+
message: "Session active. Press Enter to resume.",
|
|
709
|
+
});
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
dispatch({ type: "SET_OVERLAY", overlay: "mode-select" });
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
// Switch to worktree (Enter) — also resumes session
|
|
716
|
+
if (key.return) {
|
|
717
|
+
if (!di.worktree) {
|
|
718
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to switch to" });
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (isInTmux()) {
|
|
722
|
+
const windowName = di.issue.identifier;
|
|
723
|
+
const sessionId = di.worktree.sessionId;
|
|
724
|
+
const bin = resolveAgentBinary();
|
|
725
|
+
const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
|
|
726
|
+
try {
|
|
727
|
+
execSync(`tmux select-window -t "${windowName}"`, { stdio: "ignore" });
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
// Window doesn't exist — create one and resume/launch
|
|
731
|
+
try {
|
|
732
|
+
execSync(`tmux new-window -n "${windowName}" -c "${di.worktree.path}"`, {
|
|
733
|
+
stdio: "ignore",
|
|
734
|
+
});
|
|
735
|
+
const cmd = resumeCmd ?? "st worktree work";
|
|
736
|
+
execSync(`tmux send-keys -t "${windowName}" "${cmd}" Enter`, {
|
|
737
|
+
stdio: "ignore",
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to switch tmux window" });
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
leaveAltScreen();
|
|
747
|
+
console.log(`SANTREE_CD:${di.worktree.path}`);
|
|
748
|
+
exit();
|
|
749
|
+
}
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
// Open in Linear
|
|
753
|
+
if (input === "o") {
|
|
754
|
+
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
755
|
+
execSync(`${openCmd} "${di.issue.url}"`, { stdio: "ignore" });
|
|
756
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened in browser" });
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
// Open PR
|
|
760
|
+
if (input === "p") {
|
|
761
|
+
if (!di.pr?.url) {
|
|
762
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to open" });
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
766
|
+
execSync(`${openCmd} "${di.pr.url}"`, { stdio: "ignore" });
|
|
767
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Opened PR in browser" });
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
// Create PR
|
|
771
|
+
if (input === "c") {
|
|
772
|
+
if (!di.worktree) {
|
|
773
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Create a worktree first (w)" });
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (di.pr) {
|
|
777
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "PR already exists" });
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
dispatch({
|
|
781
|
+
type: "PR_CREATE_START",
|
|
782
|
+
ticketId: di.issue.identifier,
|
|
783
|
+
worktreePath: di.worktree.path,
|
|
784
|
+
branch: di.worktree.branch,
|
|
785
|
+
});
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
// Review PR
|
|
789
|
+
if (input === "r") {
|
|
790
|
+
if (!di.pr || !di.worktree) {
|
|
791
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to review" });
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (isInTmux()) {
|
|
795
|
+
const windowName = `review-${di.issue.identifier}`;
|
|
796
|
+
try {
|
|
797
|
+
execSync(`tmux new-window -n "${windowName}" -c "${di.worktree.path}"`, {
|
|
798
|
+
stdio: "ignore",
|
|
799
|
+
});
|
|
800
|
+
execSync(`tmux send-keys -t "${windowName}" "st pr review" Enter`, { stdio: "ignore" });
|
|
801
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Launched review in tmux" });
|
|
802
|
+
}
|
|
803
|
+
catch {
|
|
804
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to launch review" });
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
leaveAltScreen();
|
|
809
|
+
console.log(`SANTREE_CD:${di.worktree.path}`);
|
|
810
|
+
exit();
|
|
811
|
+
}
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
// Open in editor
|
|
815
|
+
if (input === "e") {
|
|
816
|
+
if (!di.worktree) {
|
|
817
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to open" });
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
openInEditor(di.worktree.path);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
// Open workspace
|
|
824
|
+
if (input === "E") {
|
|
825
|
+
openWorkspace();
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
// Commit & push
|
|
829
|
+
if (input === "C") {
|
|
830
|
+
if (!di.worktree) {
|
|
831
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree" });
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (!di.worktree.dirty) {
|
|
835
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "No changes to commit" });
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
dispatch({
|
|
839
|
+
type: "COMMIT_START",
|
|
840
|
+
ticketId: di.issue.identifier,
|
|
841
|
+
worktreePath: di.worktree.path,
|
|
842
|
+
branch: di.worktree.branch,
|
|
843
|
+
gitStatus: di.worktree.gitStatus,
|
|
844
|
+
});
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
// Fix PR
|
|
848
|
+
if (input === "f") {
|
|
849
|
+
if (!di.pr || !di.worktree) {
|
|
850
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "No PR to fix" });
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (isInTmux()) {
|
|
854
|
+
const windowName = `fix-${di.issue.identifier}`;
|
|
855
|
+
try {
|
|
856
|
+
execSync(`tmux new-window -n "${windowName}" -c "${di.worktree.path}"`, {
|
|
857
|
+
stdio: "ignore",
|
|
858
|
+
});
|
|
859
|
+
execSync(`tmux send-keys -t "${windowName}" "st pr fix" Enter`, { stdio: "ignore" });
|
|
860
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Launched PR fix in tmux" });
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "Failed to launch PR fix" });
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
else {
|
|
867
|
+
leaveAltScreen();
|
|
868
|
+
console.log(`SANTREE_CD:${di.worktree.path}`);
|
|
869
|
+
exit();
|
|
870
|
+
}
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
// Delete worktree
|
|
874
|
+
if (input === "d") {
|
|
875
|
+
if (!di.worktree) {
|
|
876
|
+
dispatch({ type: "SET_ACTION_MESSAGE", message: "No worktree to remove" });
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
dispatch({ type: "SET_OVERLAY", overlay: "confirm-delete" });
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
// Refresh
|
|
883
|
+
if (input === "R") {
|
|
884
|
+
refresh();
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
}, { isActive: state.overlay !== "commit" || state.commitPhase !== "awaiting-message" });
|
|
888
|
+
// ── Render ─────────────────────────────────────────────────────────
|
|
889
|
+
if (state.loading) {
|
|
890
|
+
return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsxs(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading dashboard..." })] }) }));
|
|
891
|
+
}
|
|
892
|
+
if (state.error) {
|
|
893
|
+
return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsxs(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", state.error] }), _jsx(Text, { dimColor: true, children: "Press R to retry or q to quit" })] }) }));
|
|
894
|
+
}
|
|
895
|
+
if (state.flatIssues.length === 0) {
|
|
896
|
+
return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsxs(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: "No active issues assigned to you" }), _jsx(Text, { dimColor: true, children: "Press R to refresh or q to quit" })] }) }));
|
|
897
|
+
}
|
|
898
|
+
const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
|
|
899
|
+
return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Dashboard" }), _jsxs(Text, { dimColor: true, children: [" ", "(", state.flatIssues.length, " issues)", state.refreshing ? " refreshing..." : ""] }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), 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 === "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"] })] }) })) : (_jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: leftWidth, children: _jsx(IssueList, { groups: state.groups, flatIssues: state.flatIssues, selectedIndex: state.selectedIndex, scrollOffset: state.listScrollOffset, height: contentHeight, width: leftWidth, creatingForTicket: state.creatingForTicket, deletingForTicket: state.deletingForTicket }) }), _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.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 })) : (_jsx(DetailPanel, { issue: selectedIssue, scrollOffset: state.detailScrollOffset, height: contentHeight, width: rightWidth, creatingForTicket: state.creatingForTicket, creationLogs: state.creationLogs })) })] }))] }));
|
|
900
|
+
}
|