merge-steward 0.18.0 → 0.19.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/dist/watch/App.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useState } from "react";
3
- import { Box, Text, useApp, useInput } from "ink";
3
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
4
4
  import { fetchGatewayHealth, fetchSnapshot, triggerReconcile } from "./api.js";
5
5
  import { buildDashboard, matchRepoRef, repoSelector, stepRepo } from "./dashboard-model.js";
6
6
  import { HelpBar } from "./HelpBar.js";
@@ -10,6 +10,7 @@ import { StatusBar } from "./StatusBar.js";
10
10
  const REFRESH_INTERVAL_MS = 1_500;
11
11
  export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber }) {
12
12
  const { exit } = useApp();
13
+ const { stdout } = useStdout();
13
14
  const [repoStates, setRepoStates] = useState(() => repos.map((repo) => ({
14
15
  ...repo,
15
16
  serviceState: "offline",
@@ -27,6 +28,7 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
27
28
  const [view, setView] = useState(initialRepoRef || initialPrNumber !== undefined ? "detail" : "list");
28
29
  const [flashMessage, setFlashMessage] = useState(null);
29
30
  const [pendingInitialPrNumber, setPendingInitialPrNumber] = useState(initialPrNumber ?? null);
31
+ const [detailScrollOffset, setDetailScrollOffset] = useState(0);
30
32
  const model = useMemo(() => buildDashboard(repoStates), [repoStates]);
31
33
  useEffect(() => {
32
34
  setSelectedRepoId((current) => repoSelector(model.repos, current));
@@ -150,6 +152,9 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
150
152
  setFlashMessage(error instanceof Error ? error.message : String(error));
151
153
  }
152
154
  }
155
+ const rows = Math.max(8, stdout?.rows ?? 24);
156
+ const chromeRows = 1 /* status */ + 1 /* body marginTop */ + (flashMessage ? 2 : 0) + 2 /* help bar + its marginTop */;
157
+ const bodyRows = Math.max(2, rows - chromeRows);
153
158
  useInput((input, key) => {
154
159
  if (input === "q") {
155
160
  exit();
@@ -159,22 +164,58 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
159
164
  void runReconcile(selectedRepoId);
160
165
  return;
161
166
  }
162
- if (input === "j" || key.downArrow) {
167
+ if (view === "list") {
168
+ if (input === "j" || key.downArrow || input === "]") {
169
+ setSelectedRepoId((current) => stepRepo(model.repos, current, 1));
170
+ return;
171
+ }
172
+ if (input === "k" || key.upArrow || input === "[") {
173
+ setSelectedRepoId((current) => stepRepo(model.repos, current, -1));
174
+ return;
175
+ }
176
+ if (key.return)
177
+ setView("detail");
178
+ return;
179
+ }
180
+ // detail view
181
+ if (input === "]") {
163
182
  setSelectedRepoId((current) => stepRepo(model.repos, current, 1));
183
+ setDetailScrollOffset(0);
164
184
  return;
165
185
  }
166
- if (input === "k" || key.upArrow) {
186
+ if (input === "[") {
167
187
  setSelectedRepoId((current) => stepRepo(model.repos, current, -1));
188
+ setDetailScrollOffset(0);
168
189
  return;
169
190
  }
170
- if (view === "list") {
171
- if (key.return)
172
- setView("detail");
191
+ if (input === "j" || key.downArrow) {
192
+ setDetailScrollOffset((offset) => offset + 1);
193
+ return;
194
+ }
195
+ if (input === "k" || key.upArrow) {
196
+ setDetailScrollOffset((offset) => Math.max(0, offset - 1));
197
+ return;
198
+ }
199
+ if (key.pageDown || input === " ") {
200
+ setDetailScrollOffset((offset) => offset + Math.max(1, bodyRows - 2));
201
+ return;
202
+ }
203
+ if (key.pageUp) {
204
+ setDetailScrollOffset((offset) => Math.max(0, offset - Math.max(1, bodyRows - 2)));
205
+ return;
206
+ }
207
+ if (input === "g") {
208
+ setDetailScrollOffset(0);
209
+ return;
210
+ }
211
+ if (input === "G") {
212
+ setDetailScrollOffset(Number.MAX_SAFE_INTEGER);
173
213
  return;
174
214
  }
175
215
  if (key.escape || key.backspace || key.delete) {
176
216
  setView("list");
217
+ setDetailScrollOffset(0);
177
218
  }
178
219
  });
179
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { connected: model.repos.some((repo) => !repo.offlineMessage), lastSnapshotReceivedAt: lastSnapshotReceivedAt, gatewayError: gatewayError }), view === "detail" ? (_jsx(ProjectDetailView, { model: model, selectedRepoId: selectedRepoId })) : (_jsx(OverviewView, { model: model, selectedRepoId: selectedRepoId, showCursor: true })), flashMessage ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: flashMessage }) })) : null, _jsx(HelpBar, { view: view })] }));
220
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { connected: model.repos.some((repo) => !repo.offlineMessage), lastSnapshotReceivedAt: lastSnapshotReceivedAt, gatewayError: gatewayError }), view === "detail" ? (_jsx(ProjectDetailView, { model: model, selectedRepoId: selectedRepoId, bodyRows: bodyRows, scrollOffset: detailScrollOffset })) : (_jsx(OverviewView, { model: model, selectedRepoId: selectedRepoId, showCursor: true, bodyRows: bodyRows })), flashMessage ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: flashMessage }) })) : null, _jsx(HelpBar, { view: view })] }));
180
221
  }
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  export function HelpBar({ view }) {
4
4
  const text = view === "list"
5
- ? "↑↓ switch repo enter detail r reconcile q quit"
6
- : "↑↓ switch repo esc back r reconcile q quit";
5
+ ? "↑↓ repo enter detail r reconcile q quit"
6
+ : "[ ] repo ↑↓ scroll g/G top/end esc back r reconcile q quit";
7
7
  return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: text }) }));
