Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions bin/gh-pulse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,22 @@ export function ttySpinner(label: string, stream: NodeJS.WriteStream = process.s
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let i = 0;
let line = '';
const draw = (): void => { stream.write(`\r\x1b[K${frames[i % frames.length]} ${label}${line ? `: ${line}` : ''}`); i += 1; };
let ended = false;
// Clamped to the terminal width: `\r\x1b[K` only clears the last physical row
// of a wrapped line, so an overlong row would walk down the screen a row per tick.
const draw = (): void => {
const cols = stream.columns ?? 80;
const text = `${frames[i % frames.length]} ${label}${line ? `: ${line}` : ''}`;
stream.write(`\r\x1b[K${text.length < cols ? text : `${text.slice(0, Math.max(0, cols - 2))}…`}`);
i += 1;
};
const timer = setInterval(draw, 80);
timer.unref();
draw();
return {
progress: (l) => { line = l; draw(); },
done: () => { clearInterval(timer); stream.write(`\r\x1b[K${line ? `gh-pulse: ${line}\n` : ''}`); },
progress: (l) => { line = l; if (!ended) draw(); },
// Idempotent: a caller may stop it in a finally block after stopping it on success.
done: () => { if (ended) return; ended = true; clearInterval(timer); stream.write(`\r\x1b[K${line ? `gh-pulse: ${line}\n` : ''}`); },
};
}

