@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
package/dist/github.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitHub half of Pulse: pull requests, issues, releases and stars over the
|
|
3
|
+
* period, read through the gh CLI. gh holds the login, so nothing here ever
|
|
4
|
+
* sees a token, and a machine without gh (or without a login) simply gets the
|
|
5
|
+
* git half on its own.
|
|
6
|
+
*
|
|
7
|
+
* Pages are walked newest-first and stop at the first item older than the
|
|
8
|
+
* period, under a small page budget, so a busy repository on "all time"
|
|
9
|
+
* cannot turn one keypress into a thousand requests. When the budget runs out
|
|
10
|
+
* the counts are floors and the result says so.
|
|
11
|
+
*/
|
|
12
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
13
|
+
import { git } from "./git.js";
|
|
14
|
+
/**
|
|
15
|
+
* https, ssh and scp-style GitHub remotes, with or without .git, including
|
|
16
|
+
* GitHub's "SSH over the HTTPS port" form (ssh.github.com:443) and an explicit
|
|
17
|
+
* port. Anything else is null.
|
|
18
|
+
*/
|
|
19
|
+
export function parseGitHubRemote(url) {
|
|
20
|
+
const m = /^(?:https?:\/\/(?:[^@/]+@)?|git@|ssh:\/\/(?:git@)?)(?:ssh\.)?github\.com(?::\d+)?[/:]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(url.trim());
|
|
21
|
+
return m ? { owner: m[1], name: m[2] } : null;
|
|
22
|
+
}
|
|
23
|
+
export function githubRemote(root) {
|
|
24
|
+
const url = git(root, ["remote", "get-url", "origin"]);
|
|
25
|
+
return url ? parseGitHubRemote(url) : null;
|
|
26
|
+
}
|
|
27
|
+
export const repoName = (repo) => `${repo.owner}/${repo.name}`;
|
|
28
|
+
let ghPresent = null;
|
|
29
|
+
/** Whether gh is on the PATH. Asked once per process. */
|
|
30
|
+
export function ghInstalled() {
|
|
31
|
+
if (ghPresent === null)
|
|
32
|
+
ghPresent = spawnSync("gh", ["--version"], { encoding: "utf8" }).status === 0;
|
|
33
|
+
return ghPresent;
|
|
34
|
+
}
|
|
35
|
+
/** What to tell the user when gh could not answer. */
|
|
36
|
+
export function ghErrorMessage(error, stderr) {
|
|
37
|
+
if (error.code === "ENOENT")
|
|
38
|
+
return "gh is not installed";
|
|
39
|
+
// `killed` is execFile's own timeout; a signal is gh dying to something else. Neither leaves anything on stderr.
|
|
40
|
+
if (error.killed || error.signal)
|
|
41
|
+
return "GitHub did not answer in time (gh was stopped after 60 seconds)";
|
|
42
|
+
const s = stderr.trim();
|
|
43
|
+
if (/gh auth login|not logged in|authentication required|HTTP 401/i.test(s))
|
|
44
|
+
return "gh is not logged in (run gh auth login)";
|
|
45
|
+
if (/rate limit/i.test(s))
|
|
46
|
+
return "GitHub API rate limit exceeded; try again later";
|
|
47
|
+
if (/HTTP 404/.test(s))
|
|
48
|
+
return "repository not found on GitHub, or the gh login cannot see it";
|
|
49
|
+
return s.split("\n")[0] || error.message;
|
|
50
|
+
}
|
|
51
|
+
export function ghFetch(path, headers = {}) {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
const args = ["api", path];
|
|
54
|
+
for (const [k, v] of Object.entries(headers))
|
|
55
|
+
args.push("-H", `${k}: ${v}`);
|
|
56
|
+
execFile("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: 60_000 }, (error, stdout, stderr) => {
|
|
57
|
+
if (error) {
|
|
58
|
+
reject(new Error(ghErrorMessage(error, stderr)));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
resolve(JSON.parse(stdout));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
reject(new Error("gh returned something that is not JSON"));
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export const PAGE_BUDGET = { pulls: 5, issues: 5, releases: 2, stars: 3 };
|
|
71
|
+
const STAR_HEADERS = { Accept: "application/vnd.github.star+json" };
|
|
72
|
+
/** Walk numbered pages until `stop` says an item is older than needed, a short page ends the list, or the budget is spent. */
|
|
73
|
+
async function walk(fetch, path, pages, stop) {
|
|
74
|
+
const items = [];
|
|
75
|
+
for (let page = 1; page <= pages; page++) {
|
|
76
|
+
const data = (await fetch(`${path}&page=${page}`));
|
|
77
|
+
if (!Array.isArray(data) || data.length === 0)
|
|
78
|
+
return { items, partial: false };
|
|
79
|
+
for (const item of data) {
|
|
80
|
+
if (stop(item))
|
|
81
|
+
return { items, partial: false };
|
|
82
|
+
items.push(item);
|
|
83
|
+
}
|
|
84
|
+
if (data.length < 100)
|
|
85
|
+
return { items, partial: false };
|
|
86
|
+
}
|
|
87
|
+
return { items, partial: true };
|
|
88
|
+
}
|
|
89
|
+
/** GitHub lists stargazers oldest first, so the count of new ones starts at the last page and walks back. */
|
|
90
|
+
async function countStars(fetch, name, total, cutoff, pages) {
|
|
91
|
+
if (cutoff === 0 || total === 0)
|
|
92
|
+
return { count: total, partial: false };
|
|
93
|
+
const lastPage = Math.max(1, Math.ceil(total / 100));
|
|
94
|
+
let count = 0;
|
|
95
|
+
for (let page = lastPage, used = 0; page >= 1; page--, used++) {
|
|
96
|
+
if (used >= pages)
|
|
97
|
+
return { count, partial: true };
|
|
98
|
+
let data;
|
|
99
|
+
try {
|
|
100
|
+
data = (await fetch(`repos/${name}/stargazers?per_page=100&page=${page}`, STAR_HEADERS));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// GitHub refuses the stargazers list of a very large repository (facebook/react
|
|
104
|
+
// answers 404 on every page), and a rate limit can land here after everything
|
|
105
|
+
// else answered. The star count is the only casualty, not the whole read.
|
|
106
|
+
return { count, partial: true };
|
|
107
|
+
}
|
|
108
|
+
if (!Array.isArray(data))
|
|
109
|
+
return { count, partial: false };
|
|
110
|
+
for (let i = data.length - 1; i >= 0; i--) {
|
|
111
|
+
if (Date.parse(data[i].starred_at) < cutoff)
|
|
112
|
+
return { count, partial: false };
|
|
113
|
+
count++;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { count, partial: false };
|
|
117
|
+
}
|
|
118
|
+
const item = (x, at) => ({ number: x.number, title: x.title, user: x.user?.login ?? "", at });
|
|
119
|
+
const newest = (a, b) => b.at.localeCompare(a.at);
|
|
120
|
+
export async function readGitHubPulse(repo, since, fetch = ghFetch, budget = PAGE_BUDGET) {
|
|
121
|
+
const name = repoName(repo);
|
|
122
|
+
const cutoff = since ? since.getTime() : 0;
|
|
123
|
+
const inRange = (iso) => iso !== null && iso !== undefined && Date.parse(iso) >= cutoff;
|
|
124
|
+
const stale = (iso) => !inRange(iso);
|
|
125
|
+
const info = (await fetch(`repos/${name}`));
|
|
126
|
+
// Sorted by last update, newest first: the first one untouched in the period ends the walk.
|
|
127
|
+
const pulls = await walk(fetch, `repos/${name}/pulls?state=all&sort=updated&direction=desc&per_page=100`, budget.pulls, (p) => stale(p.updated_at));
|
|
128
|
+
const sinceParam = since ? `&since=${since.toISOString()}` : "";
|
|
129
|
+
const issues = await walk(fetch, `repos/${name}/issues?state=all&sort=updated&direction=desc&per_page=100${sinceParam}`, budget.issues, (i) => stale(i.updated_at));
|
|
130
|
+
// Releases are listed by the tagged commit's date, not by when they were
|
|
131
|
+
// published, so a release cut this week for an older tag sits below older
|
|
132
|
+
// ones: no early stop, the budget's pages are read and published_at decides.
|
|
133
|
+
const releases = await walk(fetch, `repos/${name}/releases?per_page=100`, budget.releases, () => false);
|
|
134
|
+
const stars = await countStars(fetch, name, info.stargazers_count ?? 0, cutoff, budget.stars);
|
|
135
|
+
return {
|
|
136
|
+
repo: name,
|
|
137
|
+
url: info.html_url ?? `https://github.com/${name}`,
|
|
138
|
+
description: info.description ?? "",
|
|
139
|
+
stars: info.stargazers_count ?? 0,
|
|
140
|
+
forks: info.forks_count ?? 0,
|
|
141
|
+
openIssues: info.open_issues_count ?? 0,
|
|
142
|
+
prsOpened: pulls.items.filter((p) => inRange(p.created_at)).map((p) => item(p, p.created_at)).sort(newest),
|
|
143
|
+
prsMerged: pulls.items.filter((p) => inRange(p.merged_at)).map((p) => item(p, p.merged_at)).sort(newest),
|
|
144
|
+
prsClosed: pulls.items.filter((p) => !p.merged_at && inRange(p.closed_at)).map((p) => item(p, p.closed_at)).sort(newest),
|
|
145
|
+
issuesOpened: issues.items.filter((i) => !i.pull_request && inRange(i.created_at)).map((i) => item(i, i.created_at)).sort(newest),
|
|
146
|
+
issuesClosed: issues.items.filter((i) => !i.pull_request && inRange(i.closed_at)).map((i) => item(i, i.closed_at)).sort(newest),
|
|
147
|
+
releases: releases.items
|
|
148
|
+
.filter((r) => !r.draft && inRange(r.published_at))
|
|
149
|
+
.map((r) => ({ tag: r.tag_name, name: r.name ?? r.tag_name, at: r.published_at }))
|
|
150
|
+
.sort((a, b) => b.at.localeCompare(a.at)),
|
|
151
|
+
newStars: stars.count,
|
|
152
|
+
partial: pulls.partial || issues.partial || releases.partial || stars.partial,
|
|
153
|
+
};
|
|
154
|
+
}
|
package/dist/main.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
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 { type Container, type Theme } from "@profullstack/hqtui";
|
|
14
|
+
import { type FileChange, type Repo } from "./git.ts";
|
|
15
|
+
import { type DiffPalette } from "./diff.ts";
|
|
16
|
+
import { type Fetch, type GitHubRepo } from "./github.ts";
|
|
17
|
+
import { type RangeKey } from "./pulse.ts";
|
|
18
|
+
import { type PulseActions, type PulseState } from "./pulse-view.ts";
|
|
19
|
+
export type PaneName = "files" | "branches" | "log";
|
|
20
|
+
export type Screen = "repo" | "pulse";
|
|
21
|
+
export interface State {
|
|
22
|
+
repo: Repo;
|
|
23
|
+
/** The origin remote, when it is on GitHub. */
|
|
24
|
+
github: GitHubRepo | null;
|
|
25
|
+
screen: Screen;
|
|
26
|
+
pane: PaneName;
|
|
27
|
+
selected: Record<PaneName, number>;
|
|
28
|
+
offset: Record<PaneName, number>;
|
|
29
|
+
diff: string[];
|
|
30
|
+
diffOffset: number;
|
|
31
|
+
note: string;
|
|
32
|
+
pulse: PulseState;
|
|
33
|
+
}
|
|
34
|
+
export declare function createState(repo: Repo): State;
|
|
35
|
+
export declare function selectedFile(state: State): FileChange | undefined;
|
|
36
|
+
/** The diff pane follows whichever pane has focus. */
|
|
37
|
+
export declare function refreshDiff(state: State): void;
|
|
38
|
+
export declare function move(state: State, delta: number): void;
|
|
39
|
+
export declare function toggleStaged(state: State): void;
|
|
40
|
+
export declare function reload(state: State): void;
|
|
41
|
+
/** Read the git half of the pulse for the current range, and the traffic if a gh-pulse report has this repository. */
|
|
42
|
+
export declare function refreshPulse(state: State, now?: Date): void;
|
|
43
|
+
/** Flip to the Pulse screen, reading it the first time or when the range changed. */
|
|
44
|
+
export declare function openPulse(state: State, range?: RangeKey, now?: Date): void;
|
|
45
|
+
export declare function pickRange(state: State, range: RangeKey, now?: Date): void;
|
|
46
|
+
export declare function scrollPulseFiles(state: State, delta: number): void;
|
|
47
|
+
/**
|
|
48
|
+
* The GitHub half, off the render loop. Every call takes a ticket; a reply for
|
|
49
|
+
* an older ticket (the range changed while it was in flight) is dropped.
|
|
50
|
+
*/
|
|
51
|
+
export declare function startGitHub(state: State, onChange: () => void, now?: Date, fetch?: Fetch, installed?: () => boolean): Promise<void>;
|
|
52
|
+
export declare const USAGE: string;
|
|
53
|
+
export interface Cli {
|
|
54
|
+
path: string;
|
|
55
|
+
pulse: boolean;
|
|
56
|
+
range?: RangeKey;
|
|
57
|
+
help: boolean;
|
|
58
|
+
error?: string;
|
|
59
|
+
}
|
|
60
|
+
export declare function parseCli(argv: readonly string[]): Cli;
|
|
61
|
+
/** Diff colours from the active theme, so highlighting follows the theme. */
|
|
62
|
+
export declare function diffPalette(theme: Theme): DiffPalette;
|
|
63
|
+
export interface ViewArgs {
|
|
64
|
+
ui: Container;
|
|
65
|
+
theme: Theme;
|
|
66
|
+
width: number;
|
|
67
|
+
height: number;
|
|
68
|
+
elapsed: number;
|
|
69
|
+
}
|
|
70
|
+
/** One frame: whichever screen the state is on. */
|
|
71
|
+
export declare function view(args: ViewArgs, state: State, actions?: PulseActions): void;
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
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 } from "@profullstack/hqtui";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import { fileDiff, git, readRepo, stage, unstage, } from "./git.js";
|
|
16
|
+
import { highlightDiff } from "./diff.js";
|
|
17
|
+
import { ghFetch, ghInstalled, githubRemote, readGitHubPulse, repoName } from "./github.js";
|
|
18
|
+
import { RANGE_KEYS, parseRangeKey, rangeForHotkey, readPulse, sinceFor } from "./pulse.js";
|
|
19
|
+
import { NO_ACTIONS, clampOffset, createPulseState, pulseView } from "./pulse-view.js";
|
|
20
|
+
import { readTraffic } from "./traffic.js";
|
|
21
|
+
export function createState(repo) {
|
|
22
|
+
const state = {
|
|
23
|
+
repo,
|
|
24
|
+
github: githubRemote(repo.root),
|
|
25
|
+
screen: "repo",
|
|
26
|
+
pane: "files",
|
|
27
|
+
selected: { files: 0, branches: 0, log: 0 },
|
|
28
|
+
offset: { files: 0, branches: 0, log: 0 },
|
|
29
|
+
diff: [],
|
|
30
|
+
diffOffset: 0,
|
|
31
|
+
note: "",
|
|
32
|
+
pulse: createPulseState(),
|
|
33
|
+
};
|
|
34
|
+
refreshDiff(state);
|
|
35
|
+
return state;
|
|
36
|
+
}
|
|
37
|
+
export function selectedFile(state) {
|
|
38
|
+
return state.repo.files[state.selected.files];
|
|
39
|
+
}
|
|
40
|
+
/** The diff pane follows whichever pane has focus. */
|
|
41
|
+
export function refreshDiff(state) {
|
|
42
|
+
state.diffOffset = 0;
|
|
43
|
+
if (state.pane === "files") {
|
|
44
|
+
const file = selectedFile(state);
|
|
45
|
+
state.diff = file
|
|
46
|
+
? fileDiff(state.repo.root, file, file.staged && !file.unstaged).split("\n")
|
|
47
|
+
: [];
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (state.pane === "log") {
|
|
51
|
+
const commit = state.repo.commits[state.selected.log];
|
|
52
|
+
state.diff = commit
|
|
53
|
+
? (git(state.repo.root, ["show", "--no-color", "--stat", "--patch", commit.hash]) ?? "").split("\n")
|
|
54
|
+
: [];
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
state.diff = [];
|
|
58
|
+
}
|
|
59
|
+
const statusGlyph = (file) => {
|
|
60
|
+
if (file.conflicted)
|
|
61
|
+
return "!!";
|
|
62
|
+
if (file.untracked)
|
|
63
|
+
return "??";
|
|
64
|
+
return `${file.index === "." ? " " : file.index}${file.work === "." ? " " : file.work}`;
|
|
65
|
+
};
|
|
66
|
+
const statusColor = (theme, file) => {
|
|
67
|
+
if (file.conflicted)
|
|
68
|
+
return theme.danger;
|
|
69
|
+
if (file.untracked)
|
|
70
|
+
return theme.muted;
|
|
71
|
+
if (file.staged && !file.unstaged)
|
|
72
|
+
return theme.success;
|
|
73
|
+
if (file.staged)
|
|
74
|
+
return theme.warning;
|
|
75
|
+
return theme.accent;
|
|
76
|
+
};
|
|
77
|
+
const PANES = ["files", "branches", "log"];
|
|
78
|
+
function count(state, pane) {
|
|
79
|
+
if (pane === "files")
|
|
80
|
+
return state.repo.files.length;
|
|
81
|
+
if (pane === "branches")
|
|
82
|
+
return state.repo.branches.length;
|
|
83
|
+
return state.repo.commits.length;
|
|
84
|
+
}
|
|
85
|
+
export function move(state, delta) {
|
|
86
|
+
const total = count(state, state.pane);
|
|
87
|
+
if (total === 0)
|
|
88
|
+
return;
|
|
89
|
+
const next = Math.max(0, Math.min(total - 1, state.selected[state.pane] + delta));
|
|
90
|
+
if (next === state.selected[state.pane])
|
|
91
|
+
return;
|
|
92
|
+
state.selected[state.pane] = next;
|
|
93
|
+
refreshDiff(state);
|
|
94
|
+
}
|
|
95
|
+
export function toggleStaged(state) {
|
|
96
|
+
const file = selectedFile(state);
|
|
97
|
+
if (!file)
|
|
98
|
+
return;
|
|
99
|
+
const ok = file.staged && !file.unstaged
|
|
100
|
+
? unstage(state.repo.root, file)
|
|
101
|
+
: stage(state.repo.root, file);
|
|
102
|
+
state.note = ok ? "" : `could not ${file.staged ? "unstage" : "stage"} ${file.path}`;
|
|
103
|
+
reload(state);
|
|
104
|
+
}
|
|
105
|
+
export function reload(state) {
|
|
106
|
+
const fresh = readRepo(state.repo.root);
|
|
107
|
+
if (!fresh) {
|
|
108
|
+
state.note = "repository disappeared";
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
state.repo = fresh;
|
|
112
|
+
for (const pane of PANES) {
|
|
113
|
+
state.selected[pane] = Math.max(0, Math.min(count(state, pane) - 1, state.selected[pane]));
|
|
114
|
+
}
|
|
115
|
+
refreshDiff(state);
|
|
116
|
+
}
|
|
117
|
+
// ---------------------------------------------------------------- pulse
|
|
118
|
+
/** Read the git half of the pulse for the current range, and the traffic if a gh-pulse report has this repository. */
|
|
119
|
+
export function refreshPulse(state, now = new Date()) {
|
|
120
|
+
const p = state.pulse;
|
|
121
|
+
p.data = readPulse(state.repo.root, p.range, now);
|
|
122
|
+
p.filesOffset = 0;
|
|
123
|
+
p.traffic = state.github ? readTraffic(repoName(state.github)) : null;
|
|
124
|
+
}
|
|
125
|
+
/** Flip to the Pulse screen, reading it the first time or when the range changed. */
|
|
126
|
+
export function openPulse(state, range, now = new Date()) {
|
|
127
|
+
state.screen = "pulse";
|
|
128
|
+
if (range)
|
|
129
|
+
state.pulse.range = range;
|
|
130
|
+
if (!state.pulse.data || state.pulse.data.range !== state.pulse.range)
|
|
131
|
+
refreshPulse(state, now);
|
|
132
|
+
}
|
|
133
|
+
export function pickRange(state, range, now = new Date()) {
|
|
134
|
+
state.pulse.range = range;
|
|
135
|
+
state.pulse.note = "";
|
|
136
|
+
refreshPulse(state, now);
|
|
137
|
+
}
|
|
138
|
+
export function scrollPulseFiles(state, delta) {
|
|
139
|
+
state.pulse.filesOffset = clampOffset(state.pulse.filesOffset + delta, state.pulse.data?.files.length ?? 0);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The GitHub half, off the render loop. Every call takes a ticket; a reply for
|
|
143
|
+
* an older ticket (the range changed while it was in flight) is dropped.
|
|
144
|
+
*/
|
|
145
|
+
export function startGitHub(state, onChange, now = new Date(), fetch = ghFetch, installed = ghInstalled) {
|
|
146
|
+
const p = state.pulse;
|
|
147
|
+
const ticket = ++p.githubRequest;
|
|
148
|
+
const unavailable = (why) => {
|
|
149
|
+
p.githubStatus = "unavailable";
|
|
150
|
+
p.githubNote = why;
|
|
151
|
+
onChange();
|
|
152
|
+
return Promise.resolve();
|
|
153
|
+
};
|
|
154
|
+
if (!state.github)
|
|
155
|
+
return unavailable("origin is not a GitHub remote");
|
|
156
|
+
if (!installed())
|
|
157
|
+
return unavailable("gh is not installed");
|
|
158
|
+
p.githubStatus = "loading";
|
|
159
|
+
p.githubNote = "";
|
|
160
|
+
onChange();
|
|
161
|
+
return readGitHubPulse(state.github, sinceFor(p.range, now), fetch).then((github) => {
|
|
162
|
+
if (ticket !== p.githubRequest)
|
|
163
|
+
return;
|
|
164
|
+
p.github = github;
|
|
165
|
+
p.githubStatus = "ready";
|
|
166
|
+
onChange();
|
|
167
|
+
}, (error) => {
|
|
168
|
+
if (ticket !== p.githubRequest)
|
|
169
|
+
return;
|
|
170
|
+
p.githubStatus = "unavailable";
|
|
171
|
+
p.githubNote = error instanceof Error ? error.message : String(error);
|
|
172
|
+
onChange();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// ---------------------------------------------------------------- command line
|
|
176
|
+
export const USAGE = `Usage:
|
|
177
|
+
g1tz [path] the repository (default: the working directory)
|
|
178
|
+
g1tz pulse [path] [--range KEY] start on the Pulse screen
|
|
179
|
+
KEY: ${RANGE_KEYS.join(", ")} (default week)`;
|
|
180
|
+
export function parseCli(argv) {
|
|
181
|
+
const cli = { path: ".", pulse: false, help: false };
|
|
182
|
+
for (let i = 0; i < argv.length; i++) {
|
|
183
|
+
const a = argv[i];
|
|
184
|
+
if (a === "-h" || a === "--help")
|
|
185
|
+
cli.help = true;
|
|
186
|
+
else if (a === "pulse")
|
|
187
|
+
cli.pulse = true;
|
|
188
|
+
else if (a === "--range" || a.startsWith("--range=")) {
|
|
189
|
+
const text = a === "--range" ? (argv[++i] ?? "") : a.slice("--range=".length);
|
|
190
|
+
const key = parseRangeKey(text);
|
|
191
|
+
if (!key) {
|
|
192
|
+
cli.error = `--range wants one of ${RANGE_KEYS.join(", ")}, not "${text}"`;
|
|
193
|
+
return cli;
|
|
194
|
+
}
|
|
195
|
+
cli.range = key;
|
|
196
|
+
cli.pulse = true;
|
|
197
|
+
}
|
|
198
|
+
else if (a.startsWith("-")) {
|
|
199
|
+
cli.error = `unknown option: ${a}`;
|
|
200
|
+
return cli;
|
|
201
|
+
}
|
|
202
|
+
else
|
|
203
|
+
cli.path = a;
|
|
204
|
+
}
|
|
205
|
+
return cli;
|
|
206
|
+
}
|
|
207
|
+
async function main() {
|
|
208
|
+
const cli = parseCli(process.argv.slice(2));
|
|
209
|
+
if (cli.help) {
|
|
210
|
+
console.log(USAGE);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (cli.error) {
|
|
214
|
+
console.error(`g1tz: ${cli.error}\n${USAGE}`);
|
|
215
|
+
process.exit(2);
|
|
216
|
+
}
|
|
217
|
+
const repo = readRepo(resolve(cli.path));
|
|
218
|
+
if (!repo) {
|
|
219
|
+
console.error("g1tz: not a git repository");
|
|
220
|
+
process.exit(1);
|
|
221
|
+
}
|
|
222
|
+
const state = createState(repo);
|
|
223
|
+
if (cli.pulse)
|
|
224
|
+
openPulse(state, cli.range);
|
|
225
|
+
// focusNavigation off: the app handles every key itself, and with it on,
|
|
226
|
+
// Enter or Space would activate whichever range button held hqtui's hidden
|
|
227
|
+
// focus (the first one) and silently switch the range to "day".
|
|
228
|
+
const app = await createApp({ theme: themes.dark, title: "g1tz", quitKeys: ["ctrl+c"], focusNavigation: false });
|
|
229
|
+
const changed = () => app.invalidate();
|
|
230
|
+
const actions = {
|
|
231
|
+
pickRange: (key) => { pickRange(state, key); void startGitHub(state, changed); changed(); },
|
|
232
|
+
// The re-read panels and the spinner are the acknowledgement; a sticky note here would mask the GitHub line.
|
|
233
|
+
refresh: () => { refreshPulse(state); void startGitHub(state, changed); changed(); },
|
|
234
|
+
back: () => { state.screen = "repo"; changed(); },
|
|
235
|
+
pulse: () => { openPulse(state); void startGitHub(state, changed); changed(); },
|
|
236
|
+
};
|
|
237
|
+
if (cli.pulse)
|
|
238
|
+
void startGitHub(state, changed);
|
|
239
|
+
// No `q` here: on this screen q is the quarter range. Ctrl+C quits from
|
|
240
|
+
// anywhere, and p or Escape go back to the repository, where q quits.
|
|
241
|
+
const pulseKey = (key) => {
|
|
242
|
+
switch (key) {
|
|
243
|
+
case "p":
|
|
244
|
+
case "escape":
|
|
245
|
+
actions.back();
|
|
246
|
+
return;
|
|
247
|
+
case "r":
|
|
248
|
+
actions.refresh();
|
|
249
|
+
return;
|
|
250
|
+
case "up":
|
|
251
|
+
scrollPulseFiles(state, -1);
|
|
252
|
+
return;
|
|
253
|
+
case "down":
|
|
254
|
+
scrollPulseFiles(state, 1);
|
|
255
|
+
return;
|
|
256
|
+
case "pageup":
|
|
257
|
+
scrollPulseFiles(state, -10);
|
|
258
|
+
return;
|
|
259
|
+
case "pagedown":
|
|
260
|
+
scrollPulseFiles(state, 10);
|
|
261
|
+
return;
|
|
262
|
+
default: {
|
|
263
|
+
const range = rangeForHotkey(key);
|
|
264
|
+
if (range)
|
|
265
|
+
actions.pickRange(range);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
app.on("key", (event) => {
|
|
270
|
+
if (state.screen === "pulse") {
|
|
271
|
+
pulseKey(event.key);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
switch (event.key) {
|
|
275
|
+
case "q":
|
|
276
|
+
app.quit();
|
|
277
|
+
return;
|
|
278
|
+
case "tab":
|
|
279
|
+
state.pane = PANES[(PANES.indexOf(state.pane) + 1) % PANES.length];
|
|
280
|
+
refreshDiff(state);
|
|
281
|
+
return;
|
|
282
|
+
case "up":
|
|
283
|
+
move(state, -1);
|
|
284
|
+
return;
|
|
285
|
+
case "down":
|
|
286
|
+
move(state, 1);
|
|
287
|
+
return;
|
|
288
|
+
case "pageup":
|
|
289
|
+
move(state, -10);
|
|
290
|
+
return;
|
|
291
|
+
case "pagedown":
|
|
292
|
+
move(state, 10);
|
|
293
|
+
return;
|
|
294
|
+
case "space":
|
|
295
|
+
toggleStaged(state);
|
|
296
|
+
return;
|
|
297
|
+
case "r":
|
|
298
|
+
reload(state);
|
|
299
|
+
state.note = "reloaded";
|
|
300
|
+
return;
|
|
301
|
+
case "left":
|
|
302
|
+
state.diffOffset = Math.max(0, state.diffOffset - 10);
|
|
303
|
+
return;
|
|
304
|
+
case "right":
|
|
305
|
+
state.diffOffset += 10;
|
|
306
|
+
return;
|
|
307
|
+
case "p":
|
|
308
|
+
actions.pulse();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
app.render((args) => view(args, state, actions));
|
|
313
|
+
await app.start();
|
|
314
|
+
}
|
|
315
|
+
/** Diff colours from the active theme, so highlighting follows the theme. */
|
|
316
|
+
export function diffPalette(theme) {
|
|
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
|
+
/** One frame: whichever screen the state is on. */
|
|
332
|
+
export function view(args, state, actions = NO_ACTIONS) {
|
|
333
|
+
if (state.screen === "pulse") {
|
|
334
|
+
pulseView(args, state.pulse, state.repo.root, actions);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
repoView(args, state, actions);
|
|
338
|
+
}
|
|
339
|
+
function repoView({ ui, theme, height }, state, actions) {
|
|
340
|
+
const repo = state.repo;
|
|
341
|
+
const track = repo.upstream
|
|
342
|
+
? `${repo.upstream}${repo.ahead ? ` ↑${repo.ahead}` : ""}${repo.behind ? ` ↓${repo.behind}` : ""}`
|
|
343
|
+
: "no upstream";
|
|
344
|
+
ui.row({ size: 1 }, (header) => {
|
|
345
|
+
header.text(" g1tz", { fg: theme.title, bold: true, size: 7 });
|
|
346
|
+
header.text(repo.branch || "(detached)", { fg: theme.accent, size: 24 });
|
|
347
|
+
header.text(track, { fg: theme.muted });
|
|
348
|
+
header.text(`${repo.root} Tab panes Space stage p pulse q quit `, { fg: theme.muted, align: "right" });
|
|
349
|
+
});
|
|
350
|
+
ui.row({ size: height - 2, gap: 1 }, (row) => {
|
|
351
|
+
row.column({ width: "1fr", gap: 1 }, (left) => {
|
|
352
|
+
left.panel({
|
|
353
|
+
title: `Files (${repo.files.length})`,
|
|
354
|
+
size: "1.2fr",
|
|
355
|
+
borderColor: state.pane === "files" ? theme.borderFocused : theme.border,
|
|
356
|
+
}, (p) => {
|
|
357
|
+
if (repo.files.length === 0) {
|
|
358
|
+
p.label("Working tree clean.");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
p.table({
|
|
362
|
+
rows: repo.files.map((f) => ({
|
|
363
|
+
st: statusGlyph(f),
|
|
364
|
+
path: f.from ? `${f.from} → ${f.path}` : f.path,
|
|
365
|
+
file: f,
|
|
366
|
+
})),
|
|
367
|
+
selected: state.selected.files,
|
|
368
|
+
offset: state.offset.files,
|
|
369
|
+
followSelection: true,
|
|
370
|
+
scrollbar: true,
|
|
371
|
+
onScroll: (d) => { state.offset.files = Math.max(0, state.offset.files + d); },
|
|
372
|
+
header: false,
|
|
373
|
+
columns: [
|
|
374
|
+
// Per row, not per column: staged, unstaged and conflicted files
|
|
375
|
+
// each need their own colour, and a column-wide colour would paint
|
|
376
|
+
// the whole list whatever the first file happened to be.
|
|
377
|
+
{ key: "st", title: "", width: 3, color: (row) => statusColor(theme, row.file) },
|
|
378
|
+
{ key: "path", title: "", min: 8, color: theme.foreground },
|
|
379
|
+
],
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
left.panel({
|
|
383
|
+
title: `Branches (${repo.branches.length})`,
|
|
384
|
+
size: "0.8fr",
|
|
385
|
+
borderColor: state.pane === "branches" ? theme.borderFocused : theme.border,
|
|
386
|
+
}, (p) => {
|
|
387
|
+
if (repo.branches.length === 0) {
|
|
388
|
+
p.label("No branches.");
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
p.list({
|
|
392
|
+
items: repo.branches.map((b) => `${b.current ? "* " : " "}${b.name}${b.ahead ? ` ↑${b.ahead}` : ""}${b.behind ? ` ↓${b.behind}` : ""}`),
|
|
393
|
+
selected: state.selected.branches,
|
|
394
|
+
offset: state.offset.branches,
|
|
395
|
+
scrollbar: true,
|
|
396
|
+
onScroll: (d) => { state.offset.branches = Math.max(0, state.offset.branches + d); },
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
left.panel({
|
|
400
|
+
title: `Log (${repo.commits.length})`,
|
|
401
|
+
size: "1fr",
|
|
402
|
+
borderColor: state.pane === "log" ? theme.borderFocused : theme.border,
|
|
403
|
+
}, (p) => {
|
|
404
|
+
if (repo.commits.length === 0) {
|
|
405
|
+
p.label("No commits.");
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
p.table({
|
|
409
|
+
rows: repo.commits.map((c) => ({ hash: c.short, subject: c.subject, when: c.when })),
|
|
410
|
+
selected: state.selected.log,
|
|
411
|
+
offset: state.offset.log,
|
|
412
|
+
followSelection: true,
|
|
413
|
+
scrollbar: true,
|
|
414
|
+
onScroll: (d) => { state.offset.log = Math.max(0, state.offset.log + d); },
|
|
415
|
+
header: false,
|
|
416
|
+
columns: [
|
|
417
|
+
{ key: "hash", title: "", width: 9, color: theme.warning },
|
|
418
|
+
{ key: "subject", title: "", min: 8, color: theme.foreground },
|
|
419
|
+
{ key: "when", title: "", width: 14, align: "right", color: theme.muted },
|
|
420
|
+
],
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
row.panel({ title: "Diff", width: "1.6fr" }, (p) => {
|
|
425
|
+
if (state.note !== "")
|
|
426
|
+
p.text(state.note, { fg: theme.warning, size: 1 });
|
|
427
|
+
if (state.diff.length === 0 || (state.diff.length === 1 && state.diff[0] === "")) {
|
|
428
|
+
p.label(state.pane === "branches" ? "Select a file or a commit." : "No changes.");
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
// Spans, so the words that actually differ can be emphasised inside an
|
|
432
|
+
// otherwise green or red line. Highlighting runs over the whole diff
|
|
433
|
+
// rather than the visible slice, because pairing a removal with its
|
|
434
|
+
// addition needs to see both even when one is scrolled off.
|
|
435
|
+
const highlighted = highlightDiff(state.diff, diffPalette(theme));
|
|
436
|
+
for (const line of highlighted.slice(state.diffOffset, state.diffOffset + 400)) {
|
|
437
|
+
p.text(line.length === 0 ? " " : line, { size: 1 });
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
ui.statusBar({
|
|
442
|
+
items: [
|
|
443
|
+
{ key: "Tab", label: state.pane, active: true },
|
|
444
|
+
{ key: "Space", label: "Stage" },
|
|
445
|
+
{ key: "↑↓", label: "Move" },
|
|
446
|
+
{ key: "r", label: "Reload" },
|
|
447
|
+
{ key: "p", label: "Pulse", onPress: actions.pulse },
|
|
448
|
+
{ key: "q", label: "Quit" },
|
|
449
|
+
],
|
|
450
|
+
right: [{ label: repo.errors.length ? `${repo.errors.length} git errors` : "" }],
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
if (import.meta.main) {
|
|
454
|
+
main().catch((error) => {
|
|
455
|
+
console.error(error);
|
|
456
|
+
process.exit(1);
|
|
457
|
+
});
|
|
458
|
+
}
|