8
8
  }
@@ -3,6 +3,7 @@ interface ListViewProps {
3
3
  model: DashboardModel;
4
4
  selectedRepoId: string | null;
5
5
  showCursor: boolean;
6
+ bodyRows: number;
6
7
  }
7
8
  export declare function RepoRow({ repo, selected, showCursor, width, }: {
8
9
  repo: DashboardRepo;
@@ -14,5 +15,5 @@ export declare function pickVisibleWindow(total: number, selectedIndex: number,
14
15
  start: number;
15
16
  end: number;
16
17
  };
17
- export declare function OverviewView({ model, selectedRepoId, showCursor }: ListViewProps): React.JSX.Element;
18
+ export declare function OverviewView({ model, selectedRepoId, showCursor, bodyRows }: ListViewProps): React.JSX.Element;
18
19
  export {};
@@ -45,21 +45,36 @@ export function pickVisibleWindow(total, selectedIndex, availableRows) {
45
45
  }
46
46
  return { start, end };
47
47
  }
48
- export function OverviewView({ model, selectedRepoId, showCursor }) {
48
+ export function OverviewView({ model, selectedRepoId, showCursor, bodyRows }) {
49
49
  const { stdout } = useStdout();
50
- const rows = Math.max(3, stdout?.rows ?? 24);
51
50
  const width = Math.max(40, stdout?.columns ?? 80);
52
- const availableRows = Math.max(1, rows - 3);
51
+ const total = model.repos.length;
53
52
  const selectedIndex = Math.max(0, model.repos.findIndex((repo) => repo.repoId === selectedRepoId));
54
- const quietReserve = model.repos.length > availableRows && model.quietCount > 0 ? 1 : 0;
55
- const windowRows = Math.max(1, availableRows - quietReserve);
56
- const { start, end } = pickVisibleWindow(model.repos.length, selectedIndex, windowRows);
53
+ let { start, end } = pickVisibleWindow(total, selectedIndex, Math.max(1, bodyRows));
54
+ let needTop = start > 0;
55
+ let needBottom = end < total;
56
+ if (needTop || needBottom) {
57
+ const reserve = (needTop ? 1 : 0) + (needBottom ? 1 : 0);
58
+ const windowRows = Math.max(1, bodyRows - reserve);
59
+ ({ start, end } = pickVisibleWindow(total, selectedIndex, windowRows));
60
+ needTop = start > 0;
61
+ needBottom = end < total;
62
+ }
57
63
  const visible = model.repos.slice(start, end);
58
- const hiddenQuiet = model.repos
59
- .filter((repo, index) => !repo.hasActivity && repo.offlineMessage === null && (index < start || index >= end))
60
- .length;
61
- if (visible.length === 0) {
64
+ const above = start;
65
+ const below = total - end;
66
+ if (total === 0 || bodyRows <= 0) {
62
67
  return _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: " " }) });
63
68
  }
