@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/src/main.ts ADDED
@@ -0,0 +1,466 @@
1
+ /**
2
+ * g1tz: a git TUI that shows you the repository, not a menu of git commands.
3
+ *
4
+ * bunx @profullstack/g1tz # the repository in the working directory
5
+ * bunx @profullstack/g1tz ~/proj # somewhere else
6
+ * bunx @profullstack/g1tz pulse [--range month] # start on the Pulse screen
7
+ *
8
+ * Files, branches and log on the left; the diff for whatever is selected on
9
+ * the right. Space stages and unstages. `p` flips to Pulse: what moved in the
10
+ * repository over a period, from git, from GitHub when gh is logged in, and
11
+ * from a gh-pulse report when one exists on the machine.
12
+ */
13
+ import { createApp, elevate, themes, type Container, type KeyEvent, type Theme } from "@profullstack/hqtui";
14
+ import { resolve } from "node:path";
15
+ import {
16
+ fileDiff, git, readRepo, stage, unstage,
17
+ type FileChange, type Repo,
18
+ } from "./git.ts";
19
+ import { highlightDiff, type DiffPalette } from "./diff.ts";
20
+ import { ghFetch, ghInstalled, githubRemote, readGitHubPulse, repoName, type Fetch, type GitHubRepo } from "./github.ts";
21
+ import { RANGE_KEYS, parseRangeKey, rangeForHotkey, readPulse, sinceFor, type RangeKey } from "./pulse.ts";
22
+ import { NO_ACTIONS, clampOffset, createPulseState, pulseView, type PulseActions, type PulseState } from "./pulse-view.ts";
23
+ import { readTraffic } from "./traffic.ts";
24
+
25
+ export type PaneName = "files" | "branches" | "log";
26
+ export type Screen = "repo" | "pulse";
27
+
28
+ export interface State {
29
+ repo: Repo;
30
+ /** The origin remote, when it is on GitHub. */
31
+ github: GitHubRepo | null;
32
+ screen: Screen;
33
+ pane: PaneName;
34
+ selected: Record<PaneName, number>;
35
+ offset: Record<PaneName, number>;
36
+ diff: string[];
37
+ diffOffset: number;
38
+ note: string;
39
+ pulse: PulseState;
40
+ }
41
+
42
+ export function createState(repo: Repo): State {
43
+ const state: State = {
44
+ repo,
45
+ github: githubRemote(repo.root),
46
+ screen: "repo",
47
+ pane: "files",
48
+ selected: { files: 0, branches: 0, log: 0 },
49
+ offset: { files: 0, branches: 0, log: 0 },
50
+ diff: [],
51
+ diffOffset: 0,
52
+ note: "",
53
+ pulse: createPulseState(),
54
+ };
55
+ refreshDiff(state);
56
+ return state;
57
+ }
58
+
59
+ export function selectedFile(state: State): FileChange | undefined {
60
+ return state.repo.files[state.selected.files];
61
+ }
62
+
63
+ /** The diff pane follows whichever pane has focus. */
64
+ export function refreshDiff(state: State): void {
65
+ state.diffOffset = 0;
66
+ if (state.pane === "files") {
67
+ const file = selectedFile(state);
68
+ state.diff = file
69
+ ? fileDiff(state.repo.root, file, file.staged && !file.unstaged).split("\n")
70
+ : [];
71
+ return;
72
+ }
73
+ if (state.pane === "log") {
74
+ const commit = state.repo.commits[state.selected.log];
75
+ state.diff = commit
76
+ ? (git(state.repo.root, ["show", "--no-color", "--stat", "--patch", commit.hash]) ?? "").split("\n")
77
+ : [];
78
+ return;
79
+ }
80
+ state.diff = [];
81
+ }
82
+
83
+ const statusGlyph = (file: FileChange): string => {
84
+ if (file.conflicted) return "!!";
85
+ if (file.untracked) return "??";
86
+ return `${file.index === "." ? " " : file.index}${file.work === "." ? " " : file.work}`;
87
+ };
88
+
89
+ const statusColor = (theme: Theme, file: FileChange): number => {
90
+ if (file.conflicted) return theme.danger;
91
+ if (file.untracked) return theme.muted;
92
+ if (file.staged && !file.unstaged) return theme.success;
93
+ if (file.staged) return theme.warning;
94
+ return theme.accent;
95
+ };
96
+
97
+ const PANES: PaneName[] = ["files", "branches", "log"];
98
+
99
+ function count(state: State, pane: PaneName): number {
100
+ if (pane === "files") return state.repo.files.length;
101
+ if (pane === "branches") return state.repo.branches.length;
102
+ return state.repo.commits.length;
103
+ }
104
+
105
+ export function move(state: State, delta: number): void {
106
+ const total = count(state, state.pane);
107
+ if (total === 0) return;
108
+ const next = Math.max(0, Math.min(total - 1, state.selected[state.pane] + delta));
109
+ if (next === state.selected[state.pane]) return;
110
+ state.selected[state.pane] = next;
111
+ refreshDiff(state);
112
+ }
113
+
114
+ export function toggleStaged(state: State): void {
115
+ const file = selectedFile(state);
116
+ if (!file) return;
117
+ const ok = file.staged && !file.unstaged
118
+ ? unstage(state.repo.root, file)
119
+ : stage(state.repo.root, file);
120
+ state.note = ok ? "" : `could not ${file.staged ? "unstage" : "stage"} ${file.path}`;
121
+ reload(state);
122
+ }
123
+
124
+ export function reload(state: State): void {
125
+ const fresh = readRepo(state.repo.root);
126
+ if (!fresh) { state.note = "repository disappeared"; return; }
127
+ state.repo = fresh;
128
+ for (const pane of PANES) {
129
+ state.selected[pane] = Math.max(0, Math.min(count(state, pane) - 1, state.selected[pane]));
130
+ }
131
+ refreshDiff(state);
132
+ }
133
+
134
+ // ---------------------------------------------------------------- pulse
135
+
136
+ /** Read the git half of the pulse for the current range, and the traffic if a gh-pulse report has this repository. */
137
+ export function refreshPulse(state: State, now: Date = new Date()): void {
138
+ const p = state.pulse;
139
+ p.data = readPulse(state.repo.root, p.range, now);
140
+ p.filesOffset = 0;
141
+ p.traffic = state.github ? readTraffic(repoName(state.github)) : null;
142
+ }
143
+
144
+ /** Flip to the Pulse screen, reading it the first time or when the range changed. */
145
+ export function openPulse(state: State, range?: RangeKey, now: Date = new Date()): void {
146
+ state.screen = "pulse";
147
+ if (range) state.pulse.range = range;
148
+ if (!state.pulse.data || state.pulse.data.range !== state.pulse.range) refreshPulse(state, now);
149
+ }
150
+
151
+ export function pickRange(state: State, range: RangeKey, now: Date = new Date()): void {
152
+ state.pulse.range = range;
153
+ state.pulse.note = "";
154
+ refreshPulse(state, now);
155
+ }
156
+
157
+ export function scrollPulseFiles(state: State, delta: number): void {
158
+ state.pulse.filesOffset = clampOffset(state.pulse.filesOffset + delta, state.pulse.data?.files.length ?? 0);
159
+ }
160
+
161
+ /**
162
+ * The GitHub half, off the render loop. Every call takes a ticket; a reply for
163
+ * an older ticket (the range changed while it was in flight) is dropped.
164
+ */
165
+ export function startGitHub(
166
+ state: State,
167
+ onChange: () => void,
168
+ now: Date = new Date(),
169
+ fetch: Fetch = ghFetch,
170
+ installed: () => boolean = ghInstalled,
171
+ ): Promise<void> {
172
+ const p = state.pulse;
173
+ const ticket = ++p.githubRequest;
174
+ const unavailable = (why: string): Promise<void> => {
175
+ p.githubStatus = "unavailable";
176
+ p.githubNote = why;
177
+ onChange();
178
+ return Promise.resolve();
179
+ };
180
+ if (!state.github) return unavailable("origin is not a GitHub remote");
181
+ if (!installed()) return unavailable("gh is not installed");
182
+ p.githubStatus = "loading";
183
+ p.githubNote = "";
184
+ onChange();
185
+ return readGitHubPulse(state.github, sinceFor(p.range, now), fetch).then(
186
+ (github) => {
187
+ if (ticket !== p.githubRequest) return;
188
+ p.github = github;
189
+ p.githubStatus = "ready";
190
+ onChange();
191
+ },
192
+ (error: unknown) => {
193
+ if (ticket !== p.githubRequest) return;
194
+ p.githubStatus = "unavailable";
195
+ p.githubNote = error instanceof Error ? error.message : String(error);
196
+ onChange();
197
+ },
198
+ );
199
+ }
200
+
201
+ // ---------------------------------------------------------------- command line
202
+
203
+ export const USAGE = `Usage:
204
+ g1tz [path] the repository (default: the working directory)
205
+ g1tz pulse [path] [--range KEY] start on the Pulse screen
206
+ KEY: ${RANGE_KEYS.join(", ")} (default week)`;
207
+
208
+ export interface Cli {
209
+ path: string;
210
+ pulse: boolean;
211
+ range?: RangeKey;
212
+ help: boolean;
213
+ error?: string;
214
+ }
215
+
216
+ export function parseCli(argv: readonly string[]): Cli {
217
+ const cli: Cli = { path: ".", pulse: false, help: false };
218
+ for (let i = 0; i < argv.length; i++) {
219
+ const a = argv[i] as string;
220
+ if (a === "-h" || a === "--help") cli.help = true;
221
+ else if (a === "pulse") cli.pulse = true;
222
+ else if (a === "--range" || a.startsWith("--range=")) {
223
+ const text = a === "--range" ? (argv[++i] ?? "") : a.slice("--range=".length);
224
+ const key = parseRangeKey(text);
225
+ if (!key) {
226
+ cli.error = `--range wants one of ${RANGE_KEYS.join(", ")}, not "${text}"`;
227
+ return cli;
228
+ }
229
+ cli.range = key;
230
+ cli.pulse = true;
231
+ } else if (a.startsWith("-")) {
232
+ cli.error = `unknown option: ${a}`;
233
+ return cli;
234
+ } else cli.path = a;
235
+ }
236
+ return cli;
237
+ }
238
+
239
+ async function main(): Promise<void> {
240
+ const cli = parseCli(process.argv.slice(2));
241
+ if (cli.help) {
242
+ console.log(USAGE);
243
+ return;
244
+ }
245
+ if (cli.error) {
246
+ console.error(`g1tz: ${cli.error}\n${USAGE}`);
247
+ process.exit(2);
248
+ }
249
+ const repo = readRepo(resolve(cli.path));
250
+ if (!repo) {
251
+ console.error("g1tz: not a git repository");
252
+ process.exit(1);
253
+ }
254
+ const state = createState(repo);
255
+ if (cli.pulse) openPulse(state, cli.range);
256
+ // focusNavigation off: the app handles every key itself, and with it on,
257
+ // Enter or Space would activate whichever range button held hqtui's hidden
258
+ // focus (the first one) and silently switch the range to "day".
259
+ const app = await createApp({ theme: themes.dark, title: "g1tz", quitKeys: ["ctrl+c"], focusNavigation: false });
260
+ const changed = (): void => app.invalidate();
261
+ const actions: PulseActions = {
262
+ pickRange: (key) => { pickRange(state, key); void startGitHub(state, changed); changed(); },
263
+ // The re-read panels and the spinner are the acknowledgement; a sticky note here would mask the GitHub line.
264
+ refresh: () => { refreshPulse(state); void startGitHub(state, changed); changed(); },
265
+ back: () => { state.screen = "repo"; changed(); },
266
+ pulse: () => { openPulse(state); void startGitHub(state, changed); changed(); },
267
+ };
268
+ if (cli.pulse) void startGitHub(state, changed);
269
+
270
+ // No `q` here: on this screen q is the quarter range. Ctrl+C quits from
271
+ // anywhere, and p or Escape go back to the repository, where q quits.
272
+ const pulseKey = (key: string): void => {
273
+ switch (key) {
274
+ case "p": case "escape": actions.back(); return;
275
+ case "r": actions.refresh(); return;
276
+ case "up": scrollPulseFiles(state, -1); return;
277
+ case "down": scrollPulseFiles(state, 1); return;
278
+ case "pageup": scrollPulseFiles(state, -10); return;
279
+ case "pagedown": scrollPulseFiles(state, 10); return;
280
+ default: {
281
+ const range = rangeForHotkey(key);
282
+ if (range) actions.pickRange(range);
283
+ }
284
+ }
285
+ };
286
+
287
+ app.on("key", (event: KeyEvent) => {
288
+ if (state.screen === "pulse") {
289
+ pulseKey(event.key);
290
+ return;
291
+ }
292
+ switch (event.key) {
293
+ case "q": app.quit(); return;
294
+ case "tab":
295
+ state.pane = PANES[(PANES.indexOf(state.pane) + 1) % PANES.length] as PaneName;
296
+ refreshDiff(state);
297
+ return;
298
+ case "up": move(state, -1); return;
299
+ case "down": move(state, 1); return;
300
+ case "pageup": move(state, -10); return;
301
+ case "pagedown": move(state, 10); return;
302
+ case "space": toggleStaged(state); return;
303
+ case "r": reload(state); state.note = "reloaded"; return;
304
+ case "left": state.diffOffset = Math.max(0, state.diffOffset - 10); return;
305
+ case "right": state.diffOffset += 10; return;
306
+ case "p": actions.pulse(); return;
307
+ }
308
+ });
309
+
310
+ app.render((args) => view(args, state, actions));
311
+ await app.start();
312
+ }
313
+
314
+
315
+ /** Diff colours from the active theme, so highlighting follows the theme. */
316
+ export function diffPalette(theme: Theme): DiffPalette {
317
+ return {
318
+ add: theme.success,
319
+ remove: theme.danger,
320
+ hunk: theme.accent,
321
+ meta: theme.muted,
322
+ context: theme.foreground,
323
+ // A background wash rather than another foreground: the line already
324
+ // carries its add/remove colour, and a second one would compete with it.
325
+ // `elevate` lifts the surface a little so the wash reads on both a light
326
+ // and a dark palette.
327
+ addEmphasis: elevate(theme, 0.18),
328
+ removeEmphasis: elevate(theme, 0.18),
329
+ };
330
+ }
331
+
332
+ export interface ViewArgs {
333
+ ui: Container;
334
+ theme: Theme;
335
+ width: number;
336
+ height: number;
337
+ elapsed: number;
338
+ }
339
+
340
+ /** One frame: whichever screen the state is on. */
341
+ export function view(args: ViewArgs, state: State, actions: PulseActions = NO_ACTIONS): void {
342
+ if (state.screen === "pulse") {
343
+ pulseView(args, state.pulse, state.repo.root, actions);
344
+ return;
345
+ }
346
+ repoView(args, state, actions);
347
+ }
348
+
349
+ function repoView({ ui, theme, height }: ViewArgs, state: State, actions: PulseActions): void {
350
+ const repo = state.repo;
351
+ const track = repo.upstream
352
+ ? `${repo.upstream}${repo.ahead ? ` ↑${repo.ahead}` : ""}${repo.behind ? ` ↓${repo.behind}` : ""}`
353
+ : "no upstream";
354
+
355
+ ui.row({ size: 1 }, (header) => {
356
+ header.text(" g1tz", { fg: theme.title, bold: true, size: 7 });
357
+ header.text(repo.branch || "(detached)", { fg: theme.accent, size: 24 });
358
+ header.text(track, { fg: theme.muted });
359
+ header.text(`${repo.root} Tab panes Space stage p pulse q quit `, { fg: theme.muted, align: "right" });
360
+ });
361
+
362
+ ui.row({ size: height - 2, gap: 1 }, (row) => {
363
+ row.column({ width: "1fr", gap: 1 }, (left) => {
364
+ left.panel({
365
+ title: `Files (${repo.files.length})`,
366
+ size: "1.2fr",
367
+ borderColor: state.pane === "files" ? theme.borderFocused : theme.border,
368
+ }, (p) => {
369
+ if (repo.files.length === 0) { p.label("Working tree clean."); return; }
370
+ p.table({
371
+ rows: repo.files.map((f) => ({
372
+ st: statusGlyph(f),
373
+ path: f.from ? `${f.from} → ${f.path}` : f.path,
374
+ file: f,
375
+ })),
376
+ selected: state.selected.files,
377
+ offset: state.offset.files,
378
+ followSelection: true,
379
+ scrollbar: true,
380
+ onScroll: (d) => { state.offset.files = Math.max(0, state.offset.files + d); },
381
+ header: false,
382
+ columns: [
383
+ // Per row, not per column: staged, unstaged and conflicted files
384
+ // each need their own colour, and a column-wide colour would paint
385
+ // the whole list whatever the first file happened to be.
386
+ { key: "st", title: "", width: 3, color: (row) => statusColor(theme, row.file) },
387
+ { key: "path", title: "", min: 8, color: theme.foreground },
388
+ ],
389
+ });
390
+ });
391
+
392
+ left.panel({
393
+ title: `Branches (${repo.branches.length})`,
394
+ size: "0.8fr",
395
+ borderColor: state.pane === "branches" ? theme.borderFocused : theme.border,
396
+ }, (p) => {
397
+ if (repo.branches.length === 0) { p.label("No branches."); return; }
398
+ p.list({
399
+ items: repo.branches.map((b) =>
400
+ `${b.current ? "* " : " "}${b.name}${b.ahead ? ` ↑${b.ahead}` : ""}${b.behind ? ` ↓${b.behind}` : ""}`),
401
+ selected: state.selected.branches,
402
+ offset: state.offset.branches,
403
+ scrollbar: true,
404
+ onScroll: (d) => { state.offset.branches = Math.max(0, state.offset.branches + d); },
405
+ });
406
+ });
407
+
408
+ left.panel({
409
+ title: `Log (${repo.commits.length})`,
410
+ size: "1fr",
411
+ borderColor: state.pane === "log" ? theme.borderFocused : theme.border,
412
+ }, (p) => {
413
+ if (repo.commits.length === 0) { p.label("No commits."); return; }
414
+ p.table({
415
+ rows: repo.commits.map((c) => ({ hash: c.short, subject: c.subject, when: c.when })),
416
+ selected: state.selected.log,
417
+ offset: state.offset.log,
418
+ followSelection: true,
419
+ scrollbar: true,
420
+ onScroll: (d) => { state.offset.log = Math.max(0, state.offset.log + d); },
421
+ header: false,
422
+ columns: [
423
+ { key: "hash", title: "", width: 9, color: theme.warning },
424
+ { key: "subject", title: "", min: 8, color: theme.foreground },
425
+ { key: "when", title: "", width: 14, align: "right", color: theme.muted },
426
+ ],
427
+ });
428
+ });
429
+ });
430
+
431
+ row.panel({ title: "Diff", width: "1.6fr" }, (p) => {
432
+ if (state.note !== "") p.text(state.note, { fg: theme.warning, size: 1 });
433
+ if (state.diff.length === 0 || (state.diff.length === 1 && state.diff[0] === "")) {
434
+ p.label(state.pane === "branches" ? "Select a file or a commit." : "No changes.");
435
+ return;
436
+ }
437
+ // Spans, so the words that actually differ can be emphasised inside an
438
+ // otherwise green or red line. Highlighting runs over the whole diff
439
+ // rather than the visible slice, because pairing a removal with its
440
+ // addition needs to see both even when one is scrolled off.
441
+ const highlighted = highlightDiff(state.diff, diffPalette(theme));
442
+ for (const line of highlighted.slice(state.diffOffset, state.diffOffset + 400)) {
443
+ p.text(line.length === 0 ? " " : line, { size: 1 });
444
+ }
445
+ });
446
+ });
447
+
448
+ ui.statusBar({
449
+ items: [
450
+ { key: "Tab", label: state.pane, active: true },
451
+ { key: "Space", label: "Stage" },
452
+ { key: "↑↓", label: "Move" },
453
+ { key: "r", label: "Reload" },
454
+ { key: "p", label: "Pulse", onPress: actions.pulse },
455
+ { key: "q", label: "Quit" },
456
+ ],
457
+ right: [{ label: repo.errors.length ? `${repo.errors.length} git errors` : "" }],
458
+ });
459
+ }
460
+
461
+ if (import.meta.main) {
462
+ main().catch((error) => {
463
+ console.error(error);
464
+ process.exit(1);
465
+ });
466
+ }