@profullstack/g1tz 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/LICENSE +21 -0
- package/README.md +90 -0
- package/bin/g1tz.mjs +2 -0
- package/dist/diff.d.ts +44 -0
- package/dist/diff.js +112 -0
- package/dist/git.d.ts +61 -0
- package/dist/git.js +182 -0
- package/dist/github.d.ts +63 -0
- package/dist/github.js +154 -0
- package/dist/main.d.ts +71 -0
- package/dist/main.js +458 -0
- package/dist/pulse-view.d.ts +62 -0
- package/dist/pulse-view.js +244 -0
- package/dist/pulse.d.ts +120 -0
- package/dist/pulse.js +348 -0
- package/dist/traffic.d.ts +37 -0
- package/dist/traffic.js +68 -0
- package/package.json +53 -0
- package/src/diff.ts +147 -0
- package/src/git.ts +225 -0
- package/src/github.ts +213 -0
- package/src/main.ts +466 -0
- package/src/pulse-view.ts +294 -0
- package/src/pulse.ts +413 -0
- package/src/traffic.ts +100 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Pulse screen: what moved in this repository over a period.
|
|
3
|
+
*
|
|
4
|
+
* Git on the left (overview, commits over time, authors), the wider world on
|
|
5
|
+
* the right (the files that changed, GitHub's pull requests, issues, releases
|
|
6
|
+
* and stars, and the repository's views and clones from a gh-pulse report).
|
|
7
|
+
* Pure: everything drawn comes from PulseState, so the screen is asserted on
|
|
8
|
+
* as text in the tests and captured for the hqtui.com gallery the same way.
|
|
9
|
+
*/
|
|
10
|
+
import { widgets, type Container, type Theme } from "@profullstack/hqtui";
|
|
11
|
+
import type { GitHubPulse } from "./github.ts";
|
|
12
|
+
import { RANGE_KEYS, RANGE_LABEL, foldBuckets, type Pulse, type PulseRef, type RangeKey } from "./pulse.ts";
|
|
13
|
+
import type { Traffic, TrafficDay } from "./traffic.ts";
|
|
14
|
+
|
|
15
|
+
export type GitHubStatus = "idle" | "loading" | "ready" | "unavailable";
|
|
16
|
+
|
|
17
|
+
export interface PulseState {
|
|
18
|
+
range: RangeKey;
|
|
19
|
+
data: Pulse | null;
|
|
20
|
+
github: GitHubPulse | null;
|
|
21
|
+
githubStatus: GitHubStatus;
|
|
22
|
+
/** Why GitHub is unavailable, when it is. */
|
|
23
|
+
githubNote: string;
|
|
24
|
+
/** Ticket of the latest GitHub request; a reply to an older one is dropped. */
|
|
25
|
+
githubRequest: number;
|
|
26
|
+
traffic: Traffic | null;
|
|
27
|
+
filesOffset: number;
|
|
28
|
+
note: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createPulseState(range: RangeKey = "week"): PulseState {
|
|
32
|
+
return {
|
|
33
|
+
range,
|
|
34
|
+
data: null,
|
|
35
|
+
github: null,
|
|
36
|
+
githubStatus: "idle",
|
|
37
|
+
githubNote: "",
|
|
38
|
+
githubRequest: 0,
|
|
39
|
+
traffic: null,
|
|
40
|
+
filesOffset: 0,
|
|
41
|
+
note: "",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PulseActions {
|
|
46
|
+
pickRange: (key: RangeKey) => void;
|
|
47
|
+
refresh: () => void;
|
|
48
|
+
/** Back to the repository screen. */
|
|
49
|
+
back: () => void;
|
|
50
|
+
/** Open the Pulse screen from the repository screen. */
|
|
51
|
+
pulse: () => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const NO_ACTIONS: PulseActions = { pickRange: () => {}, refresh: () => {}, back: () => {}, pulse: () => {} };
|
|
55
|
+
|
|
56
|
+
export interface ViewArgs {
|
|
57
|
+
ui: Container;
|
|
58
|
+
theme: Theme;
|
|
59
|
+
width: number;
|
|
60
|
+
height: number;
|
|
61
|
+
elapsed: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
65
|
+
const fmtStamp = (iso: string): string => iso.replace("T", " ").slice(0, 16);
|
|
66
|
+
const names = (refs: readonly PulseRef[], max: number): string =>
|
|
67
|
+
`${refs.slice(0, max).map((r) => r.name).join(", ")}${refs.length > max ? ", …" : ""}`;
|
|
68
|
+
|
|
69
|
+
/** Keep the tail of a path, which is the part that tells files apart; the library's own truncation keeps the head. */
|
|
70
|
+
export function shortenPath(path: string, max: number): string {
|
|
71
|
+
if (max < 4 || path.length <= max) return path;
|
|
72
|
+
return `…${path.slice(path.length - (max - 1))}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const clampOffset = (offset: number, total: number): number => Math.max(0, Math.min(Math.max(0, total - 1), offset));
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Content columns of the two panes: the body splits 1fr : 1.2fr with a gap of
|
|
79
|
+
* one, and a panel's border and padding take two on each side. An estimate,
|
|
80
|
+
* since the layout is not known until it is solved; a column short is fine,
|
|
81
|
+
* a column over is a truncated axis label.
|
|
82
|
+
*/
|
|
83
|
+
export const leftPaneColumns = (width: number): number => Math.max(10, Math.floor((width - 1) / 2.2) - 4);
|
|
84
|
+
export const rightPaneColumns = (width: number): number => Math.max(10, Math.floor(((width - 1) * 1.2) / 2.2) - 4);
|
|
85
|
+
|
|
86
|
+
export function commitTotals(pulse: Pulse): { commits: number; merges: number } {
|
|
87
|
+
let merges = 0;
|
|
88
|
+
for (const c of pulse.commits) if (c.merge) merges++;
|
|
89
|
+
return { commits: pulse.commits.length - merges, merges };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What the status bar says on the right: the GitHub read, or nothing. */
|
|
93
|
+
export function githubLine(state: PulseState, elapsed: number): string {
|
|
94
|
+
switch (state.githubStatus) {
|
|
95
|
+
case "loading": return `${widgets.spinnerFrame(elapsed, widgets.SPINNER_FRAMES.dots)} GitHub`;
|
|
96
|
+
case "unavailable": return `GitHub: ${state.githubNote}`;
|
|
97
|
+
case "ready": return state.github?.partial ? "GitHub counts are floors (page budget)" : "";
|
|
98
|
+
default: return "";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function overviewPanel(col: Container, theme: Theme, pulse: Pulse | null): void {
|
|
103
|
+
col.panel({ title: "Overview", size: 9 }, (p) => {
|
|
104
|
+
if (!pulse) {
|
|
105
|
+
p.label("Press r to read the repository.");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const { commits, merges } = commitTotals(pulse);
|
|
109
|
+
const authors = pulse.authors;
|
|
110
|
+
p.keyValues([
|
|
111
|
+
{
|
|
112
|
+
label: "Commits",
|
|
113
|
+
value: `${commits} on ${pulse.branch || "HEAD"}${merges ? `, ${plural(merges, "merge")}` : ""}${pulse.commitsTruncated ? " (capped)" : ""}`,
|
|
114
|
+
color: theme.accent,
|
|
115
|
+
},
|
|
116
|
+
{ label: "All branches", value: `${plural(pulse.allBranchCommits, "commit")}, merges excluded` },
|
|
117
|
+
{
|
|
118
|
+
label: "Authors",
|
|
119
|
+
value: authors.length ? `${authors.length}: ${authors.slice(0, 3).map((a) => a.name).join(", ")}${authors.length > 3 ? ", …" : ""}` : "none",
|
|
120
|
+
},
|
|
121
|
+
{ label: "Changed", value: `${plural(pulse.filesChanged, "file")}, +${pulse.additions} −${pulse.deletions}`, color: theme.success },
|
|
122
|
+
{ label: "Branches", value: pulse.branches.length ? `${pulse.branches.length} active: ${names(pulse.branches, 3)}` : "none active" },
|
|
123
|
+
{ label: "Tags", value: pulse.tags.length ? `${pulse.tags.length} new: ${names(pulse.tags, 4)}` : "none" },
|
|
124
|
+
{ label: "Period", value: `${pulse.since ? fmtStamp(pulse.since) : "the first commit"} to ${fmtStamp(pulse.until)}` },
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function activityPanel(col: Container, theme: Theme, pulse: Pulse | null, label: string, cols: number): void {
|
|
130
|
+
col.panel({ title: pulse ? `Commits per ${pulse.unit}` : "Commits", size: 8 }, (p) => {
|
|
131
|
+
if (!pulse) return;
|
|
132
|
+
const buckets = foldBuckets(pulse.buckets, Math.max(1, Math.floor(cols / 2)));
|
|
133
|
+
const peak = buckets.reduce((m, b) => Math.max(m, b.count), 0);
|
|
134
|
+
if (peak === 0) {
|
|
135
|
+
p.label(`No commits, ${label}.`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// Each bucket is widened to fill the pane, as one column per bucket would leave most of it empty.
|
|
139
|
+
const per = Math.max(1, Math.floor(cols / Math.max(1, buckets.length)));
|
|
140
|
+
p.text(`${plural(pulse.commits.length, "commit")}, peak ${peak} in one ${pulse.unit}`, { fg: theme.muted, size: 1 });
|
|
141
|
+
p.histogram({ values: buckets.flatMap((b) => Array<number>(per).fill(b.count)), color: theme.accent, size: 4 });
|
|
142
|
+
const first = buckets[0]?.label ?? "";
|
|
143
|
+
const last = buckets.at(-1)?.label ?? "";
|
|
144
|
+
p.text(`${first}${" ".repeat(Math.max(1, cols - first.length - last.length))}${last}`, { fg: theme.muted, size: 1 });
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function authorsPanel(col: Container, theme: Theme, pulse: Pulse | null, label: string): void {
|
|
149
|
+
col.panel({ title: `Authors (${pulse?.authors.length ?? 0})`, size: "1fr" }, (p) => {
|
|
150
|
+
if (!pulse || pulse.authors.length === 0) {
|
|
151
|
+
p.label(`No commits, ${label}.`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const total = pulse.authors.reduce((t, a) => t + a.commits, 0);
|
|
155
|
+
p.table({
|
|
156
|
+
rows: pulse.authors,
|
|
157
|
+
header: false,
|
|
158
|
+
scrollbar: true,
|
|
159
|
+
columns: [
|
|
160
|
+
{ key: "name", title: "", min: 8, color: theme.foreground },
|
|
161
|
+
{ key: "commits", title: "", width: 6, align: "right", color: theme.accent, render: (a) => String(a.commits) },
|
|
162
|
+
{
|
|
163
|
+
key: "share",
|
|
164
|
+
title: "",
|
|
165
|
+
width: 16,
|
|
166
|
+
color: theme.muted,
|
|
167
|
+
render: (a) => {
|
|
168
|
+
const filled = Math.round((a.commits / total) * 10);
|
|
169
|
+
return `${"█".repeat(filled)}${"·".repeat(10 - filled)} ${Math.round((100 * a.commits) / total)}%`;
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function filesPanel(col: Container, theme: Theme, state: PulseState, label: string, cols: number): void {
|
|
178
|
+
const pulse = state.data;
|
|
179
|
+
const title = pulse ? `Files changed (${pulse.filesChanged}) +${pulse.additions} −${pulse.deletions}` : "Files changed";
|
|
180
|
+
col.panel({ title, size: "1fr" }, (p) => {
|
|
181
|
+
if (!pulse || pulse.files.length === 0) {
|
|
182
|
+
p.label(`Nothing changed, ${label}.`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const pathWidth = Math.max(8, cols - 16);
|
|
186
|
+
p.table({
|
|
187
|
+
rows: pulse.files,
|
|
188
|
+
offset: state.filesOffset,
|
|
189
|
+
header: false,
|
|
190
|
+
scrollbar: true,
|
|
191
|
+
onScroll: (delta) => { state.filesOffset = clampOffset(state.filesOffset + delta, pulse.files.length); },
|
|
192
|
+
columns: [
|
|
193
|
+
{ key: "path", title: "", min: 8, color: theme.foreground, render: (f) => shortenPath(f.from ? `${f.from} → ${f.path}` : f.path, pathWidth) },
|
|
194
|
+
{ key: "added", title: "", width: 7, align: "right", color: theme.success, render: (f) => (f.binary ? "bin" : `+${f.added}`) },
|
|
195
|
+
{ key: "deleted", title: "", width: 7, align: "right", color: theme.danger, render: (f) => (f.binary ? "" : `−${f.deleted}`) },
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function githubPanel(col: Container, theme: Theme, state: PulseState, label: string, elapsed: number): void {
|
|
202
|
+
const g = state.github;
|
|
203
|
+
col.panel({ title: g ? `GitHub ${g.repo}` : "GitHub", size: 10 }, (p) => {
|
|
204
|
+
if (state.githubStatus === "loading") {
|
|
205
|
+
p.spinner({ label: "asking GitHub", text: label, elapsed });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (state.githubStatus !== "ready" || !g) {
|
|
209
|
+
p.text(state.githubNote || "Pull requests, issues, releases and stars appear here when gh is logged in.", { fg: theme.muted, wrap: true });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
p.keyValues([
|
|
213
|
+
{
|
|
214
|
+
label: "Pull requests",
|
|
215
|
+
value: `${g.prsMerged.length} merged, ${g.prsOpened.length} opened, ${g.prsClosed.length} closed unmerged`,
|
|
216
|
+
color: theme.accent,
|
|
217
|
+
},
|
|
218
|
+
{ label: "Issues", value: `${g.issuesOpened.length} opened, ${g.issuesClosed.length} closed, ${g.openIssues} open now` },
|
|
219
|
+
{ label: "Releases", value: g.releases.length ? `${g.releases.length}: ${g.releases.slice(0, 4).map((r) => r.tag).join(", ")}` : "none" },
|
|
220
|
+
{ label: "Stars", value: `+${g.newStars}${g.partial ? " or more" : ""}, ${g.stars} total, ${plural(g.forks, "fork")}` },
|
|
221
|
+
]);
|
|
222
|
+
const lines: string[] = [];
|
|
223
|
+
for (const pr of g.prsMerged.slice(0, 2)) lines.push(`merged #${pr.number} ${pr.title} (${pr.user})`);
|
|
224
|
+
for (const pr of g.prsOpened.slice(0, 1)) lines.push(`opened #${pr.number} ${pr.title} (${pr.user})`);
|
|
225
|
+
for (const is of g.issuesOpened.slice(0, 1)) lines.push(`issue #${is.number} ${is.title} (${is.user})`);
|
|
226
|
+
if (lines.length) p.text(lines.join("\n"), { fg: theme.muted });
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function trafficPanel(col: Container, theme: Theme, t: Traffic | null): void {
|
|
231
|
+
col.panel({ title: t ? `Traffic gh-pulse ${fmtStamp(t.reportAt)}` : "Traffic", size: 7 }, (p) => {
|
|
232
|
+
if (!t) {
|
|
233
|
+
p.text("Views and clones appear here when a gh-pulse report exists on this machine (~/.local/share/gh-pulse).", { fg: theme.muted, wrap: true });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const sum = (series: TrafficDay[], k: "count" | "uniques"): number => series.reduce((n, d) => n + d[k], 0);
|
|
237
|
+
const series = (s: TrafficDay[]): number[] => (s.length ? s.map((d) => d.count) : [0]);
|
|
238
|
+
p.sparkline({ values: series(t.views14d), label: "Views 14d", text: `${sum(t.views14d, "count")} / ${sum(t.views14d, "uniques")} unique`, color: theme.info, size: 1 });
|
|
239
|
+
p.sparkline({ values: series(t.clones14d), label: "Clones 14d", text: `${sum(t.clones14d, "count")} / ${sum(t.clones14d, "uniques")} unique`, color: theme.warning, size: 1 });
|
|
240
|
+
p.text(t.referrers.length ? `Referrers: ${t.referrers.slice(0, 4).map((r) => `${r.referrer} ${r.count}`).join(" · ")}` : "No referrers in the report.", { fg: theme.muted, size: 1 });
|
|
241
|
+
p.text(t.paths.length ? `Popular: ${t.paths.slice(0, 3).map((q) => `${q.path.replace(`/${t.repo}`, "") || "/"} ${q.count}`).join(" · ")}` : "No popular paths in the report.", { fg: theme.muted, size: 1 });
|
|
242
|
+
p.text(`${t.url}/graphs/traffic`, { fg: theme.muted, size: 1 });
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function pulseView({ ui, theme, width, height, elapsed }: ViewArgs, state: PulseState, root: string, actions: PulseActions = NO_ACTIONS): void {
|
|
247
|
+
const pulse = state.data;
|
|
248
|
+
const label = RANGE_LABEL[state.range];
|
|
249
|
+
|
|
250
|
+
ui.row({ size: 1 }, (header) => {
|
|
251
|
+
header.text(" g1tz", { fg: theme.title, bold: true, size: 7 });
|
|
252
|
+
header.text("pulse", { fg: theme.accent, size: 7 });
|
|
253
|
+
header.text(pulse ? pulse.branch || "(detached)" : "", { fg: theme.accent, size: 24 });
|
|
254
|
+
header.text(label, { fg: theme.muted });
|
|
255
|
+
header.text(`${root} d w m q y a range p repo ctrl+c quit `, { fg: theme.muted, align: "right" });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// One button per range, the current one lit, all clickable. `focused` is
|
|
259
|
+
// pinned off: the library would otherwise paint whichever button holds its
|
|
260
|
+
// focus index (the first) in the strong style, beside the real range.
|
|
261
|
+
ui.buttons(
|
|
262
|
+
RANGE_KEYS.map((k) => ({
|
|
263
|
+
label: k,
|
|
264
|
+
variant: (state.range === k ? "primary" : "ghost") as "primary" | "ghost",
|
|
265
|
+
focused: false,
|
|
266
|
+
onPress: () => actions.pickRange(k),
|
|
267
|
+
})),
|
|
268
|
+
{ size: 1 },
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
ui.row({ size: height - 3, gap: 1 }, (row) => {
|
|
272
|
+
row.column({ width: "1fr", gap: 1 }, (left) => {
|
|
273
|
+
overviewPanel(left, theme, pulse);
|
|
274
|
+
activityPanel(left, theme, pulse, label, leftPaneColumns(width));
|
|
275
|
+
authorsPanel(left, theme, pulse, label);
|
|
276
|
+
});
|
|
277
|
+
row.column({ width: "1.2fr", gap: 1 }, (right) => {
|
|
278
|
+
filesPanel(right, theme, state, label, rightPaneColumns(width));
|
|
279
|
+
githubPanel(right, theme, state, label, elapsed);
|
|
280
|
+
trafficPanel(right, theme, state.traffic);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
ui.statusBar({
|
|
285
|
+
items: [
|
|
286
|
+
{ key: "d w m q y a", label, active: true },
|
|
287
|
+
{ key: "↑↓", label: "Files" },
|
|
288
|
+
{ key: "r", label: "Refresh", onPress: actions.refresh },
|
|
289
|
+
{ key: "p", label: "Repo", onPress: actions.back },
|
|
290
|
+
{ key: "ctrl+c", label: "Quit" },
|
|
291
|
+
],
|
|
292
|
+
right: [{ label: state.note || githubLine(state, elapsed) }],
|
|
293
|
+
});
|
|
294
|
+
}
|