64
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [visible.map((repo) => (_jsx(RepoRow, { repo: repo, selected: repo.repoId === selectedRepoId, showCursor: showCursor, width: width - 2 }, repo.repoId))), hiddenQuiet > 0 ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `+${hiddenQuiet} quiet` }) })) : null] }));
69
+ const children = [];
70
+ if (needTop) {
71
+ children.push(_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `\u2191${above} more above` }) }, "above"));
72
+ }
73
+ for (const repo of visible) {
74
+ children.push(_jsx(RepoRow, { repo: repo, selected: repo.repoId === selectedRepoId, showCursor: showCursor, width: width - 2 }, repo.repoId));
75
+ }
76
+ if (needBottom) {
77
+ children.push(_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `\u2193${below} more below` }) }, "below"));
78
+ }
79
+ return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: children.slice(0, Math.max(1, bodyRows)) }));
65
80
  }
@@ -1,7 +1,20 @@
1
- import { type DashboardModel } from "./dashboard-model.ts";
1
+ import { type DashboardModel, type DashboardPrEntry, type DashboardRepo } from "./dashboard-model.ts";
2
2
  interface ProjectDetailViewProps {
3
3
  model: DashboardModel;
4
4
  selectedRepoId: string | null;
5
+ bodyRows: number;
6
+ scrollOffset: number;
5
7
  }
6
- export declare function ProjectDetailView({ model, selectedRepoId }: ProjectDetailViewProps): React.JSX.Element;
8
+ type ContentLine = {
9
+ kind: "blank";
10
+ } | {
11
+ kind: "entry-header";
12
+ entry: DashboardPrEntry;
13
+ } | {
14
+ kind: "summary-line";
15
+ text: string;
16
+ };
17
+ export declare function buildContentLines(repo: DashboardRepo, width: number): ContentLine[];
18
+ export declare function clampScrollOffset(requested: number, totalLines: number, scrollArea: number): number;
19
+ export declare function ProjectDetailView({ model, selectedRepoId, bodyRows, scrollOffset }: ProjectDetailViewProps): React.JSX.Element;
7
20
  export {};
@@ -5,28 +5,58 @@ import { clipSummary } from "./dashboard-model.js";
5
5
  const PR_ID_WIDTH = 7;
6
6
  const PR_PHRASE_WIDTH = 20;
7
7
  const SUMMARY_INDENT = 9;
