@impeccable/reviews 0.1.20 → 0.1.22

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/ui/bridge.js CHANGED
@@ -121,7 +121,9 @@ async function askOnStdin(q) {
121
121
  const out = process.stdout;
122
122
  if (q.kind === 'confirm') {
123
123
  out.write(`\n${q.title ? `${q.title}\n` : ''}${q.hint ? `${q.hint}\n` : ''}${q.prompt}\n`);
124
- const how = `Type y to ${(q.confirmLabel ?? 'continue').toLowerCase()}, or Enter to ${(q.skipLabel ?? 'skip').toLowerCase()}:`;
124
+ // Lowercased after "to", but for the I of "I'll install it myself".
125
+ const lower = (label) => label.toLowerCase().replace(/^i(?=['\s])/, 'I');
126
+ const how = `Type y to ${lower(q.confirmLabel ?? 'continue')}, or Enter to ${lower(q.skipLabel ?? 'skip')}:`;
125
127
  out.write(`${how} `);
126
128
  for (let line = await nextLine(); line !== null; line = await nextLine()) {
127
129
  if (/^y(es)?$/i.test(line.trim()))
@@ -130,7 +132,7 @@ async function askOnStdin(q) {
130
132
  return { skipped: true };
131
133
  out.write(`That is not an answer to this one. ${how} `);
132
134
  }
133
- return { skipped: true };
135
+ return { skipped: true, closed: true };
134
136
  }
135
137
  if (q.kind === 'account') {
136
138
  // An empty username keeps the one the field would hold; an empty password skips the account.
@@ -158,5 +160,5 @@ async function askOnStdin(q) {
158
160
  out.write(`That is not an answer to this one. ${how} `);
159
161
  }
160
162
  // Nobody is there to answer.
161
- return { skipped: true };
163
+ return { skipped: true, closed: true };
162
164
  }
package/dist/ui/look.js CHANGED
@@ -63,13 +63,30 @@ function block(lines, width, size = Math.max(0, ...lines.map((line) => visibleWi
63
63
  const left = ' '.repeat(Math.max(0, Math.floor((width - Math.min(size, width)) / 2)));
64
64
  return lines.map((line) => left + truncateToWidth(line, width - left.length, '…'));
65
65
  }
66
+ /** A word wider than `max` columns (a host name, an address) cut after a dot or a slash in each row's last two thirds, so it breaks where a reader expects (#390); a word with none stays whole. */
67
+ function dotRows(word, max) {
68
+ const rows = [];
69
+ let rest = word;
70
+ while (visibleWidth(rest) > max) {
71
+ const head = rest.slice(0, max);
72
+ const cut = Math.max(head.lastIndexOf('.'), head.lastIndexOf('/')) + 1;
73
+ if (cut < max / 3)
74
+ break;
75
+ rows.push(rest.slice(0, cut));
76
+ rest = rest.slice(cut);
77
+ }
78
+ return [...rows, rest];
79
+ }
66
80
  /** A paragraph of plain text wrapped to at most `max` columns: a sentence a line when each fits, else lines as even as the words allow. */
67
- function balanced(text, max) {
81
+ function balanced(given, max) {
82
+ const text = given.split(' ').map((word) => dotRows(word, max).join('\n')).join(' ');
68
83
  const sentences = text.split(/(?<=[.!?])\s+/);
69
84
  if (sentences.length > 1 && sentences.every((line) => visibleWidth(line) <= max))
70
85
  return sentences;
71
86
  const lines = wrapTextWithAnsi(text, Math.max(10, max));
72
- for (let w = Math.ceil(visibleWidth(text) / lines.length); lines.length > 1 && w < max; w++) {
87
+ // Never narrower than its longest word, which would then break mid-word.
88
+ const longest = Math.max(0, ...text.split(/\s+/).map((word) => visibleWidth(word)));
89
+ for (let w = Math.max(longest, Math.ceil(visibleWidth(text) / lines.length)); lines.length > 1 && w < max; w++) {
73
90
  const even = wrapTextWithAnsi(text, w);
74
91
  if (even.length === lines.length)
75
92
  return even;
@@ -240,24 +257,33 @@ function runPage(scene, f, inner, st) {
240
257
  if (!workStarted(scene.tasks))
241
258
  return { heading: 'Understanding your app', rule: loader(inner, f.motion?.frame ?? 0, st), action: status, ...around };
242
259
  const running = scene.tasks.findIndex((t) => t.status === 'in_progress');
243
- const lines = scene.tasks.map((t, i) => {
260
+ const rows = scene.tasks.map((t, i) => {
261
+ // A task back to pending after it started (Preview method while Test drive runs) keeps a started mark, never ○ (#390).
244
262
  const mark = t.status === 'completed' ? '✓'
245
263
  : t.status === 'in_progress' ? (i === running && f.motion?.at === 'task' ? glyph : '•')
246
264
  : t.status === 'failed' ? '✗'
247
265
  : t.status === 'skipped' ? '–'
248
- : '○';
266
+ : t.started ? '•'
267
+ : '○';
249
268
  const ink = t.status === 'in_progress' || t.status === 'failed' ? st.full : t.status === 'skipped' ? st.faint : st.soft;
250
269
  const row = (detail) => `${mark} ${t.title}${detail ? ` · ${detail}` : ''}`;
251
- if (t.status === 'skipped' || t.status === 'failed')
252
- return ink(row(t.status));
270
+ return { ink, row, line: t.status === 'skipped' || t.status === 'failed' ? row(t.status) : row() };
271
+ });
272
+ // The list's left edge is set by the rows without the wait's words, so it stays put while a wait comes and goes (#390).
273
+ const widest = Math.max(...rows.map(({ line }) => visibleWidth(line)));
274
+ const room = inner - Math.max(0, Math.floor((inner - Math.min(widest, inner)) / 2));
275
+ const lines = rows.map(({ ink, row, line }, i) => {
253
276
  if (i !== running || !scene.wait)
254
- return ink(row());
277
+ return ink(line);
255
278
  // The task in progress says what the run waits for and the time so far, never how long it may wait, so a screen
256
279
  // waiting on a host's build is never still; the time alone where the words would push it off the row.
257
280
  const waiting = row(`waiting for ${scene.wait.host ? `${scene.wait.host}'s preview` : 'the preview'} · ${scene.wait.timer}`);
258
- return ink(visibleWidth(waiting) <= inner ? waiting : row(scene.wait.timer));
281
+ if (visibleWidth(waiting) <= room)
282
+ return ink(waiting);
283
+ // Where even the time alone would pass the box from that edge, the title gives way, never the time.
284
+ const tail = ` · ${scene.wait.timer}`;
285
+ return ink(visibleWidth(row(scene.wait.timer)) <= room ? row(scene.wait.timer) : truncateToWidth(row(), Math.max(1, room - visibleWidth(tail)), '…') + tail);
259
286
  });
260
- const widest = Math.max(...lines.map((line) => visibleWidth(line)));
261
287
  // No count over the list: the list shows each task's state itself.
262
288
  return {
263
289
  heading: 'Setting up',
@@ -323,7 +349,7 @@ function pageOf(f, inner) {
323
349
  return runPage(scene, f, inner, st);
324
350
  case 'pull':
325
351
  return {
326
- heading: 'Your first review is on its way',
352
+ heading: scene.app ? `${scene.app}'s first review is on its way` : 'Your first review is on its way',
327
353
  text: said(SEE_THE_REVIEW, inner, st),
328
354
  rule: divider(inner, st),
329
355
  action: pullAction(scene.url, scene.copied, inner, st),
@@ -26,6 +26,8 @@ export class RunState {
26
26
  current;
27
27
  /** A bounded wait under way: the current line carries its timer, counting up. */
28
28
  waiting;
29
+ /** The tasks that have been in progress, done or failed in this run. */
30
+ started = new Set();
29
31
  /** When something last changed on screen: a status, the task list, an answered question. */
30
32
  changedAt;
31
33
  constructor(now = Date.now, first) {
@@ -59,7 +61,11 @@ export class RunState {
59
61
  return this.waiting && { host: this.waiting.host, timer: timer(live ? this.now() - this.waiting.since : 0) };
60
62
  }
61
63
  setTasks(tasks) {
62
- this.tasks = tasks.map((t) => ({ ...t }));
64
+ for (const t of tasks)
65
+ if (t.status === 'in_progress' || t.status === 'completed' || t.status === 'failed')
66
+ this.started.add(t.id);
67
+ // A task that has started never reads as not started again (#390).
68
+ this.tasks = tasks.map((t) => ({ ...t, ...(t.status === 'pending' && this.started.has(t.id) ? { started: true } : {}) }));
63
69
  this.touch();
64
70
  }
65
71
  /** Something happened that the person saw, so the silence starts again. */
@@ -117,7 +117,7 @@ class Wizard {
117
117
  process.off('SIGHUP', this.onSignal);
118
118
  mountUi(null);
119
119
  for (const question of this.questions.splice(0))
120
- question.resolve({ skipped: true });
120
+ question.resolve({ skipped: true, closed: true });
121
121
  // Leaves the alternate screen without copying the last frame out of it: the scrollback gets the outro's summary only.
122
122
  this.tui.stop({ preserveScreen: true });
123
123
  this.restoreConsole();
@@ -240,7 +240,7 @@ class Wizard {
240
240
  }
241
241
  this.stopAsked = 0;
242
242
  for (const question of this.questions.splice(0))
243
- question.resolve({ skipped: true });
243
+ question.resolve({ skipped: true, closed: true });
244
244
  this.state.status('Stopping the setup');
245
245
  this.redraw();
246
246
  run.cancel();
@@ -342,7 +342,7 @@ class Wizard {
342
342
  if (view.kind === 'signin')
343
343
  return { kind: 'signin', link: view.link, copied: view.copied, again: view.again, note: view.pasting ? undefined : view.note, checkout: view.opts.checkout };
344
344
  if (view.kind === 'pull')
345
- return { kind: 'pull', url: view.url, copied: view.copied };
345
+ return { kind: 'pull', url: view.url, ...(view.app ? { app: view.app } : {}), copied: view.copied };
346
346
  if (view.kind === 'outro')
347
347
  return { kind: 'outro', outro: view.outro, leaving: view.leaving, copied: view.copied };
348
348
  return { kind: 'run', tasks: this.state.tasks, band: this.state.band(!this.questions.length, live), wait: this.state.timer(live) };
@@ -486,8 +486,8 @@ async function handOver(url, hand) {
486
486
  const copied = hand.copy(url).catch(() => false);
487
487
  return (await Promise.race([copied, new Promise((r) => setTimeout(() => r(false), 1000).unref())])) === true;
488
488
  }
489
- /** The test drive on a screen of its own once the run has ended, before the last screen shows the setup pull request, handed over as it appears, until a key other than ↑↓ PgUp PgDn is pressed, or for 20 minutes. Without a terminal there is no such screen: the summary has it. */
490
- export async function showPullRequest(url, hand) {
489
+ /** The test drive on a screen of its own once the run has ended, before the last screen shows the setup pull request, handed over as it appears, until a key other than ↑↓ PgUp PgDn is pressed, or for 20 minutes; on a repository with several apps, each app's in turn, named (impeccable-reviews#397). Without a terminal there is no such screen: the summary has it. */
490
+ export async function showPullRequest(url, hand, app) {
491
491
  const screen = host();
492
492
  if (!screen)
493
493
  return;
@@ -499,7 +499,7 @@ export async function showPullRequest(url, hand) {
499
499
  resolve();
500
500
  };
501
501
  const timer = setTimeout(done, CLOSE_AFTER_MS).unref();
502
- const view = { kind: 'pull', url, copied, copy: () => hand.copy(url), done };
502
+ const view = { kind: 'pull', url, ...(app ? { app } : {}), copied, copy: () => hand.copy(url), done };
503
503
  screen.views = [];
504
504
  screen.push(view);
505
505
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impeccable/reviews",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Set up a repository for Impeccable for PRs from your terminal.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "private": false,