Expand Down Expand Up @@ -149,7 +158,7 @@ export async function main(argv: readonly string[]): Promise<number> {
if (verb === 'show') {
const { showTui } = await import('../src/gh-pulse-tui.ts');
const startKey: RangeKey | undefined = spec && spec.key !== 'custom' ? spec.key : undefined;
const code = await showTui(dir, { range: startKey, loadRange: (key, progress) => loadRange(rangeSpec(key, deps.now()), progress) });
const code = await showTui(dir, { range: startKey, loadRange: (key, progress, force) => loadRange(rangeSpec(key, deps.now()), progress, force) });
// A range scan still in flight would keep the process alive after the screen is gone, spending the hour on a report nobody reads.
process.exit(code);
}
Expand Down Expand Up @@ -181,22 +190,25 @@ export async function main(argv: readonly string[]): Promise<number> {
const sp = ttySpinner(`gh-pulse ${spec.label}`);
try {
await rangeScan({ spec, top, repos, dataDir: dir, send, to, from, progress: sp.progress }, deps);
sp.done();
process.stdout.write(readFileSync(rangeOutputPaths(dir, rangeSlug(spec, repos)).text, 'utf8'));
return 0;
} catch (error) {
sp.done();
process.stderr.write(`gh-pulse: ${(error as Error).stack ?? String(error)}\n`);
return 1;
} finally {
sp.done();
}
process.stdout.write(readFileSync(rangeOutputPaths(dir, rangeSlug(spec, repos)).text, 'utf8'));
return 0;
}

if (!to) throw new UsageError('no recipient: pass --to, set GH_PULSE_TO, or configure git user.email');
const dryRun = parsed.flags.has('--dry-run');
const sp = ttySpinner(dryRun ? 'gh-pulse dry run' : 'gh-pulse daily run');
try {
// On a terminal the coarse log lines ride the spinner too; in cron they print as before.
await run({ dryRun, to, from, top, hours, repos, dataDir: dir, progress: sp.progress }, process.stderr.isTTY ? { ...deps, log: sp.progress } : deps);
// On a terminal the coarse log lines and the per-ten counts ride the spinner;
// in cron only the coarse lines print, exactly as before the spinner existed.
const tty = process.stderr.isTTY === true;
await run({ dryRun, to, from, top, hours, repos, dataDir: dir, ...(tty ? { progress: sp.progress } : {}) }, tty ? { ...deps, log: sp.progress } : deps);
sp.done();
return 0;
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/cli-tools",
"version": "0.36.0",
"version": "0.36.1",
"private": true,
"description": "Local command-line tools, in TypeScript, exposed on PATH.",
"type": "module",
Expand Down
12 changes: 6 additions & 6 deletions src/gh-pulse-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export interface ShowOptions {
/** Start on this range instead of the latest daily report. */
range?: RangeKey | undefined;
/** Produce (or fetch from cache) the report for a range. Called off the render loop. */
loadRange: (key: RangeKey, progress: (line: string) => void) => Promise<ReportJson>;
loadRange: (key: RangeKey, progress: (line: string) => void, force?: boolean) => Promise<ReportJson>;
}

export async function showTui(dataDir: string, opts: ShowOptions): Promise<number> {
Expand Down Expand Up @@ -175,8 +175,8 @@ export async function showTui(dataDir: string, opts: ShowOptions): Promise<numbe
scanLine = force ? 'rescanning' : 'loading';
notice = '';
app.invalidate();
opts.loadRange(key, (line) => { scanLine = line; app.invalidate(); }).then(
(r) => { report = r; current = key; selected = 0; offset = 0; scanning = null; scanLine = ''; app.invalidate(); },
opts.loadRange(key, (line) => { scanLine = line; app.invalidate(); }, force).then(
(r) => { report = r; current = key; selected = 0; offset = 0; scanning = null; scanLine = ''; notice = ''; app.invalidate(); },
(error: unknown) => { scanning = null; scanLine = ''; notice = `scan failed: ${(error as Error).message}`; app.invalidate(); },
);
};
Expand Down Expand Up @@ -213,11 +213,11 @@ export async function showTui(dataDir: string, opts: ShowOptions): Promise<numbe

if (opts.range) pickRange(opts.range);

app.render(({ ui, theme, width, height, elapsed }) => {
app.render(({ ui, theme, width, height, elapsed, capabilities }) => {
const rows = visible();
const bodyRows = Math.max(3, height - 14);
// The same glyph the spinner line shows, for the range button that is loading.
const glyph = widgets.spinnerFrame(elapsed, widgets.SPINNER_FRAMES.dots);
// The same glyph the spinner line shows, for the range button that is loading; ascii where the terminal has no Unicode.
const glyph = widgets.spinnerFrame(elapsed, capabilities.unicode ? widgets.SPINNER_FRAMES.dots : widgets.SPINNER_FRAMES.ascii);
if (selected >= rows.length) selected = Math.max(0, rows.length - 1);
if (selected < offset) offset = selected;
if (selected >= offset + bodyRows) offset = selected - bodyRows + 1;
Expand Down
31 changes: 31 additions & 0 deletions test/gh-pulse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
type Snapshot,
} from '../src/gh-pulse.ts';
import { historyRows } from '../src/gh-pulse-tui.ts';
import { ttySpinner } from '../bin/gh-pulse.ts';

const b = (day: string, count: number, uniques = count): Bucket => ({ timestamp: `${day}T00:00:00Z`, count, uniques });
const moved = (patch: Partial<Movement>): Movement => ({ ...emptyMovement(), ...patch });
Expand Down Expand Up @@ -196,3 +197,33 @@ describe('historyRows', () => {
expect(historyRows([snap])).toEqual([{ at: snap.at, repos: 2, stars: 12, followers: 2, movers: 1, views: 3, clones: 4 }]);
});
});

describe('ttySpinner', () => {
const fake = (isTTY: boolean, columns = 40) => {
const out: string[] = [];
const stream = { isTTY, columns, write: (s: string) => { out.push(s); return true; } } as unknown as NodeJS.WriteStream;
return { stream, out };
};

it('prints plain lines and nothing else when there is no terminal', () => {
const { stream, out } = fake(false);
const sp = ttySpinner('gh-pulse daily run', stream);
sp.progress('349 repos');
sp.done();
sp.done();
expect(out).toEqual(['gh-pulse: 349 repos\n']);
});

it('clamps the busy row to the terminal width and stops exactly once', () => {
const { stream, out } = fake(true, 30);
const sp = ttySpinner('gh-pulse last 7 days', stream);
sp.progress('a progress line that is far wider than thirty columns');
sp.done();
sp.done();
const rows = out.filter((s) => s.startsWith('\r\x1b[K') && !s.endsWith('\n')).map((s) => s.slice(4));
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((r) => r.length <= 30)).toBe(true);
expect(rows[rows.length - 1]!.endsWith('…')).toBe(true);
expect(out.filter((s) => s.endsWith('\n'))).toEqual(['\r\x1b[Kgh-pulse: a progress line that is far wider than thirty columns\n']);
});
});
Loading