8
- function PrEntryRow({ entry, width, includeSummary, }) {
8
+ function truncate(value, maxWidth) {
9
+ if (value.length <= maxWidth)
10
+ return value;
11
+ if (maxWidth <= 1)
12
+ return value.slice(0, maxWidth);
13
+ return `${value.slice(0, Math.max(0, maxWidth - 1))}\u2026`;
14
+ }
15
+ export function buildContentLines(repo, width) {
16
+ const lines = [];
17
+ repo.entries.forEach((entry, index) => {
18
+ if (index > 0)
19
+ lines.push({ kind: "blank" });
20
+ lines.push({ kind: "entry-header", entry });
21
+ if (entry.summary) {
22
+ const summaryText = clipSummary(entry.summary, {
23
+ maxLines: 3,
24
+ width: Math.max(20, width - SUMMARY_INDENT),
25
+ });
26
+ if (summaryText) {
27
+ summaryText.split("\n").forEach((line) => lines.push({ kind: "summary-line", text: line }));
28
+ }
29
+ }
30
+ });
31
+ return lines;
32
+ }
33
+ function EntryHeaderRow({ entry, width }) {
9
34
  const idText = `#${entry.prNumber}`.padEnd(PR_ID_WIDTH, " ");
10
35
  const paddedPhrase = entry.phrase.padEnd(PR_PHRASE_WIDTH, " ");
11
- const summaryText = includeSummary
12
- ? clipSummary(entry.summary, { maxLines: 3, width: Math.max(20, width - SUMMARY_INDENT) })
13
- : "";
14
36
  const titleSpace = Math.max(0, width - (PR_ID_WIDTH + 3 + PR_PHRASE_WIDTH + 1));
15
37
  const title = entry.title && entry.title !== entry.phrase && titleSpace >= 8
16
38
  ? truncate(entry.title, titleSpace)
17
39
  : "";
18
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: entry.color, children: idText }), _jsx(Text, { color: entry.color, children: entry.glyph }), _jsx(Text, { children: ` ${paddedPhrase}` }), title ? _jsx(Text, { dimColor: true, children: ` ${title}` }) : null] }), summaryText ? (_jsxs(Box, { children: [_jsx(Text, { children: " ".repeat(SUMMARY_INDENT) }), _jsx(Text, { dimColor: true, children: summaryText })] })) : null] }));
40
+ return (_jsxs(Box, { children: [_jsx(Text, { color: entry.color, children: idText }), _jsx(Text, { color: entry.color, children: entry.glyph }), _jsx(Text, { children: ` ${paddedPhrase}` }), title ? _jsx(Text, { dimColor: true, children: ` ${title}` }) : null] }));
19
41
  }
20
- function truncate(value, maxWidth) {
21
- if (value.length <= maxWidth)
22
- return value;
23
- if (maxWidth <= 1)
24
- return value.slice(0, maxWidth);
25
- return `${value.slice(0, Math.max(0, maxWidth - 1))}\u2026`;
42
+ function renderLine(line, key, width) {
43
+ if (line.kind === "blank")
44
+ return _jsx(Box, { children: _jsx(Text, { children: " " }) }, key);
45
+ if (line.kind === "summary-line") {
46
+ return (_jsxs(Box, { children: [_jsx(Text, { children: " ".repeat(SUMMARY_INDENT) }), _jsx(Text, { dimColor: true, children: line.text })] }, key));
47
+ }
48
+ return _jsx(EntryHeaderRow, { entry: line.entry, width: width }, key);
26
49
  }
27
- export function ProjectDetailView({ model, selectedRepoId }) {
50
+ export function clampScrollOffset(requested, totalLines, scrollArea) {
51
+ if (scrollArea <= 0 || totalLines <= scrollArea)
52
+ return 0;
53
+ // When scrolled at all, a one-row top indicator eats into the viewport, so the
54
+ // largest useful offset lets (scrollArea - 1) rows of content fill the remainder.
55
+ const maxOffset = Math.max(0, totalLines - Math.max(1, scrollArea - 1));
56
+ return Math.max(0, Math.min(requested, maxOffset));
57
+ }
58
+ export function ProjectDetailView({ model, selectedRepoId, bodyRows, scrollOffset }) {
28
59
  const { stdout } = useStdout();
29
- const rows = Math.max(3, stdout?.rows ?? 24);
30
60
  const width = Math.max(40, stdout?.columns ?? 80);
31
61
  const repo = selectedRepoId
32
62
  ? model.repos.find((candidate) => candidate.repoId === selectedRepoId) ?? model.repos[0] ?? null
@@ -34,42 +64,32 @@ export function ProjectDetailView({ model, selectedRepoId }) {
34
64
  if (!repo) {
35
65
  return _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: " " }) });
36
66
  }
