santree 0.3.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 +55 -2
- package/dist/commands/dashboard.js +538 -188
- package/dist/commands/doctor.js +164 -13
- package/dist/commands/helpers/statusline.js +10 -2
- package/dist/commands/helpers/text-editor.d.ts +13 -0
- package/dist/commands/helpers/text-editor.js +118 -0
- package/dist/commands/update.d.ts +15 -0
- package/dist/commands/update.js +72 -0
- package/dist/commands/worktree/create.d.ts +1 -0
- package/dist/commands/worktree/create.js +30 -38
- 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 +48 -14
- 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/MultilineTextArea.js +225 -82
- package/dist/lib/dashboard/Overlays.js +1 -1
- 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 +14 -8
- package/dist/lib/dashboard/external-editor.d.ts +12 -0
- package/dist/lib/dashboard/external-editor.js +74 -0
- 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 +26 -4
- package/dist/lib/git.js +45 -33
- package/dist/lib/multiplexer/cmux.d.ts +2 -0
- package/dist/lib/multiplexer/cmux.js +97 -0
- package/dist/lib/multiplexer/index.d.ts +4 -0
- package/dist/lib/multiplexer/index.js +20 -0
- package/dist/lib/multiplexer/none.d.ts +2 -0
- package/dist/lib/multiplexer/none.js +22 -0
- package/dist/lib/multiplexer/tmux.d.ts +2 -0
- package/dist/lib/multiplexer/tmux.js +82 -0
- package/dist/lib/multiplexer/types.d.ts +23 -0
- package/dist/lib/multiplexer/types.js +3 -0
- package/dist/lib/session-signal.js +5 -8
- package/dist/lib/version.d.ts +55 -0
- package/dist/lib/version.js +224 -0
- package/package.json +1 -1
- package/shell/init.zsh.njk +45 -15
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { DiffFile } from "./types.js";
|
|
2
|
+
interface Props {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
ticketId: string;
|
|
6
|
+
baseBranch: string;
|
|
7
|
+
files: DiffFile[];
|
|
8
|
+
fileIndex: number;
|
|
9
|
+
fileScrollOffset: number;
|
|
10
|
+
content: string | null;
|
|
11
|
+
contentScrollOffset: number;
|
|
12
|
+
loadingFiles: boolean;
|
|
13
|
+
loadingContent: boolean;
|
|
14
|
+
error: string | null;
|
|
15
|
+
/** Theme-adapted selection background. Falls back to dark navy. */
|
|
16
|
+
selectionBg?: string;
|
|
17
|
+
}
|
|
18
|
+
interface RenderedRow {
|
|
19
|
+
prefix: string;
|
|
20
|
+
label: string;
|
|
21
|
+
color?: string;
|
|
22
|
+
dim?: boolean;
|
|
23
|
+
bold?: boolean;
|
|
24
|
+
fileIndex: number | null;
|
|
25
|
+
}
|
|
26
|
+
export declare function flattenTreeFiles(files: DiffFile[]): DiffFile[];
|
|
27
|
+
export interface DiffLayout {
|
|
28
|
+
bodyHeight: number;
|
|
29
|
+
leftWidth: number;
|
|
30
|
+
rightWidth: number;
|
|
31
|
+
rows: RenderedRow[];
|
|
32
|
+
effectiveScroll: number;
|
|
33
|
+
selectedRowIdx: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Computes the diff overlay layout — body height, pane widths, rendered tree
|
|
37
|
+
* rows, and the effective scroll offset (clamped to keep selection visible).
|
|
38
|
+
*
|
|
39
|
+
* Shared between DiffOverlay (rendering) and the dashboard mouse handler
|
|
40
|
+
* (mapping click coords back to file indices).
|
|
41
|
+
*/
|
|
42
|
+
export declare function computeDiffLayout(opts: {
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
files: DiffFile[];
|
|
46
|
+
fileIndex: number;
|
|
47
|
+
fileScrollOffset: number;
|
|
48
|
+
}): DiffLayout;
|
|
49
|
+
export default function DiffOverlay({ width, height, ticketId, baseBranch, files, fileIndex, fileScrollOffset, content, contentScrollOffset, loadingFiles, loadingContent, error, selectionBg, }: Props): import("react/jsx-runtime").JSX.Element;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import Spinner from "ink-spinner";
|
|
4
|
+
function buildTree(files) {
|
|
5
|
+
const root = { kind: "dir", name: "", children: [] };
|
|
6
|
+
for (const file of files) {
|
|
7
|
+
const parts = file.path.split("/");
|
|
8
|
+
let cursor = root;
|
|
9
|
+
for (let i = 0; i < parts.length; i++) {
|
|
10
|
+
const part = parts[i];
|
|
11
|
+
const isLeaf = i === parts.length - 1;
|
|
12
|
+
if (isLeaf) {
|
|
13
|
+
cursor.children.push({ kind: "file", name: part, file });
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
let next = cursor.children.find((c) => c.kind === "dir" && c.name === part);
|
|
17
|
+
if (!next) {
|
|
18
|
+
next = { kind: "dir", name: part, children: [] };
|
|
19
|
+
cursor.children.push(next);
|
|
20
|
+
}
|
|
21
|
+
cursor = next;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Collapse single-child directory chains for compactness.
|
|
26
|
+
collapseChains(root);
|
|
27
|
+
return root;
|
|
28
|
+
}
|
|
29
|
+
function collapseChains(dir) {
|
|
30
|
+
for (const child of dir.children) {
|
|
31
|
+
if (child.kind !== "dir")
|
|
32
|
+
continue;
|
|
33
|
+
while (child.children.length === 1 && child.children[0].kind === "dir") {
|
|
34
|
+
const only = child.children[0];
|
|
35
|
+
child.name = `${child.name}/${only.name}`;
|
|
36
|
+
child.children = only.children;
|
|
37
|
+
}
|
|
38
|
+
collapseChains(child);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function statusColor(status) {
|
|
42
|
+
switch (status) {
|
|
43
|
+
case "A":
|
|
44
|
+
return "green";
|
|
45
|
+
case "D":
|
|
46
|
+
return "red";
|
|
47
|
+
case "M":
|
|
48
|
+
return "yellow";
|
|
49
|
+
case "R":
|
|
50
|
+
return "magenta";
|
|
51
|
+
case "C":
|
|
52
|
+
return "blue";
|
|
53
|
+
default:
|
|
54
|
+
return "gray";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function renderTree(dir, depth, rows, fileCounter) {
|
|
58
|
+
const sorted = [...dir.children].sort((a, b) => {
|
|
59
|
+
// Directories first, then files
|
|
60
|
+
if (a.kind !== b.kind)
|
|
61
|
+
return a.kind === "dir" ? -1 : 1;
|
|
62
|
+
return a.name.localeCompare(b.name);
|
|
63
|
+
});
|
|
64
|
+
const indent = " ".repeat(depth);
|
|
65
|
+
for (const child of sorted) {
|
|
66
|
+
if (child.kind === "dir") {
|
|
67
|
+
rows.push({
|
|
68
|
+
prefix: indent,
|
|
69
|
+
label: `${child.name}/`,
|
|
70
|
+
color: "blue",
|
|
71
|
+
bold: true,
|
|
72
|
+
fileIndex: null,
|
|
73
|
+
});
|
|
74
|
+
renderTree(child, depth + 1, rows, fileCounter);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const idx = fileCounter.value++;
|
|
78
|
+
rows.push({
|
|
79
|
+
prefix: indent,
|
|
80
|
+
label: `${child.file.status} ${child.name}`,
|
|
81
|
+
color: statusColor(child.file.status),
|
|
82
|
+
fileIndex: idx,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Flatten files in the same order as the rendered tree, so fileIndex matches
|
|
88
|
+
// the order users see when navigating with j/k.
|
|
89
|
+
export function flattenTreeFiles(files) {
|
|
90
|
+
const tree = buildTree(files);
|
|
91
|
+
const ordered = [];
|
|
92
|
+
const walk = (dir) => {
|
|
93
|
+
const sorted = [...dir.children].sort((a, b) => {
|
|
94
|
+
if (a.kind !== b.kind)
|
|
95
|
+
return a.kind === "dir" ? -1 : 1;
|
|
96
|
+
return a.name.localeCompare(b.name);
|
|
97
|
+
});
|
|
98
|
+
for (const c of sorted) {
|
|
99
|
+
if (c.kind === "dir")
|
|
100
|
+
walk(c);
|
|
101
|
+
else
|
|
102
|
+
ordered.push(c.file);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
walk(tree);
|
|
106
|
+
return ordered;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Computes the diff overlay layout — body height, pane widths, rendered tree
|
|
110
|
+
* rows, and the effective scroll offset (clamped to keep selection visible).
|
|
111
|
+
*
|
|
112
|
+
* Shared between DiffOverlay (rendering) and the dashboard mouse handler
|
|
113
|
+
* (mapping click coords back to file indices).
|
|
114
|
+
*/
|
|
115
|
+
export function computeDiffLayout(opts) {
|
|
116
|
+
const headerHeight = 2;
|
|
117
|
+
const footerHeight = 1;
|
|
118
|
+
const bodyHeight = Math.max(3, opts.height - headerHeight - footerHeight);
|
|
119
|
+
const leftWidth = Math.min(48, Math.max(24, Math.floor(opts.width * 0.32)));
|
|
120
|
+
const rightWidth = Math.max(20, opts.width - leftWidth - 1);
|
|
121
|
+
const rows = [];
|
|
122
|
+
const tree = buildTree(opts.files);
|
|
123
|
+
renderTree(tree, 0, rows, { value: 0 });
|
|
124
|
+
const selectedRowIdx = rows.findIndex((r) => r.fileIndex === opts.fileIndex);
|
|
125
|
+
const totalRows = rows.length;
|
|
126
|
+
const maxScroll = Math.max(0, totalRows - bodyHeight);
|
|
127
|
+
let effectiveScroll = Math.min(opts.fileScrollOffset, maxScroll);
|
|
128
|
+
if (selectedRowIdx >= 0) {
|
|
129
|
+
if (selectedRowIdx < effectiveScroll) {
|
|
130
|
+
effectiveScroll = selectedRowIdx;
|
|
131
|
+
}
|
|
132
|
+
else if (selectedRowIdx >= effectiveScroll + bodyHeight) {
|
|
133
|
+
effectiveScroll = selectedRowIdx - bodyHeight + 1;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { bodyHeight, leftWidth, rightWidth, rows, effectiveScroll, selectedRowIdx };
|
|
137
|
+
}
|
|
138
|
+
function colorizeDiffLine(line) {
|
|
139
|
+
if (line.startsWith("diff --git") || line.startsWith("index ")) {
|
|
140
|
+
return { text: line, color: "yellow", bold: true };
|
|
141
|
+
}
|
|
142
|
+
if (line.startsWith("+++") || line.startsWith("---")) {
|
|
143
|
+
return { text: line, color: "yellow", dim: true };
|
|
144
|
+
}
|
|
145
|
+
if (line.startsWith("@@")) {
|
|
146
|
+
return { text: line, color: "cyan" };
|
|
147
|
+
}
|
|
148
|
+
if (line.startsWith("+")) {
|
|
149
|
+
return { text: line, color: "green" };
|
|
150
|
+
}
|
|
151
|
+
if (line.startsWith("-")) {
|
|
152
|
+
return { text: line, color: "red" };
|
|
153
|
+
}
|
|
154
|
+
return { text: line };
|
|
155
|
+
}
|
|
156
|
+
// Content from external diff tools (delta, diff-so-fancy, etc.) ships its own
|
|
157
|
+
// ANSI escapes. Detecting them lets us skip our manual colorize and render the
|
|
158
|
+
// raw text — Ink passes ANSI through to the terminal natively.
|
|
159
|
+
function looksLikeAnsi(text) {
|
|
160
|
+
return /\x1b\[[0-9;]*[A-Za-z]/.test(text);
|
|
161
|
+
}
|
|
162
|
+
const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
163
|
+
/**
|
|
164
|
+
* Truncate a line to `max` visible columns, preserving any ANSI escape
|
|
165
|
+
* sequences along the way. Ink's built-in `wrap="truncate"` measures by string
|
|
166
|
+
* length (counting escapes as visible chars) and stops short or, with very long
|
|
167
|
+
* lines, lets content bleed past the box's right edge — which on the diff
|
|
168
|
+
* pane caused lines to spill into the column to the right. Doing the math
|
|
169
|
+
* ourselves avoids both bugs.
|
|
170
|
+
*/
|
|
171
|
+
function truncateVisible(s, max) {
|
|
172
|
+
if (max <= 0)
|
|
173
|
+
return "";
|
|
174
|
+
if (s.replace(ANSI_RE, "").length <= max)
|
|
175
|
+
return s;
|
|
176
|
+
let out = "";
|
|
177
|
+
let visible = 0;
|
|
178
|
+
let i = 0;
|
|
179
|
+
while (i < s.length && visible < max - 1) {
|
|
180
|
+
const ch = s[i];
|
|
181
|
+
if (ch === "\x1b" && s[i + 1] === "[") {
|
|
182
|
+
ANSI_RE.lastIndex = i;
|
|
183
|
+
const m = ANSI_RE.exec(s);
|
|
184
|
+
if (m && m.index === i) {
|
|
185
|
+
out += m[0];
|
|
186
|
+
i += m[0].length;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
out += ch;
|
|
191
|
+
visible++;
|
|
192
|
+
i++;
|
|
193
|
+
}
|
|
194
|
+
return out + "…";
|
|
195
|
+
}
|
|
196
|
+
// ── Component ─────────────────────────────────────────────────────────
|
|
197
|
+
export default function DiffOverlay({ width, height, ticketId, baseBranch, files, fileIndex, fileScrollOffset, content, contentScrollOffset, loadingFiles, loadingContent, error, selectionBg = "#1e3a5f", }) {
|
|
198
|
+
const layout = computeDiffLayout({ width, height, files, fileIndex, fileScrollOffset });
|
|
199
|
+
const { bodyHeight, leftWidth, rightWidth, rows, effectiveScroll, selectedRowIdx } = layout;
|
|
200
|
+
const visibleRows = rows.slice(effectiveScroll, effectiveScroll + bodyHeight);
|
|
201
|
+
// Right pane: split content into lines and slice for scroll. If the content
|
|
202
|
+
// already carries ANSI escapes (from an external diff tool), pass them
|
|
203
|
+
// through as-is; otherwise apply our built-in line-prefix colorization.
|
|
204
|
+
// Clamp scroll so the deepest position lands the last line at the bottom.
|
|
205
|
+
const isExternalContent = content ? looksLikeAnsi(content) : false;
|
|
206
|
+
const rawLines = content ? content.split("\n") : [];
|
|
207
|
+
const allLines = isExternalContent
|
|
208
|
+
? rawLines.map((text) => ({ text }))
|
|
209
|
+
: rawLines.map(colorizeDiffLine);
|
|
210
|
+
const maxContentScroll = Math.max(0, allLines.length - bodyHeight);
|
|
211
|
+
const effectiveContentScroll = Math.min(Math.max(0, contentScrollOffset), maxContentScroll);
|
|
212
|
+
const visibleLines = allLines.slice(effectiveContentScroll, effectiveContentScroll + bodyHeight);
|
|
213
|
+
const totalFiles = files.length;
|
|
214
|
+
const currentFile = files[fileIndex];
|
|
215
|
+
// Pre-compute path truncation. Letting each <Text> rely on wrap="truncate"
|
|
216
|
+
// inside a flex row caused the path to wrap onto a new line when the row
|
|
217
|
+
// overflowed — pushing the tabs above off-screen. Manual truncation on the
|
|
218
|
+
// only variable-length segment keeps the header strictly one line.
|
|
219
|
+
const filesLabel = `(${totalFiles} ${totalFiles === 1 ? "file" : "files"})`;
|
|
220
|
+
const meta = ` ${ticketId} vs ${baseBranch} ${filesLabel}`;
|
|
221
|
+
const sep = " • ";
|
|
222
|
+
const consumed = "Diff".length + meta.length;
|
|
223
|
+
const pathRoom = Math.max(0, width - consumed - sep.length);
|
|
224
|
+
let truncatedPath = "";
|
|
225
|
+
if (currentFile) {
|
|
226
|
+
const p = currentFile.path;
|
|
227
|
+
truncatedPath = p.length > pathRoom ? "…" + p.slice(-Math.max(0, pathRoom - 1)) : p;
|
|
228
|
+
}
|
|
229
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, overflow: "hidden", children: [_jsxs(Box, { flexShrink: 0, width: width, children: [_jsx(Text, { bold: true, color: "cyan", children: "Diff" }), _jsx(Text, { dimColor: true, children: meta }), currentFile && pathRoom > 0 && _jsx(Text, { dimColor: true, children: sep }), currentFile && pathRoom > 0 && _jsx(Text, { children: truncatedPath })] }), _jsx(Box, { flexShrink: 0, width: width, children: _jsx(Text, { dimColor: true, wrap: "truncate", children: "─".repeat(width) }) }), _jsxs(Box, { height: bodyHeight, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", width: leftWidth, height: bodyHeight, overflow: "hidden", paddingRight: 1, children: loadingFiles ? (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " Loading files..." })] })) : error ? (_jsx(Text, { color: "red", children: error })) : files.length === 0 ? (_jsx(Text, { dimColor: true, children: "No changes" })) : (visibleRows.map((row, i) => {
|
|
230
|
+
const absIdx = effectiveScroll + i;
|
|
231
|
+
const isSelected = absIdx === selectedRowIdx;
|
|
232
|
+
const text = `${row.prefix}${row.label}`;
|
|
233
|
+
// Selected row keeps its own color (file-status hue or directory
|
|
234
|
+
// blue) but gets the theme-aware selection bg + bold so it stays
|
|
235
|
+
// readable in light and dark modes alike.
|
|
236
|
+
return (_jsx(Text, { color: row.color, backgroundColor: isSelected ? selectionBg : undefined, bold: row.bold || isSelected, dimColor: row.dim, wrap: "truncate", children: text }, i));
|
|
237
|
+
})) }), _jsx(Box, { flexDirection: "column", height: bodyHeight, children: Array.from({ length: bodyHeight }).map((_, i) => (_jsx(Text, { dimColor: true, children: "\u2502" }, i))) }), _jsx(Box, { flexDirection: "column", width: rightWidth, height: bodyHeight, overflow: "hidden", paddingLeft: 1, children: loadingContent ? (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " Loading diff..." })] })) : !currentFile ? (_jsx(Text, { dimColor: true, children: "Select a file" })) : visibleLines.length === 0 ? (_jsx(Text, { dimColor: true, children: "(empty diff)" })) : (visibleLines.map((line, i) => {
|
|
238
|
+
// rightWidth includes the paddingLeft={1} of the wrapper Box,
|
|
239
|
+
// so usable column count is rightWidth - 1.
|
|
240
|
+
const cell = truncateVisible(line.text || " ", Math.max(1, rightWidth - 1));
|
|
241
|
+
return (_jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, children: cell }, i));
|
|
242
|
+
})) })] }), _jsx(Box, { flexShrink: 0, width: width, children: _jsx(Text, { dimColor: true, wrap: "truncate", children: "j/k file \u2022 J/K scroll \u2022 g/G top/bot \u2022 click select \u2022 wheel scroll \u2022 q close" }) })] }));
|
|
243
|
+
}
|
|
@@ -6,8 +6,25 @@ interface Props {
|
|
|
6
6
|
scrollOffset: number;
|
|
7
7
|
height: number;
|
|
8
8
|
width: number;
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
/** Theme-adapted selection background (light/dark). Falls back to dark navy. */
|
|
10
|
+
selectionBg?: string;
|
|
11
11
|
}
|
|
12
|
-
export
|
|
12
|
+
export type ListRow = {
|
|
13
|
+
kind: "header";
|
|
14
|
+
name: string;
|
|
15
|
+
count: number;
|
|
16
|
+
isFirst: boolean;
|
|
17
|
+
} | {
|
|
18
|
+
kind: "status-header";
|
|
19
|
+
name: string;
|
|
20
|
+
type: string;
|
|
21
|
+
count: number;
|
|
22
|
+
} | {
|
|
23
|
+
kind: "issue";
|
|
24
|
+
issue: DashboardIssue;
|
|
25
|
+
flatIndex: number;
|
|
26
|
+
depth: number;
|
|
27
|
+
};
|
|
28
|
+
export declare function buildIssueListRows(groups: ProjectGroup[], flatIssues: DashboardIssue[]): ListRow[];
|
|
29
|
+
export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg, }: Props): import("react/jsx-runtime").JSX.Element;
|
|
13
30
|
export {};
|
|
@@ -21,130 +21,101 @@ function stateColor(type, name) {
|
|
|
21
21
|
return "yellow";
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
24
|
+
/**
|
|
25
|
+
* One-character priority marker, only rendered for urgent (P1) and high (P2)
|
|
26
|
+
* — everything else is a single space so columns still align. This is one of
|
|
27
|
+
* the few places we use color, so it stands out without shouting.
|
|
28
|
+
*/
|
|
29
|
+
function priorityMarker(priority) {
|
|
30
|
+
if (priority === 1)
|
|
31
|
+
return { glyph: "▎", color: "red" };
|
|
32
|
+
if (priority === 2)
|
|
33
|
+
return { glyph: "▎", color: "yellow" };
|
|
34
|
+
return { glyph: " ", color: "gray" };
|
|
35
|
+
}
|
|
36
|
+
function workIndicator(wt) {
|
|
37
|
+
if (!wt)
|
|
38
|
+
return { glyph: "·", color: "gray" };
|
|
39
|
+
return { glyph: "✓", color: "green" };
|
|
37
40
|
}
|
|
38
|
-
function
|
|
41
|
+
function ciIndicator(checks) {
|
|
39
42
|
if (!checks || checks.length === 0)
|
|
40
|
-
return {
|
|
43
|
+
return { glyph: "·", color: "gray" };
|
|
41
44
|
if (checks.some((c) => c.bucket === "fail"))
|
|
42
|
-
return {
|
|
45
|
+
return { glyph: "✗", color: "red" };
|
|
43
46
|
if (checks.every((c) => c.bucket === "pass"))
|
|
44
|
-
return {
|
|
45
|
-
return {
|
|
47
|
+
return { glyph: "✓", color: "green" };
|
|
48
|
+
return { glyph: "●", color: "yellow" };
|
|
46
49
|
}
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
-
return { text: "-", color: "gray" };
|
|
50
|
-
const label = `#${pr.number}`;
|
|
51
|
-
if (pr.state === "MERGED")
|
|
52
|
-
return { text: label, color: "magenta" };
|
|
53
|
-
if (pr.state === "CLOSED")
|
|
54
|
-
return { text: label, color: "red" };
|
|
55
|
-
if (pr.isDraft)
|
|
56
|
-
return { text: label, color: "gray" };
|
|
57
|
-
return { text: label, color: "green" };
|
|
58
|
-
}
|
|
59
|
-
function sessionIndicator(wt, isCreating, isDeleting) {
|
|
60
|
-
if (isDeleting)
|
|
61
|
-
return { text: " deleting", color: "red" };
|
|
62
|
-
if (isCreating)
|
|
63
|
-
return { text: " creating", color: "yellow" };
|
|
64
|
-
if (!wt)
|
|
65
|
-
return { text: " -", color: "gray" };
|
|
66
|
-
// Session state takes priority over session ID
|
|
67
|
-
if (wt.sessionState === "waiting")
|
|
68
|
-
return { text: " waiting!", color: "red" };
|
|
69
|
-
if (wt.sessionState === "active")
|
|
70
|
-
return { text: " active", color: "green" };
|
|
71
|
-
if (wt.sessionState === "idle")
|
|
72
|
-
return { text: " idle", color: "yellow" };
|
|
73
|
-
if (wt.sessionId)
|
|
74
|
-
return { text: " " + wt.sessionId.slice(0, 8), color: "cyan" };
|
|
75
|
-
return { text: " none", color: "red" };
|
|
76
|
-
}
|
|
77
|
-
function buildRows(groups, flatIssues) {
|
|
78
|
-
const rows = [{ kind: "columns" }];
|
|
79
|
-
// Build a map from issue identifier to flat index
|
|
50
|
+
export function buildIssueListRows(groups, flatIssues) {
|
|
51
|
+
const rows = [];
|
|
80
52
|
const indexMap = new Map();
|
|
81
53
|
flatIssues.forEach((di, i) => indexMap.set(di.issue.identifier, i));
|
|
82
54
|
function pushIssueWithChildren(di, depth) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
issue: di,
|
|
86
|
-
flatIndex: indexMap.get(di.issue.identifier) ?? -1,
|
|
87
|
-
depth,
|
|
88
|
-
});
|
|
55
|
+
const flatIndex = indexMap.get(di.issue.identifier) ?? -1;
|
|
56
|
+
rows.push({ kind: "issue", issue: di, flatIndex, depth });
|
|
89
57
|
if (di.children) {
|
|
90
58
|
for (const child of di.children) {
|
|
91
59
|
pushIssueWithChildren(child, depth + 1);
|
|
92
60
|
}
|
|
93
61
|
}
|
|
94
62
|
}
|
|
95
|
-
|
|
63
|
+
groups.forEach((group, gi) => {
|
|
96
64
|
const totalIssues = group.statusGroups.reduce((sum, sg) => sum + sg.issues.length, 0);
|
|
97
|
-
rows.push({ kind: "header", name: group.name, count: totalIssues });
|
|
65
|
+
rows.push({ kind: "header", name: group.name, count: totalIssues, isFirst: gi === 0 });
|
|
98
66
|
for (const sg of group.statusGroups) {
|
|
99
67
|
rows.push({ kind: "status-header", name: sg.name, type: sg.type, count: sg.issues.length });
|
|
100
68
|
for (const di of sg.issues) {
|
|
101
69
|
pushIssueWithChildren(di, 0);
|
|
102
70
|
}
|
|
103
71
|
}
|
|
104
|
-
}
|
|
72
|
+
});
|
|
105
73
|
return rows;
|
|
106
74
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
75
|
+
// Issue row anatomy:
|
|
76
|
+
// priority(1) │ space(1) │ dot(1) │ space(2) │ id(11) │ space(1) │ title(rest) │ pad │ WT(2) │ space(2) │ CI(2)
|
|
77
|
+
// Each right-side column is 2 chars wide (matching the 2-char header label).
|
|
78
|
+
// Glyphs are 1 char and rendered right-aligned within their column.
|
|
79
|
+
const LEFT_FIXED = 1 + 1 + 1 + 2 + 11; // 16 — left-aligned columns
|
|
80
|
+
const RIGHT_FIXED = 2 + 2 + 2; // 6 — WT + 2 spaces + CI
|
|
81
|
+
const TITLE_GAP = 2; // minimum spacing between title and the right columns
|
|
82
|
+
export default function IssueList({ groups, flatIssues, selectedIndex, scrollOffset, height, width, selectionBg = "#1e3a5f", }) {
|
|
83
|
+
const rows = buildIssueListRows(groups, flatIssues);
|
|
84
|
+
const visible = rows.slice(scrollOffset, scrollOffset + height);
|
|
85
|
+
const titleMaxWidth = Math.max(width - LEFT_FIXED - 1 /* leading space */ - RIGHT_FIXED - TITLE_GAP, 10);
|
|
86
|
+
return (_jsx(Box, { flexDirection: "column", width: width, height: height, children: _jsx(Box, { flexDirection: "column", height: height, children: visible.map((row, i) => {
|
|
87
|
+
if (row.kind === "header") {
|
|
88
|
+
// On the first project header, also render the WT/CI column
|
|
89
|
+
// labels right-aligned to the worktree/CI glyph columns —
|
|
90
|
+
// keeps the labels discoverable without burning a row.
|
|
91
|
+
// Label "WT CI" is 5 chars; the "W" lines up with the WT
|
|
92
|
+
// glyph at column (width - RIGHT_FIXED + 1).
|
|
93
|
+
const namePart = `${row.name} ${row.count}`;
|
|
94
|
+
const labelText = "WT CI";
|
|
95
|
+
const labelPad = row.isFirst
|
|
96
|
+
? Math.max(2, width - namePart.length - labelText.length)
|
|
97
|
+
: 0;
|
|
98
|
+
return (_jsxs(Box, { marginTop: i === 0 ? 0 : 1, children: [_jsx(Text, { bold: true, children: row.name }), _jsxs(Text, { dimColor: true, children: [" ", row.count] }), row.isFirst && _jsx(Text, { dimColor: true, children: `${" ".repeat(labelPad)}${labelText}` })] }, `h-${i}`));
|
|
99
|
+
}
|
|
100
|
+
if (row.kind === "status-header") {
|
|
101
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: stateColor(row.type, row.name), children: [" ", row.name] }), _jsxs(Text, { dimColor: true, children: [" ", row.count] })] }, `sh-${i}`));
|
|
102
|
+
}
|
|
103
|
+
const { issue, flatIndex, depth } = row;
|
|
104
|
+
const selected = flatIndex === selectedIndex;
|
|
105
|
+
const di = issue;
|
|
106
|
+
const sc = stateColor(di.issue.state.type, di.issue.state.name);
|
|
107
|
+
const prio = priorityMarker(di.issue.priority);
|
|
108
|
+
const work = workIndicator(di.worktree);
|
|
109
|
+
const ci = ciIndicator(di.checks);
|
|
110
|
+
const nestPrefix = depth > 0 ? " ".repeat(depth - 1) + "└ " : "";
|
|
111
|
+
const adjustedTitleWidth = Math.max(titleMaxWidth - nestPrefix.length, 5);
|
|
112
|
+
const title = di.issue.title.length > adjustedTitleWidth
|
|
113
|
+
? di.issue.title.slice(0, adjustedTitleWidth - 1) + "…"
|
|
114
|
+
: di.issue.title;
|
|
115
|
+
const bg = selected ? selectionBg : undefined;
|
|
116
|
+
// Pad between title and the right columns so the W/CI markers stay
|
|
117
|
+
// pinned to the right edge regardless of title length.
|
|
118
|
+
const trailingPad = Math.max(0, width - LEFT_FIXED - 1 - nestPrefix.length - title.length - RIGHT_FIXED);
|
|
119
|
+
return (_jsxs(Box, { width: width, children: [_jsx(Text, { backgroundColor: bg, color: prio.color, children: prio.glyph }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: sc, children: "\u25CF" }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsxs(Text, { backgroundColor: bg, dimColor: true, children: [nestPrefix, di.issue.identifier.padEnd(10)] }), _jsxs(Text, { backgroundColor: bg, bold: selected, children: [" ", title] }), _jsx(Text, { backgroundColor: bg, children: " ".repeat(trailingPad) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: work.color, children: work.glyph }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: ci.color, children: ci.glyph })] }, di.issue.identifier));
|
|
120
|
+
}) }) }));
|
|
150
121
|
}
|