@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/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
+ }