37
- const availableRows = Math.max(1, rows - 3);
38
- const afterRepoRow = Math.max(0, availableRows - 2);
39
- const entries = repo.entries;
40
- const planned = [];
41
- let used = 0;
42
- const maxSummaryLines = 3;
43
- for (const entry of entries) {
44
- if (used >= afterRepoRow)
45
- break;
46
- if (used + 1 > afterRepoRow)
47
- break;
48
- planned.push({ entry, includeSummary: false });
49
- used += 1;
67
+ const repoHeaderRows = 1;
68
+ const separatorRows = bodyRows >= 3 ? 1 : 0;
69
+ const scrollArea = Math.max(0, bodyRows - repoHeaderRows - separatorRows);
70
+ const contentWidth = Math.max(20, width - 2);
71
+ const contentLines = buildContentLines(repo, contentWidth);
72
+ const offset = clampScrollOffset(scrollOffset, contentLines.length, scrollArea);
73
+ const children = [];
74
+ const couldHaveAbove = offset > 0;
75
+ const reserveTop = couldHaveAbove && scrollArea > 0 ? 1 : 0;
76
+ let remaining = scrollArea - reserveTop;
77
+ let tentativeEnd = Math.min(contentLines.length, offset + remaining);
78
+ const belowCount = contentLines.length - tentativeEnd;
79
+ const reserveBottom = belowCount > 0 && remaining > 0 ? 1 : 0;
80
+ if (reserveBottom) {
81
+ remaining = Math.max(0, remaining - 1);
82
+ tentativeEnd = Math.min(contentLines.length, offset + remaining);
50
83
  }
51
- if (afterRepoRow - used >= 2) {
52
- for (let i = 0; i < planned.length; i += 1) {
53
- const item = planned[i];
54
- if (!item.entry.summary)
55
- continue;
56
- const summaryText = clipSummary(item.entry.summary, {
57
- maxLines: maxSummaryLines,
58
- width: Math.max(20, width - SUMMARY_INDENT),
59
- });
60
- if (!summaryText)
61
- continue;
62
- const summaryLines = summaryText.split("\n").length;
63
- const isLast = i === planned.length - 1;
64
- const extra = summaryLines + (isLast ? 0 : 1);
65
- if (used + extra > afterRepoRow)
66
- break;
67
- item.includeSummary = true;
68
- used += extra;
69
- }
84
+ if (reserveTop) {
85
+ children.push(_jsx(Box, { children: _jsx(Text, { dimColor: true, children: ` \u2191${offset} more above` }) }, "above"));
86
+ }
87
+ for (let i = offset; i < tentativeEnd; i += 1) {
88
+ children.push(renderLine(contentLines[i], i, contentWidth));
89
+ }
90
+ if (reserveBottom) {
91
+ const hidden = contentLines.length - tentativeEnd;
92
+ children.push(_jsx(Box, { children: _jsx(Text, { dimColor: true, children: ` \u2193${hidden} more below` }) }, "below"));
70
93
  }
71
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(RepoRow, { repo: repo, selected: false, showCursor: false, width: width - 2 }), planned.length > 0 ? _jsx(Box, { children: _jsx(Text, { children: " " }) }) : null, planned.map(({ entry, includeSummary }, index) => {
72
- const isLast = index === planned.length - 1;
73
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(PrEntryRow, { entry: entry, width: width, includeSummary: includeSummary }), includeSummary && !isLast ? _jsx(Box, { children: _jsx(Text, { children: " " }) }) : null] }, entry.prNumber));
74
- })] }));
94
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(RepoRow, { repo: repo, selected: false, showCursor: false, width: width - 2 }), separatorRows > 0 ? _jsx(Box, { children: _jsx(Text, { children: " " }) }) : null, children] }));
75
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Serial merge queue for GitHub — rebase, CI-gate, and merge PRs one at a time",
5
5
  "type": "module",
6
6
  "repository": {