santree 0.2.8 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/dashboard.js +390 -40
- package/dist/lib/ai.js +5 -1
- package/dist/lib/dashboard/IssueList.js +20 -5
- package/dist/lib/dashboard/ReviewDetailPanel.d.ts +9 -0
- package/dist/lib/dashboard/ReviewDetailPanel.js +166 -0
- package/dist/lib/dashboard/ReviewList.d.ts +11 -0
- package/dist/lib/dashboard/ReviewList.js +53 -0
- package/dist/lib/dashboard/data.d.ts +4 -1
- package/dist/lib/dashboard/data.js +113 -6
- package/dist/lib/dashboard/types.d.ts +53 -2
- package/dist/lib/dashboard/types.js +68 -2
- package/dist/lib/github.d.ts +41 -0
- package/dist/lib/github.js +55 -0
- package/package.json +1 -1
|
@@ -79,13 +79,26 @@ function buildRows(groups, flatIssues) {
|
|
|
79
79
|
// Build a map from issue identifier to flat index
|
|
80
80
|
const indexMap = new Map();
|
|
81
81
|
flatIssues.forEach((di, i) => indexMap.set(di.issue.identifier, i));
|
|
82
|
+
function pushIssueWithChildren(di, depth) {
|
|
83
|
+
rows.push({
|
|
84
|
+
kind: "issue",
|
|
85
|
+
issue: di,
|
|
86
|
+
flatIndex: indexMap.get(di.issue.identifier) ?? -1,
|
|
87
|
+
depth,
|
|
88
|
+
});
|
|
89
|
+
if (di.children) {
|
|
90
|
+
for (const child of di.children) {
|
|
91
|
+
pushIssueWithChildren(child, depth + 1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
82
95
|
for (const group of groups) {
|
|
83
96
|
const totalIssues = group.statusGroups.reduce((sum, sg) => sum + sg.issues.length, 0);
|
|
84
97
|
rows.push({ kind: "header", name: group.name, count: totalIssues });
|
|
85
98
|
for (const sg of group.statusGroups) {
|
|
86
99
|
rows.push({ kind: "status-header", name: sg.name, type: sg.type, count: sg.issues.length });
|
|
87
100
|
for (const di of sg.issues) {
|
|
88
|
-
|
|
101
|
+
pushIssueWithChildren(di, 0);
|
|
89
102
|
}
|
|
90
103
|
}
|
|
91
104
|
}
|
|
@@ -115,7 +128,7 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
|
|
|
115
128
|
if (row.kind === "status-header") {
|
|
116
129
|
return (_jsx(Box, { children: _jsxs(Text, { color: stateColor(row.type, row.name), dimColor: true, children: [" ", row.name, " (", row.count, ")"] }) }, `sh-${i}`));
|
|
117
130
|
}
|
|
118
|
-
const { issue, flatIndex } = row;
|
|
131
|
+
const { issue, flatIndex, depth } = row;
|
|
119
132
|
const selected = flatIndex === selectedIndex;
|
|
120
133
|
const di = issue;
|
|
121
134
|
const sc = stateColor(di.issue.state.type, di.issue.state.name);
|
|
@@ -126,10 +139,12 @@ export default function IssueList({ groups, flatIssues, selectedIndex, scrollOff
|
|
|
126
139
|
const pr = prIndicator(di.pr);
|
|
127
140
|
const prio = priorityIndicator(di.issue.priority);
|
|
128
141
|
const cursor = selected ? ">" : " ";
|
|
129
|
-
const
|
|
130
|
-
|
|
142
|
+
const nestPrefix = depth > 0 ? " ".repeat(depth - 1) + "└ " : "";
|
|
143
|
+
const adjustedTitleWidth = Math.max(titleMaxWidth - nestPrefix.length, 5);
|
|
144
|
+
const title = di.issue.title.length > adjustedTitleWidth
|
|
145
|
+
? di.issue.title.slice(0, adjustedTitleWidth - 1) + "…"
|
|
131
146
|
: di.issue.title;
|
|
132
147
|
const bg = selected ? "#1e3a5f" : undefined;
|
|
133
|
-
return (_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [cursor, " "] }), _jsx(Text, { backgroundColor: bg, color: sc, children: "\u25CF" }), _jsxs(Text, { backgroundColor: bg, color: prio.color, children: [" ", prio.text] }),
|
|
148
|
+
return (_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [cursor, " "] }), _jsx(Text, { backgroundColor: bg, color: sc, children: "\u25CF" }), _jsxs(Text, { backgroundColor: bg, color: prio.color, children: [" ", prio.text] }), _jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [nestPrefix, di.issue.identifier.padEnd(10)] }), _jsx(Text, { backgroundColor: bg, color: selected ? "white" : undefined, bold: selected, children: title.padEnd(adjustedTitleWidth) }), _jsx(Text, { backgroundColor: bg, color: selected ? (sess.color === "gray" ? "gray" : sess.color) : sess.color, children: sess.text.padStart(sessionColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (pr.color === "gray" ? "gray" : pr.color) : pr.color, children: pr.text.padStart(prColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (ci.color === "gray" ? "gray" : ci.color) : ci.color, children: ci.text.padStart(checksColWidth) })] }, di.issue.identifier));
|
|
134
149
|
}) }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: footerRule }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { color: "white", children: " Navigate" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "Shift + \u2191\u2193" }), _jsx(Text, { color: "white", children: " Scroll detail" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "E" }), _jsx(Text, { color: "white", children: " Workspace" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "R" }), _jsx(Text, { color: "white", children: " Refresh" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { color: "white", children: " Quit" })] })] })] }));
|
|
135
150
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { EnrichedReviewPR } from "./types.js";
|
|
2
|
+
interface Props {
|
|
3
|
+
item: EnrichedReviewPR | null;
|
|
4
|
+
scrollOffset: number;
|
|
5
|
+
height: number;
|
|
6
|
+
width: number;
|
|
7
|
+
}
|
|
8
|
+
export default function ReviewDetailPanel({ item, scrollOffset, height, width }: Props): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
function relativeTime(dateStr) {
|
|
4
|
+
const now = Date.now();
|
|
5
|
+
const then = new Date(dateStr).getTime();
|
|
6
|
+
const diffMs = now - then;
|
|
7
|
+
const minutes = Math.floor(diffMs / 60_000);
|
|
8
|
+
if (minutes < 1)
|
|
9
|
+
return "just now";
|
|
10
|
+
if (minutes < 60)
|
|
11
|
+
return `${minutes}m ago`;
|
|
12
|
+
const hours = Math.floor(minutes / 60);
|
|
13
|
+
if (hours < 24)
|
|
14
|
+
return `${hours}h ago`;
|
|
15
|
+
const days = Math.floor(hours / 24);
|
|
16
|
+
if (days < 30)
|
|
17
|
+
return `${days}d ago`;
|
|
18
|
+
const months = Math.floor(days / 30);
|
|
19
|
+
return `${months}mo ago`;
|
|
20
|
+
}
|
|
21
|
+
function buildActions(item) {
|
|
22
|
+
const items = [];
|
|
23
|
+
if (item.worktree) {
|
|
24
|
+
items.push({ key: "r", label: "AI Review", color: "cyan" });
|
|
25
|
+
items.push({ key: "e", label: "Editor", color: "cyan" });
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
items.push({ key: "w", label: "Checkout", color: "cyan" });
|
|
29
|
+
}
|
|
30
|
+
items.push({ key: "o", label: "Open PR", color: "gray" });
|
|
31
|
+
if (item.worktree) {
|
|
32
|
+
items.push({ key: "d", label: "Remove", color: "red" });
|
|
33
|
+
}
|
|
34
|
+
return items;
|
|
35
|
+
}
|
|
36
|
+
export default function ReviewDetailPanel({ item, scrollOffset, height, width }) {
|
|
37
|
+
if (!item) {
|
|
38
|
+
return (_jsx(Box, { width: width, height: height, justifyContent: "center", alignItems: "center", children: _jsx(Text, { dimColor: true, children: "No PR selected" }) }));
|
|
39
|
+
}
|
|
40
|
+
const { pr } = item;
|
|
41
|
+
const lines = [];
|
|
42
|
+
const rule = "\u2500".repeat(width);
|
|
43
|
+
// ── Hero ──────────────────────────────────────────────────────────
|
|
44
|
+
lines.push({ text: `#${pr.number} ${pr.title}`, bold: true });
|
|
45
|
+
const meta = [`by ${pr.author.login}`];
|
|
46
|
+
if (pr.isDraft)
|
|
47
|
+
meta.push("draft");
|
|
48
|
+
meta.push(relativeTime(pr.updatedAt));
|
|
49
|
+
lines.push({ text: meta.join(" \u00b7 "), color: "cyan" });
|
|
50
|
+
// ── Changes ──────────────────────────────────────────────────────
|
|
51
|
+
if (item.changedFiles > 0) {
|
|
52
|
+
lines.push({
|
|
53
|
+
text: `${item.changedFiles} files +${item.additions} -${item.deletions}`,
|
|
54
|
+
color: "green",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// ── Branch ───────────────────────────────────────────────────────
|
|
58
|
+
if (item.branch) {
|
|
59
|
+
lines.push({ text: rule, dim: true });
|
|
60
|
+
lines.push({ text: "BRANCH", dim: true });
|
|
61
|
+
lines.push({ text: ` ${item.branch}` });
|
|
62
|
+
if (item.baseBranch) {
|
|
63
|
+
lines.push({ text: ` base: ${item.baseBranch}`, dim: true });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// ── Worktree ─────────────────────────────────────────────────────
|
|
67
|
+
if (item.worktree) {
|
|
68
|
+
lines.push({ text: rule, dim: true });
|
|
69
|
+
lines.push({ text: "WORKTREE", dim: true });
|
|
70
|
+
lines.push({ text: ` ${item.worktree.path}`, dim: true });
|
|
71
|
+
const statusParts = [];
|
|
72
|
+
if (item.worktree.dirty)
|
|
73
|
+
statusParts.push("dirty");
|
|
74
|
+
if (item.worktree.commitsAhead > 0)
|
|
75
|
+
statusParts.push(`+${item.worktree.commitsAhead} ahead`);
|
|
76
|
+
if (statusParts.length > 0) {
|
|
77
|
+
lines.push({ text: ` ${statusParts.join(" ")}`, color: "yellow" });
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
lines.push({ text: " \u2713 clean", color: "green" });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// ── Description ──────────────────────────────────────────────────
|
|
84
|
+
if (item.body) {
|
|
85
|
+
lines.push({ text: rule, dim: true });
|
|
86
|
+
lines.push({ text: "DESCRIPTION", dim: true });
|
|
87
|
+
lines.push({ text: "" });
|
|
88
|
+
for (const line of item.body.trimEnd().split("\n")) {
|
|
89
|
+
lines.push({ text: line });
|
|
90
|
+
}
|
|
91
|
+
lines.push({ text: "" });
|
|
92
|
+
}
|
|
93
|
+
// ── Checks ───────────────────────────────────────────────────────
|
|
94
|
+
if (item.checks && item.checks.length > 0) {
|
|
95
|
+
const passCount = item.checks.filter((c) => c.bucket === "pass").length;
|
|
96
|
+
lines.push({ text: rule, dim: true });
|
|
97
|
+
lines.push({ text: `CHECKS ${passCount}/${item.checks.length} passing`, dim: true });
|
|
98
|
+
for (const check of item.checks) {
|
|
99
|
+
if (check.bucket === "pass") {
|
|
100
|
+
lines.push({ text: ` \u2713 ${check.name}`, color: "green" });
|
|
101
|
+
}
|
|
102
|
+
else if (check.bucket === "fail") {
|
|
103
|
+
const desc = check.description ? ` \u2014 ${check.description}` : "";
|
|
104
|
+
lines.push({ text: ` \u2717 ${check.name}${desc}`, color: "red" });
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
lines.push({ text: ` \u25cf ${check.name} (pending)`, color: "yellow" });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// ── Reviews ──────────────────────────────────────────────────────
|
|
112
|
+
if (item.reviews && item.reviews.length > 0) {
|
|
113
|
+
lines.push({ text: rule, dim: true });
|
|
114
|
+
lines.push({ text: "REVIEWS", dim: true });
|
|
115
|
+
for (const review of item.reviews) {
|
|
116
|
+
const author = review.author.login;
|
|
117
|
+
const rc = review.state === "APPROVED"
|
|
118
|
+
? "green"
|
|
119
|
+
: review.state === "CHANGES_REQUESTED"
|
|
120
|
+
? "red"
|
|
121
|
+
: "yellow";
|
|
122
|
+
lines.push({ text: ` ${author} ${review.state}`, color: rc });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// ── Comments ─────────────────────────────────────────────────────
|
|
126
|
+
if (item.comments && item.comments.length > 0) {
|
|
127
|
+
lines.push({ text: rule, dim: true });
|
|
128
|
+
lines.push({ text: `COMMENTS ${item.comments.length}`, dim: true });
|
|
129
|
+
// Show last 5 comments
|
|
130
|
+
const recent = item.comments.slice(-5);
|
|
131
|
+
for (const comment of recent) {
|
|
132
|
+
lines.push({ text: "" });
|
|
133
|
+
lines.push({
|
|
134
|
+
text: ` ${comment.author} ${relativeTime(comment.createdAt)}`,
|
|
135
|
+
color: "cyan",
|
|
136
|
+
});
|
|
137
|
+
// Truncate long comments to ~4 lines
|
|
138
|
+
const bodyLines = comment.body.trimEnd().split("\n");
|
|
139
|
+
const maxLines = 4;
|
|
140
|
+
for (let i = 0; i < Math.min(bodyLines.length, maxLines); i++) {
|
|
141
|
+
lines.push({ text: ` ${bodyLines[i]}` });
|
|
142
|
+
}
|
|
143
|
+
if (bodyLines.length > maxLines) {
|
|
144
|
+
lines.push({ text: ` +${bodyLines.length - maxLines} more lines`, dim: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// ── Build actions footer ─────────────────────────────────────────
|
|
149
|
+
const actionItems = buildActions(item);
|
|
150
|
+
const actionsHeight = 2; // separator + action row
|
|
151
|
+
const scrollableHeight = height - actionsHeight;
|
|
152
|
+
const totalLines = lines.length;
|
|
153
|
+
const canScroll = totalLines > scrollableHeight;
|
|
154
|
+
const contentRows = canScroll ? scrollableHeight - 2 : scrollableHeight;
|
|
155
|
+
const clampedOffset = Math.min(scrollOffset, Math.max(0, totalLines - contentRows));
|
|
156
|
+
const visible = lines.slice(clampedOffset, clampedOffset + contentRows);
|
|
157
|
+
let scrollArrow = null;
|
|
158
|
+
if (canScroll) {
|
|
159
|
+
const atTop = clampedOffset === 0;
|
|
160
|
+
const atBottom = clampedOffset + contentRows >= totalLines;
|
|
161
|
+
scrollArrow = atTop ? "\u2193 scroll" : atBottom ? "\u2191 scroll" : "\u2191\u2193 scroll";
|
|
162
|
+
}
|
|
163
|
+
// Truncate lines to panel width to prevent overflow into left pane
|
|
164
|
+
const clamp = (text) => text.length > width ? text.slice(0, width - 1) + "\u2026" : text;
|
|
165
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, overflowX: "hidden", children: [visible.map((line, i) => (_jsx(Box, { children: _jsx(Text, { color: line.color, bold: line.bold, dimColor: line.dim, children: line.text ? clamp(line.text) : " " }) }, i))), scrollArrow && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: " " }) })), scrollArrow && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: scrollArrow }) })), _jsx(Box, { flexGrow: 1 }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: rule }) }), _jsx(Box, { children: actionItems.map((item, j) => (_jsxs(Text, { children: [" ", _jsx(Text, { color: item.color, bold: true, children: item.key }), _jsxs(Text, { color: item.color === "gray" ? "gray" : "white", children: [" ", item.label] })] }, j))) })] }));
|
|
166
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { EnrichedReviewPR } from "./types.js";
|
|
2
|
+
interface Props {
|
|
3
|
+
flatReviews: EnrichedReviewPR[];
|
|
4
|
+
selectedIndex: number;
|
|
5
|
+
scrollOffset: number;
|
|
6
|
+
height: number;
|
|
7
|
+
width: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function getReviewListRowCount(flatReviews: EnrichedReviewPR[]): number;
|
|
10
|
+
export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, height, width, }: Props): import("react/jsx-runtime").JSX.Element;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
function checksIndicator(checks) {
|
|
4
|
+
if (!checks || checks.length === 0)
|
|
5
|
+
return { text: "-", color: "gray" };
|
|
6
|
+
if (checks.some((c) => c.bucket === "fail"))
|
|
7
|
+
return { text: "\u2717", color: "red" };
|
|
8
|
+
if (checks.every((c) => c.bucket === "pass"))
|
|
9
|
+
return { text: "\u2713", color: "green" };
|
|
10
|
+
return { text: "\u25cf", color: "yellow" };
|
|
11
|
+
}
|
|
12
|
+
const FOOTER_HEIGHT = 2;
|
|
13
|
+
const HEADER_ROWS = 1;
|
|
14
|
+
export function getReviewListRowCount(flatReviews) {
|
|
15
|
+
return HEADER_ROWS + flatReviews.length;
|
|
16
|
+
}
|
|
17
|
+
export default function ReviewList({ flatReviews, selectedIndex, scrollOffset, height, width, }) {
|
|
18
|
+
const listHeight = height - FOOTER_HEIGHT;
|
|
19
|
+
const numColWidth = 6;
|
|
20
|
+
const authorColWidth = 12;
|
|
21
|
+
const changesColWidth = 10;
|
|
22
|
+
const checksColWidth = 2;
|
|
23
|
+
const fixedWidth = 2 + numColWidth + 1 + authorColWidth + 1 + changesColWidth + 1 + checksColWidth;
|
|
24
|
+
const titleMaxWidth = Math.max(width - fixedWidth, 10);
|
|
25
|
+
const footerRule = "\u2500".repeat(width);
|
|
26
|
+
const totalRows = HEADER_ROWS + flatReviews.length;
|
|
27
|
+
const visibleStart = scrollOffset;
|
|
28
|
+
const visibleEnd = Math.min(visibleStart + listHeight, totalRows);
|
|
29
|
+
const rows = [];
|
|
30
|
+
for (let rowIdx = visibleStart; rowIdx < visibleEnd; rowIdx++) {
|
|
31
|
+
if (rowIdx === 0) {
|
|
32
|
+
rows.push(_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "#".padEnd(numColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "".padEnd(titleMaxWidth) }), _jsx(Text, { dimColor: true, children: "author".padStart(authorColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "changes".padStart(changesColWidth) }), _jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: "ci".padStart(checksColWidth) })] }, "col-header"));
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const flatIndex = rowIdx - HEADER_ROWS;
|
|
36
|
+
const item = flatReviews[flatIndex];
|
|
37
|
+
if (!item)
|
|
38
|
+
continue;
|
|
39
|
+
const { pr } = item;
|
|
40
|
+
const selected = flatIndex === selectedIndex;
|
|
41
|
+
const cursor = selected ? ">" : " ";
|
|
42
|
+
const num = `#${pr.number}`;
|
|
43
|
+
const title = pr.title.length > titleMaxWidth ? pr.title.slice(0, titleMaxWidth - 1) + "\u2026" : pr.title;
|
|
44
|
+
const author = pr.author.login.length > authorColWidth
|
|
45
|
+
? pr.author.login.slice(0, authorColWidth - 1) + "\u2026"
|
|
46
|
+
: pr.author.login;
|
|
47
|
+
const changes = `+${item.additions} -${item.deletions}`;
|
|
48
|
+
const ci = checksIndicator(item.checks);
|
|
49
|
+
const bg = selected ? "#1e3a5f" : undefined;
|
|
50
|
+
rows.push(_jsxs(Box, { width: width, children: [_jsxs(Text, { backgroundColor: bg, color: selected ? "cyan" : undefined, bold: selected, children: [cursor, " "] }), _jsx(Text, { backgroundColor: bg, color: pr.isDraft ? "gray" : "green", children: num.padEnd(numColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? "white" : undefined, bold: selected, children: title.padEnd(titleMaxWidth) }), _jsx(Text, { backgroundColor: bg, dimColor: true, children: author.padStart(authorColWidth) }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsxs(Text, { backgroundColor: bg, children: [_jsx(Text, { color: "green", children: `+${item.additions}` }), _jsx(Text, { dimColor: true, children: "/" }), _jsx(Text, { color: "red", children: `-${item.deletions}` }), "".padStart(Math.max(0, changesColWidth - changes.length))] }), _jsx(Text, { backgroundColor: bg, children: " " }), _jsx(Text, { backgroundColor: bg, color: selected ? (ci.color === "gray" ? "gray" : ci.color) : ci.color, children: ci.text.padStart(checksColWidth) })] }, `${pr.number}`));
|
|
51
|
+
}
|
|
52
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Box, { flexDirection: "column", height: listHeight, children: rows }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: footerRule }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "j/k" }), _jsx(Text, { color: "white", children: " Navigate" }), " ", _jsxs(Text, { color: "cyan", bold: true, children: ["Shift + ", "\u2191\u2193"] }), _jsx(Text, { color: "white", children: " Scroll detail" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "o" }), _jsx(Text, { color: "white", children: " Open PR" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "Tab" }), _jsx(Text, { color: "white", children: " Issues" }), " ", _jsx(Text, { color: "cyan", bold: true, children: "q" }), _jsx(Text, { color: "white", children: " Quit" })] })] })] }));
|
|
53
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import type { DashboardIssue, ProjectGroup } from "./types.js";
|
|
1
|
+
import type { DashboardIssue, ProjectGroup, EnrichedReviewPR } from "./types.js";
|
|
2
2
|
export declare function loadDashboardData(repoRoot: string): Promise<{
|
|
3
3
|
groups: ProjectGroup[];
|
|
4
4
|
flatIssues: DashboardIssue[];
|
|
5
5
|
}>;
|
|
6
|
+
export declare function loadReviewsData(repoRoot: string): Promise<{
|
|
7
|
+
flatReviews: EnrichedReviewPR[];
|
|
8
|
+
}>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { listWorktrees, extractTicketId, getBaseBranch, readAllMetadata, readSessionState, isSessionAliveInTmux, clearSessionState, getGitStatusAsync, getCommitsAheadAsync, } from "../git.js";
|
|
2
|
-
import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, } from "../github.js";
|
|
2
|
+
import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRConversationCommentsAsync, getPRViewAsync, getReviewRequestedPRsAsync, getRepoNameAsync, } from "../github.js";
|
|
3
3
|
import { fetchAssignedIssues } from "../linear.js";
|
|
4
4
|
export async function loadDashboardData(repoRoot) {
|
|
5
5
|
// Fetch issues and worktrees in parallel
|
|
@@ -129,9 +129,37 @@ export async function loadDashboardData(repoRoot) {
|
|
|
129
129
|
reviews: reviewsInfo,
|
|
130
130
|
};
|
|
131
131
|
}));
|
|
132
|
-
//
|
|
132
|
+
// ── Compute parent-child relationships ──────────────────────────
|
|
133
|
+
// Build a map from worktree branch → DashboardIssue
|
|
134
|
+
const allIssues = [...enriched, ...orphans];
|
|
135
|
+
const branchToIssue = new Map();
|
|
136
|
+
for (const di of allIssues) {
|
|
137
|
+
if (di.worktree)
|
|
138
|
+
branchToIssue.set(di.worktree.branch, di);
|
|
139
|
+
}
|
|
140
|
+
// For each issue with a worktree, check if its base_branch matches another issue's branch
|
|
141
|
+
const childTicketIds = new Set();
|
|
142
|
+
for (const di of allIssues) {
|
|
143
|
+
if (!di.worktree)
|
|
144
|
+
continue;
|
|
145
|
+
const ticketId = di.issue.identifier;
|
|
146
|
+
const baseBranch = metadata[ticketId]?.base_branch;
|
|
147
|
+
if (!baseBranch)
|
|
148
|
+
continue; // no custom base = branched from default, not a child
|
|
149
|
+
const parent = branchToIssue.get(baseBranch);
|
|
150
|
+
if (!parent || parent === di)
|
|
151
|
+
continue;
|
|
152
|
+
di.parentTicketId = parent.issue.identifier;
|
|
153
|
+
if (!parent.children)
|
|
154
|
+
parent.children = [];
|
|
155
|
+
parent.children.push(di);
|
|
156
|
+
childTicketIds.add(ticketId);
|
|
157
|
+
}
|
|
158
|
+
// Group by project (excluding children — they'll appear nested under parents)
|
|
133
159
|
const groupMap = new Map();
|
|
134
160
|
for (const di of enriched) {
|
|
161
|
+
if (childTicketIds.has(di.issue.identifier))
|
|
162
|
+
continue;
|
|
135
163
|
const key = di.issue.projectName ?? "No Project";
|
|
136
164
|
const list = groupMap.get(key) ?? [];
|
|
137
165
|
list.push(di);
|
|
@@ -169,8 +197,9 @@ export async function loadDashboardData(repoRoot) {
|
|
|
169
197
|
statusGroups,
|
|
170
198
|
};
|
|
171
199
|
});
|
|
172
|
-
// Append orphaned worktrees as a separate group at the bottom
|
|
173
|
-
|
|
200
|
+
// Append orphaned worktrees as a separate group at the bottom (excluding children)
|
|
201
|
+
const topLevelOrphans = orphans.filter((di) => !childTicketIds.has(di.issue.identifier));
|
|
202
|
+
if (topLevelOrphans.length > 0) {
|
|
174
203
|
groups.push({
|
|
175
204
|
name: "Orphaned Worktrees",
|
|
176
205
|
id: null,
|
|
@@ -178,11 +207,89 @@ export async function loadDashboardData(repoRoot) {
|
|
|
178
207
|
{
|
|
179
208
|
name: "Orphaned",
|
|
180
209
|
type: "orphaned",
|
|
181
|
-
issues:
|
|
210
|
+
issues: topLevelOrphans,
|
|
182
211
|
},
|
|
183
212
|
],
|
|
184
213
|
});
|
|
185
214
|
}
|
|
186
|
-
|
|
215
|
+
// Flatten with children inserted right after their parent
|
|
216
|
+
function flattenWithChildren(di) {
|
|
217
|
+
const result = [di];
|
|
218
|
+
if (di.children) {
|
|
219
|
+
for (const child of di.children) {
|
|
220
|
+
result.push(...flattenWithChildren(child));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
const flatIssues = groups.flatMap((g) => g.statusGroups.flatMap((sg) => sg.issues.flatMap(flattenWithChildren)));
|
|
187
226
|
return { groups, flatIssues };
|
|
188
227
|
}
|
|
228
|
+
export async function loadReviewsData(repoRoot) {
|
|
229
|
+
const repo = await getRepoNameAsync();
|
|
230
|
+
if (!repo)
|
|
231
|
+
return { flatReviews: [] };
|
|
232
|
+
const prs = await getReviewRequestedPRsAsync(repo);
|
|
233
|
+
prs.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
234
|
+
// Build worktree map for matching PR branches to local worktrees
|
|
235
|
+
const worktrees = listWorktrees();
|
|
236
|
+
const branchToWt = new Map();
|
|
237
|
+
for (const wt of worktrees) {
|
|
238
|
+
if (wt.branch)
|
|
239
|
+
branchToWt.set(wt.branch, { path: wt.path, branch: wt.branch });
|
|
240
|
+
}
|
|
241
|
+
const metadata = readAllMetadata(repoRoot);
|
|
242
|
+
// Enrich each PR in parallel
|
|
243
|
+
const enriched = await Promise.all(prs.map(async (pr) => {
|
|
244
|
+
const [view, checks, reviews, comments] = await Promise.all([
|
|
245
|
+
getPRViewAsync(pr.number),
|
|
246
|
+
getPRChecksAsync(String(pr.number)),
|
|
247
|
+
getPRReviewsAsync(String(pr.number)),
|
|
248
|
+
getPRConversationCommentsAsync(String(pr.number)),
|
|
249
|
+
]);
|
|
250
|
+
// Check if we have a local worktree for this PR's branch
|
|
251
|
+
let worktreeInfo = null;
|
|
252
|
+
const branch = view?.headRefName ?? null;
|
|
253
|
+
if (branch) {
|
|
254
|
+
const wt = branchToWt.get(branch);
|
|
255
|
+
if (wt) {
|
|
256
|
+
const ticketId = extractTicketId(branch);
|
|
257
|
+
const base = getBaseBranch(branch);
|
|
258
|
+
const [gitStatusOutput, ahead] = await Promise.all([
|
|
259
|
+
getGitStatusAsync(wt.path),
|
|
260
|
+
getCommitsAheadAsync(wt.path, base),
|
|
261
|
+
]);
|
|
262
|
+
let sessState = ticketId ? readSessionState(repoRoot, ticketId) : null;
|
|
263
|
+
if (sessState && ticketId && !isSessionAliveInTmux(ticketId)) {
|
|
264
|
+
clearSessionState(repoRoot, ticketId);
|
|
265
|
+
sessState = null;
|
|
266
|
+
}
|
|
267
|
+
const ss = sessState?.state ?? null;
|
|
268
|
+
worktreeInfo = {
|
|
269
|
+
path: wt.path,
|
|
270
|
+
branch: wt.branch,
|
|
271
|
+
dirty: Boolean(gitStatusOutput),
|
|
272
|
+
commitsAhead: ahead,
|
|
273
|
+
sessionId: ticketId ? (metadata[ticketId]?.session_id ?? null) : null,
|
|
274
|
+
gitStatus: gitStatusOutput,
|
|
275
|
+
sessionState: ss === "exited" ? null : ss,
|
|
276
|
+
sessionMessage: sessState?.message ?? null,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
pr,
|
|
282
|
+
body: view?.body ?? null,
|
|
283
|
+
branch,
|
|
284
|
+
baseBranch: view?.baseRefName ?? null,
|
|
285
|
+
additions: view?.additions ?? 0,
|
|
286
|
+
deletions: view?.deletions ?? 0,
|
|
287
|
+
changedFiles: view?.changedFiles ?? 0,
|
|
288
|
+
checks,
|
|
289
|
+
reviews,
|
|
290
|
+
comments,
|
|
291
|
+
worktree: worktreeInfo,
|
|
292
|
+
};
|
|
293
|
+
}));
|
|
294
|
+
return { flatReviews: enriched };
|
|
295
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PRInfo, PRCheck, PRReview } from "../github.js";
|
|
1
|
+
import type { PRInfo, PRCheck, PRReview, PRConversationComment, SearchPR } from "../github.js";
|
|
2
2
|
export interface LinearAssignedIssue {
|
|
3
3
|
identifier: string;
|
|
4
4
|
title: string;
|
|
@@ -30,6 +30,8 @@ export interface DashboardIssue {
|
|
|
30
30
|
pr: PRInfo | null;
|
|
31
31
|
checks: PRCheck[] | null;
|
|
32
32
|
reviews: PRReview[] | null;
|
|
33
|
+
parentTicketId?: string;
|
|
34
|
+
children?: DashboardIssue[];
|
|
33
35
|
}
|
|
34
36
|
export interface StatusGroup {
|
|
35
37
|
name: string;
|
|
@@ -41,15 +43,35 @@ export interface ProjectGroup {
|
|
|
41
43
|
id: string | null;
|
|
42
44
|
statusGroups: StatusGroup[];
|
|
43
45
|
}
|
|
44
|
-
export type
|
|
46
|
+
export type ReviewPR = SearchPR;
|
|
47
|
+
export interface EnrichedReviewPR {
|
|
48
|
+
pr: SearchPR;
|
|
49
|
+
body: string | null;
|
|
50
|
+
branch: string | null;
|
|
51
|
+
baseBranch: string | null;
|
|
52
|
+
additions: number;
|
|
53
|
+
deletions: number;
|
|
54
|
+
changedFiles: number;
|
|
55
|
+
checks: PRCheck[] | null;
|
|
56
|
+
reviews: PRReview[] | null;
|
|
57
|
+
comments: PRConversationComment[] | null;
|
|
58
|
+
worktree: WorktreeInfo | null;
|
|
59
|
+
}
|
|
60
|
+
export type DashboardTab = "issues" | "reviews";
|
|
61
|
+
export type ActionOverlay = "mode-select" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | null;
|
|
45
62
|
export type CommitPhase = "idle" | "confirm-stage" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
|
|
46
63
|
export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "filling" | "review" | "creating" | "done" | "error";
|
|
47
64
|
export interface DashboardState {
|
|
65
|
+
activeTab: DashboardTab;
|
|
48
66
|
groups: ProjectGroup[];
|
|
49
67
|
flatIssues: DashboardIssue[];
|
|
50
68
|
selectedIndex: number;
|
|
51
69
|
listScrollOffset: number;
|
|
52
70
|
detailScrollOffset: number;
|
|
71
|
+
flatReviews: EnrichedReviewPR[];
|
|
72
|
+
reviewSelectedIndex: number;
|
|
73
|
+
reviewListScrollOffset: number;
|
|
74
|
+
reviewDetailScrollOffset: number;
|
|
53
75
|
loading: boolean;
|
|
54
76
|
refreshing: boolean;
|
|
55
77
|
error: string | null;
|
|
@@ -75,6 +97,9 @@ export interface DashboardState {
|
|
|
75
97
|
prCreateBody: string | null;
|
|
76
98
|
prCreateTitle: string | null;
|
|
77
99
|
setupMode: "plan" | "implement" | null;
|
|
100
|
+
baseSelectOptions: string[];
|
|
101
|
+
baseSelectIndex: number;
|
|
102
|
+
baseSelectChosen: string | null;
|
|
78
103
|
}
|
|
79
104
|
export type DashboardAction = {
|
|
80
105
|
type: "SET_DATA";
|
|
@@ -164,6 +189,32 @@ export type DashboardAction = {
|
|
|
164
189
|
mode: "plan" | "implement";
|
|
165
190
|
} | {
|
|
166
191
|
type: "SETUP_CONFIRM_DONE";
|
|
192
|
+
} | {
|
|
193
|
+
type: "BASE_SELECT_SHOW";
|
|
194
|
+
options: string[];
|
|
195
|
+
} | {
|
|
196
|
+
type: "BASE_SELECT_MOVE";
|
|
197
|
+
index: number;
|
|
198
|
+
} | {
|
|
199
|
+
type: "BASE_SELECT_CONFIRM";
|
|
200
|
+
chosen: string;
|
|
201
|
+
} | {
|
|
202
|
+
type: "BASE_SELECT_DONE";
|
|
203
|
+
} | {
|
|
204
|
+
type: "SET_TAB";
|
|
205
|
+
tab: DashboardTab;
|
|
206
|
+
} | {
|
|
207
|
+
type: "SET_REVIEWS_DATA";
|
|
208
|
+
flatReviews: EnrichedReviewPR[];
|
|
209
|
+
} | {
|
|
210
|
+
type: "REVIEW_SELECT";
|
|
211
|
+
index: number;
|
|
212
|
+
} | {
|
|
213
|
+
type: "REVIEW_SCROLL_LIST";
|
|
214
|
+
offset: number;
|
|
215
|
+
} | {
|
|
216
|
+
type: "REVIEW_SCROLL_DETAIL";
|
|
217
|
+
offset: number;
|
|
167
218
|
};
|
|
168
219
|
export declare const initialState: DashboardState;
|
|
169
220
|
export declare function reducer(state: DashboardState, action: DashboardAction): DashboardState;
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
// ── State management ──────────────────────────────────────────────────
|
|
2
2
|
export const initialState = {
|
|
3
|
+
activeTab: "issues",
|
|
3
4
|
groups: [],
|
|
4
5
|
flatIssues: [],
|
|
5
6
|
selectedIndex: 0,
|
|
6
7
|
listScrollOffset: 0,
|
|
7
8
|
detailScrollOffset: 0,
|
|
9
|
+
flatReviews: [],
|
|
10
|
+
reviewSelectedIndex: 0,
|
|
11
|
+
reviewListScrollOffset: 0,
|
|
12
|
+
reviewDetailScrollOffset: 0,
|
|
8
13
|
loading: true,
|
|
9
14
|
refreshing: false,
|
|
10
15
|
error: null,
|
|
@@ -30,6 +35,9 @@ export const initialState = {
|
|
|
30
35
|
prCreateBody: null,
|
|
31
36
|
prCreateTitle: null,
|
|
32
37
|
setupMode: null,
|
|
38
|
+
baseSelectOptions: [],
|
|
39
|
+
baseSelectIndex: 0,
|
|
40
|
+
baseSelectChosen: null,
|
|
33
41
|
};
|
|
34
42
|
export function reducer(state, action) {
|
|
35
43
|
switch (action.type) {
|
|
@@ -81,9 +89,21 @@ export function reducer(state, action) {
|
|
|
81
89
|
case "CREATION_LOG":
|
|
82
90
|
return { ...state, creationLogs: state.creationLogs + action.logs };
|
|
83
91
|
case "CREATION_DONE":
|
|
84
|
-
return {
|
|
92
|
+
return {
|
|
93
|
+
...state,
|
|
94
|
+
creatingForTicket: null,
|
|
95
|
+
creationLogs: "",
|
|
96
|
+
creationError: null,
|
|
97
|
+
baseSelectChosen: null,
|
|
98
|
+
};
|
|
85
99
|
case "CREATION_ERROR":
|
|
86
|
-
return {
|
|
100
|
+
return {
|
|
101
|
+
...state,
|
|
102
|
+
creationError: action.error,
|
|
103
|
+
creatingForTicket: null,
|
|
104
|
+
creationLogs: "",
|
|
105
|
+
baseSelectChosen: null,
|
|
106
|
+
};
|
|
87
107
|
case "DELETE_START":
|
|
88
108
|
return { ...state, deletingForTicket: action.ticketId };
|
|
89
109
|
case "DELETE_DONE":
|
|
@@ -176,6 +196,52 @@ export function reducer(state, action) {
|
|
|
176
196
|
overlay: null,
|
|
177
197
|
setupMode: null,
|
|
178
198
|
};
|
|
199
|
+
case "BASE_SELECT_SHOW":
|
|
200
|
+
return {
|
|
201
|
+
...state,
|
|
202
|
+
overlay: "base-select",
|
|
203
|
+
baseSelectOptions: action.options,
|
|
204
|
+
baseSelectIndex: 0,
|
|
205
|
+
baseSelectChosen: null,
|
|
206
|
+
};
|
|
207
|
+
case "BASE_SELECT_MOVE":
|
|
208
|
+
return { ...state, baseSelectIndex: action.index };
|
|
209
|
+
case "BASE_SELECT_CONFIRM":
|
|
210
|
+
return {
|
|
211
|
+
...state,
|
|
212
|
+
overlay: null,
|
|
213
|
+
baseSelectChosen: action.chosen,
|
|
214
|
+
};
|
|
215
|
+
case "BASE_SELECT_DONE":
|
|
216
|
+
return {
|
|
217
|
+
...state,
|
|
218
|
+
overlay: null,
|
|
219
|
+
baseSelectOptions: [],
|
|
220
|
+
baseSelectIndex: 0,
|
|
221
|
+
};
|
|
222
|
+
case "SET_TAB":
|
|
223
|
+
return { ...state, activeTab: action.tab };
|
|
224
|
+
case "SET_REVIEWS_DATA": {
|
|
225
|
+
const prevNum = state.flatReviews[state.reviewSelectedIndex]?.pr.number;
|
|
226
|
+
let newIdx = 0;
|
|
227
|
+
if (prevNum !== undefined) {
|
|
228
|
+
const found = action.flatReviews.findIndex((p) => p.pr.number === prevNum);
|
|
229
|
+
if (found >= 0)
|
|
230
|
+
newIdx = found;
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
...state,
|
|
234
|
+
flatReviews: action.flatReviews,
|
|
235
|
+
reviewSelectedIndex: newIdx,
|
|
236
|
+
reviewDetailScrollOffset: 0,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
case "REVIEW_SELECT":
|
|
240
|
+
return { ...state, reviewSelectedIndex: action.index, reviewDetailScrollOffset: 0 };
|
|
241
|
+
case "REVIEW_SCROLL_LIST":
|
|
242
|
+
return { ...state, reviewListScrollOffset: action.offset };
|
|
243
|
+
case "REVIEW_SCROLL_DETAIL":
|
|
244
|
+
return { ...state, reviewDetailScrollOffset: action.offset };
|
|
179
245
|
default:
|
|
180
246
|
return state;
|
|
181
247
|
}
|