Skip to content

Commit a38b050

Browse files
committed
feat: add spinner
Closes #7425
1 parent 762888a commit a38b050

File tree

15 files changed

+301
-160
lines changed

15 files changed

+301
-160
lines changed

lib/commands/init.js

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const npa = require('npm-package-arg')
66
const libexec = require('libnpmexec')
77
const mapWorkspaces = require('@npmcli/map-workspaces')
88
const PackageJson = require('@npmcli/package-json')
9-
const { log, output } = require('proc-log')
9+
const { log, output, input } = require('proc-log')
1010
const updateWorkspaces = require('../utils/update-workspaces.js')
1111
const BaseCommand = require('../base-cmd.js')
1212

@@ -148,8 +148,6 @@ class Init extends BaseCommand {
148148
}
149149

150150
async template (path = process.cwd()) {
151-
log.pause()
152-
153151
const initFile = this.npm.config.get('init-module')
154152
if (!this.npm.config.get('yes') && !this.npm.config.get('force')) {
155153
output.standard([
@@ -167,7 +165,7 @@ class Init extends BaseCommand {
167165
}
168166

169167
try {
170-
const data = await initJson(path, initFile, this.npm.config)
168+
const data = await input.read(() => initJson(path, initFile, this.npm.config))
171169
log.silly('package data', data)
172170
return data
173171
} catch (er) {
@@ -176,8 +174,6 @@ class Init extends BaseCommand {
176174
} else {
177175
throw er
178176
}
179-
} finally {
180-
log.resume()
181177
}
182178
}
183179

lib/utils/display.js

Lines changed: 194 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const proggy = require('proggy')
2-
const { log, output, META } = require('proc-log')
2+
const { log, output, input, META } = require('proc-log')
33
const { explain } = require('./explain-eresolve.js')
44
const { formatWithOptions } = require('./format')
55

@@ -137,6 +137,9 @@ class Display {
137137
// Handlers are set immediately so they can buffer all events
138138
process.on('log', this.#logHandler)
139139
process.on('output', this.#outputHandler)
140+
process.on('input', this.#inputHandler)
141+
142+
this.#progress = new Progress({ stream: stderr })
140143
}
141144

142145
off () {
@@ -146,9 +149,9 @@ class Display {
146149
process.off('output', this.#outputHandler)
147150
this.#outputState.buffer.length = 0
148151

149-
if (this.#progress) {
150-
this.#progress.stop()
151-
}
152+
process.off('input', this.#inputHandler)
153+
154+
this.#progress.off()
152155
}
153156

154157
get chalk () {
@@ -171,6 +174,7 @@ class Display {
171174
unicode,
172175
}) {
173176
this.#command = command
177+
174178
// get createSupportsColor from chalk directly if this lands
175179
// https://github.com/chalk/chalk/pull/600
176180
const [{ Chalk }, { createSupportsColor }] = await Promise.all([
@@ -201,104 +205,124 @@ class Display {
201205
// Emit resume event on the logs which will flush output
202206
log.resume()
203207
output.flush()
204-
this.#startProgress({ progress, unicode })
208+
this.#progress.load({
209+
unicode,
210+
enabled: !!progress && !this.#silent,
211+
})
205212
}
206213

207214
// STREAM WRITES
208215

209216
// Write formatted and (non-)colorized output to streams
210-
#stdoutWrite (options, ...args) {
211-
this.#stdout.write(formatWithOptions({ colors: this.#stdoutColor, ...options }, ...args))
212-
}
213-
214-
#stderrWrite (options, ...args) {
215-
this.#stderr.write(formatWithOptions({ colors: this.#stderrColor, ...options }, ...args))
217+
#write (stream, options, ...args) {
218+
const colors = stream === this.#stdout ? this.#stdoutColor : this.#stderrColor
219+
this.#progress.write(stream, formatWithOptions({ colors, ...options }, ...args))
216220
}
217221

218222
// HANDLERS
219223

220224
// Arrow function assigned to a private class field so it can be passed
221225
// directly as a listener and still reference "this"
222226
#logHandler = withMeta((level, meta, ...args) => {
223-
if (level === log.KEYS.resume) {
224-
this.#logState.buffering = false
225-
this.#logState.buffer.forEach((item) => this.#tryWriteLog(...item))
226-
this.#logState.buffer.length = 0
227-
return
228-
}
229-
230-
if (level === log.KEYS.pause) {
231-
this.#logState.buffering = true
232-
return
233-
}
234-
235-
if (this.#logState.buffering) {
236-
this.#logState.buffer.push([level, meta, ...args])
237-
return
227+
switch (level) {
228+
case log.KEYS.resume:
229+
this.#logState.buffering = false
230+
this.#logState.buffer.forEach((item) => this.#tryWriteLog(...item))
231+
this.#logState.buffer.length = 0
232+
break
233+
234+
case log.KEYS.pause:
235+
this.#logState.buffering = true
236+
break
237+
238+
default:
239+
if (this.#logState.buffering) {
240+
this.#logState.buffer.push([level, meta, ...args])
241+
} else {
242+
this.#tryWriteLog(level, meta, ...args)
243+
}
244+
break
238245
}
239-
240-
this.#tryWriteLog(level, meta, ...args)
241246
})
242247

243248
// Arrow function assigned to a private class field so it can be passed
244249
// directly as a listener and still reference "this"
245250
#outputHandler = withMeta((level, meta, ...args) => {
246-
if (level === output.KEYS.flush) {
247-
this.#outputState.buffering = false
248-
249-
if (meta.jsonError && this.#json) {
250-
const json = {}
251-
for (const item of this.#outputState.buffer) {
252-
// index 2 skips the level and meta
253-
Object.assign(json, tryJsonParse(item[2]))
251+
switch (level) {
252+
case output.KEYS.flush:
253+
this.#outputState.buffering = false
254+
if (meta.jsonError && this.#json) {
255+
const json = {}
256+
for (const item of this.#outputState.buffer) {
257+
// index 2 skips the level and meta
258+
Object.assign(json, tryJsonParse(item[2]))
259+
}
260+
this.#writeOutput(
261+
output.KEYS.standard,
262+
meta,
263+
JSON.stringify({ ...json, error: meta.jsonError }, null, 2)
264+
)
265+
} else {
266+
this.#outputState.buffer.forEach((item) => this.#writeOutput(...item))
254267
}
255-
this.#writeOutput(
256-
output.KEYS.standard,
257-
meta,
258-
JSON.stringify({ ...json, error: meta.jsonError }, null, 2)
259-
)
260-
} else {
261-
this.#outputState.buffer.forEach((item) => this.#writeOutput(...item))
262-
}
263-
264-
this.#outputState.buffer.length = 0
265-
return
266-
}
267-
268-
if (level === output.KEYS.buffer) {
269-
this.#outputState.buffer.push([output.KEYS.standard, meta, ...args])
270-
return
271-
}
272-
273-
if (this.#outputState.buffering) {
274-
this.#outputState.buffer.push([level, meta, ...args])
275-
return
268+
this.#outputState.buffer.length = 0
269+
break
270+
271+
case output.KEYS.buffer:
272+
this.#outputState.buffer.push([output.KEYS.standard, meta, ...args])
273+
break
274+
275+
default:
276+
if (this.#outputState.buffering) {
277+
this.#outputState.buffer.push([level, meta, ...args])
278+
} else {
279+
// HACK: if it looks like the banner and we are in a state where we hide the
280+
// banner then dont write any output. This hack can be replaced with proc-log.META
281+
const isBanner = args.length === 1 &&
282+
typeof args[0] === 'string' &&
283+
args[0].startsWith('\n> ') &&
284+
args[0].endsWith('\n')
285+
const hideBanner = this.#silent || ['exec', 'explore'].includes(this.#command)
286+
if (!(isBanner && hideBanner)) {
287+
this.#writeOutput(level, meta, ...args)
288+
}
289+
}
290+
break
276291
}
292+
})
277293

278-
// HACK: if it looks like the banner and we are in a state where we hide the
279-
// banner then dont write any output. This hack can be replaced with proc-log.META
280-
const isBanner = args.length === 1 &&
281-
typeof args[0] === 'string' &&
282-
args[0].startsWith('\n> ') &&
283-
args[0].endsWith('\n')
284-
const hideBanner = this.#silent || ['exec', 'explore'].includes(this.#command)
285-
if (isBanner && hideBanner) {
286-
return
294+
#inputHandler = withMeta((level, meta, ...args) => {
295+
switch (level) {
296+
case input.KEYS.start:
297+
log.pause()
298+
this.#outputState.buffering = true
299+
this.#progress.pause()
300+
break
301+
302+
case input.KEYS.end:
303+
log.resume()
304+
output.flush()
305+
this.#progress.resume()
306+
break
307+
308+
case input.KEYS.read: {
309+
const [res, rej, p] = args
310+
return input.start(() => p().then(res).catch(rej).finally(() => output.standard('')))
311+
}
287312
}
288-
289-
this.#writeOutput(level, meta, ...args)
290313
})
291314

292315
// OUTPUT
293316

294317
#writeOutput (level, meta, ...args) {
295-
if (level === output.KEYS.standard) {
296-
this.#stdoutWrite({}, ...args)
297-
return
298-
}
299-
300-
if (level === output.KEYS.error) {
301-
this.#stderrWrite({}, ...args)
318+
switch (level) {
319+
case output.KEYS.standard:
320+
this.#write(this.#stdout, {}, ...args)
321+
break
322+
323+
case output.KEYS.error:
324+
this.#write(this.#stderr, {}, ...args)
325+
break
302326
}
303327
}
304328

@@ -344,22 +368,107 @@ class Display {
344368
this.#logColors[level](level),
345369
title ? this.#logColors.title(title) : null,
346370
]
347-
this.#stderrWrite({ prefix }, ...args)
348-
} else if (this.#progress) {
349-
// TODO: make this display a single log line of filtered messages
371+
this.#write(this.#stderr, { prefix }, ...args)
350372
}
351373
}
374+
}
375+
376+
class Progress {
377+
// Taken from https://github.com/sindresorhus/cli-spinners
378+
// MIT License
379+
// Copyright (c) Sindre Sorhus <[email protected]> (https://sindresorhus.com)
380+
static dots = { duration: 80, frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] }
381+
static lines = { duration: 130, frames: ['-', '\\', '|', '/'] }
382+
383+
#stream
384+
#spinner
385+
#client
386+
#enabled = false
387+
388+
#frameIndex = 0
389+
#lastUpdate = 0
390+
#interval
391+
#initialTimeout
392+
393+
constructor ({ stream }) {
394+
this.#client = proggy.createClient({ normalize: true })
395+
this.#stream = stream
396+
}
352397

353-
// PROGRESS
398+
load ({ enabled, unicode }) {
399+
this.#enabled = enabled
400+
this.#spinner = unicode ? Progress.dots : Progress.lines
401+
this.#delayRender(500)
402+
}
354403

355-
#startProgress ({ progress, unicode }) {
356-
if (!progress || this.#silent) {
404+
off () {
405+
this.#clear()
406+
}
407+
408+
pause () {
409+
this.#clear({ clearLine: true })
410+
}
411+
412+
#clear ({ clearLine } = {}) {
413+
if (!this.#enabled) {
357414
return
358415
}
359-
this.#progress = proggy.createClient({ normalize: true })
360-
// TODO: implement proggy trackers in arborist/doctor
361-
// TODO: listen to progress events here and build progress UI
362-
// TODO: see deprecated gauge package for what unicode chars were used
416+
clearTimeout(this.#initialTimeout)
417+
this.#initialTimeout = null
418+
clearInterval(this.#interval)
419+
this.#interval = null
420+
this.#frameIndex = 0
421+
this.#lastUpdate = 0
422+
this.#stream.cursorTo(0)
423+
if (clearLine) {
424+
this.#stream.clearLine(1)
425+
}
426+
}
427+
428+
resume () {
429+
this.#delayRender(10)
430+
}
431+
432+
write (stream, str) {
433+
if (!this.#enabled || !this.#interval) {
434+
return stream.write(str)
435+
}
436+
this.#stream.cursorTo(0)
437+
if (str.startsWith('\n')) {
438+
this.#stream.write(' ')
439+
this.#stream.cursorTo(0)
440+
}
441+
stream.write(str)
442+
this.#render()
443+
}
444+
445+
#delayRender (ms) {
446+
this.#initialTimeout = setTimeout(() => {
447+
this.#initialTimeout = null
448+
this.#render()
449+
}, ms)
450+
this.#initialTimeout.unref()
451+
}
452+
453+
#render () {
454+
if (!this.#enabled || this.#initialTimeout) {
455+
return
456+
}
457+
this.#renderFrame(Date.now() - this.#lastUpdate >= this.#spinner.duration)
458+
clearInterval(this.#interval)
459+
this.#interval = setInterval(() => this.#renderFrame(true), this.#spinner.duration)
460+
}
461+
462+
#renderFrame (next) {
463+
if (next) {
464+
this.#lastUpdate = Date.now()
465+
this.#frameIndex++
466+
if (this.#frameIndex >= this.#spinner.frames.length) {
467+
this.#frameIndex = 0
468+
}
469+
}
470+
this.#stream.cursorTo(0)
471+
this.#stream.write(this.#spinner.frames[this.#frameIndex])
363472
}
364473
}
365474

0 commit comments

Comments
 (0)