Skip to content

Commit

Permalink
feat: add spinner
Browse files Browse the repository at this point in the history
Closes #7425
  • Loading branch information
lukekarrys committed Apr 28, 2024
1 parent 762888a commit 8705cbf
Show file tree
Hide file tree
Showing 8 changed files with 181 additions and 80 deletions.
8 changes: 2 additions & 6 deletions lib/commands/init.js
Expand Up @@ -6,7 +6,7 @@ const npa = require('npm-package-arg')
const libexec = require('libnpmexec')
const mapWorkspaces = require('@npmcli/map-workspaces')
const PackageJson = require('@npmcli/package-json')
const { log, output } = require('proc-log')
const { log, output, input } = require('proc-log')
const updateWorkspaces = require('../utils/update-workspaces.js')
const BaseCommand = require('../base-cmd.js')

Expand Down Expand Up @@ -148,8 +148,6 @@ class Init extends BaseCommand {
}

async template (path = process.cwd()) {
log.pause()

const initFile = this.npm.config.get('init-module')
if (!this.npm.config.get('yes') && !this.npm.config.get('force')) {
output.standard([
Expand All @@ -167,7 +165,7 @@ class Init extends BaseCommand {
}

try {
const data = await initJson(path, initFile, this.npm.config)
const data = await input.start(() => initJson(path, initFile, this.npm.config))
log.silly('package data', data)
return data
} catch (er) {
Expand All @@ -176,8 +174,6 @@ class Init extends BaseCommand {
} else {
throw er
}
} finally {
log.resume()
}
}

Expand Down
149 changes: 126 additions & 23 deletions lib/utils/display.js
@@ -1,5 +1,5 @@
const proggy = require('proggy')
const { log, output, META } = require('proc-log')
const { log, output, input, META } = require('proc-log')
const { explain } = require('./explain-eresolve.js')
const { formatWithOptions } = require('./format')

Expand Down Expand Up @@ -137,6 +137,9 @@ class Display {
// Handlers are set immediately so they can buffer all events
process.on('log', this.#logHandler)
process.on('output', this.#outputHandler)
process.on('input', this.#inputHandler)

this.#progress = new Progress({ stream: stderr })
}

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

if (this.#progress) {
this.#progress.stop()
}
process.off('input', this.#inputHandler)

this.#progress.off()
}

get chalk () {
Expand All @@ -171,6 +174,7 @@ class Display {
unicode,
}) {
this.#command = command

// get createSupportsColor from chalk directly if this lands
// https://github.com/chalk/chalk/pull/600
const [{ Chalk }, { createSupportsColor }] = await Promise.all([
Expand Down Expand Up @@ -201,18 +205,18 @@ class Display {
// Emit resume event on the logs which will flush output
log.resume()
output.flush()
this.#startProgress({ progress, unicode })
this.#progress.load({
unicode,
enabled: !!progress && !this.#silent,
})
}

// STREAM WRITES

// Write formatted and (non-)colorized output to streams
#stdoutWrite (options, ...args) {
this.#stdout.write(formatWithOptions({ colors: this.#stdoutColor, ...options }, ...args))
}

#stderrWrite (options, ...args) {
this.#stderr.write(formatWithOptions({ colors: this.#stderrColor, ...options }, ...args))
#write (stream, options, ...args) {
const colors = stream === this.#stdout ? this.#stdoutColor : this.#stderrColor
this.#progress.write(stream, formatWithOptions({ colors, ...options }, ...args))
}

// HANDLERS
Expand Down Expand Up @@ -259,6 +263,9 @@ class Display {
)
} else {
this.#outputState.buffer.forEach((item) => this.#writeOutput(...item))
if (args.length) {
this.#writeOutput(output.KEYS.standard, meta, ...args)
}
}

this.#outputState.buffer.length = 0
Expand Down Expand Up @@ -289,16 +296,31 @@ class Display {
this.#writeOutput(level, meta, ...args)
})

#inputHandler = withMeta((level, meta, ...args) => {
if (level === input.KEYS.start) {
log.pause()
this.#outputState.buffering = true
this.#progress.pause()
return
}

if (level === input.KEYS.end) {
log.resume()
output.flush('')
this.#progress.resume()
}
})

// OUTPUT

#writeOutput (level, meta, ...args) {
if (level === output.KEYS.standard) {
this.#stdoutWrite({}, ...args)
this.#write(this.#stdout, {}, ...args)
return
}

if (level === output.KEYS.error) {
this.#stderrWrite({}, ...args)
this.#write(this.#stderr, {}, ...args)
}
}

Expand Down Expand Up @@ -344,22 +366,103 @@ class Display {
this.#logColors[level](level),
title ? this.#logColors.title(title) : null,
]
this.#stderrWrite({ prefix }, ...args)
} else if (this.#progress) {
// TODO: make this display a single log line of filtered messages
this.#write(this.#stderr, { prefix }, ...args)
}
}
}

class Progress {
// Taken from https://github.com/sindresorhus/cli-spinners
// MIT License
// Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
static dots = { duration: 80, frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] }
static lines = { duration: 130, frames: ['-', '\\', '|', '/'] }

#stream
#spinner
#client
#enabled = false

#frameIndex = 0
#lastUpdate = 0
#interval
#initialTimeout

constructor ({ stream }) {
this.#client = proggy.createClient({ normalize: true })
this.#stream = stream
}

load ({ enabled, unicode }) {
this.#enabled = enabled
this.#spinner = unicode ? Progress.dots : Progress.lines
this.#delayRender(500)
}

off () {
this.#clear()
}

pause () {
this.#clear({ clearLine: true })
}

#clear ({ clearLine } = {}) {
if (!this.#enabled) {
return
}
clearTimeout(this.#initialTimeout)
this.#initialTimeout = null
clearInterval(this.#interval)
this.#interval = null
this.#frameIndex = 0
this.#lastUpdate = 0
this.#stream.cursorTo(0)

Check failure on line 420 in lib/utils/display.js

View workflow job for this annotation

GitHub Actions / Test - Linux - 18.17.0

this[\#stream].cursorTo is not a function

Check failure on line 420 in lib/utils/display.js

View workflow job for this annotation

GitHub Actions / Test - Linux - 18.x

this[\#stream].cursorTo is not a function

Check failure on line 420 in lib/utils/display.js

View workflow job for this annotation

GitHub Actions / Test - Linux - 20.5.0

this[\#stream].cursorTo is not a function

Check failure on line 420 in lib/utils/display.js

View workflow job for this annotation

GitHub Actions / Test - Linux - 20.x

this[\#stream].cursorTo is not a function

Check failure on line 420 in lib/utils/display.js

View workflow job for this annotation

GitHub Actions / Test - macOS - 18.17.0

this[\#stream].cursorTo is not a function

Check failure on line 420 in lib/utils/display.js

View workflow job for this annotation

GitHub Actions / Test - macOS - 18.x

this[\#stream].cursorTo is not a function

Check failure on line 420 in lib/utils/display.js

View workflow job for this annotation

GitHub Actions / Test - macOS - 20.5.0

this[\#stream].cursorTo is not a function

Check failure on line 420 in lib/utils/display.js

View workflow job for this annotation

GitHub Actions / Test - macOS - 20.x

this[\#stream].cursorTo is not a function
if (clearLine) {
this.#stream.clearLine(1)
}
}

// PROGRESS
resume () {
this.#delayRender(10)
}

#startProgress ({ progress, unicode }) {
if (!progress || this.#silent) {
write (stream, str) {
if (!this.#enabled || !this.#interval) {
return stream.write(str)
}
this.#stream.cursorTo(0)
stream.write(str)
this.#render()
}

#delayRender (ms) {
this.#initialTimeout = setTimeout(() => {
this.#initialTimeout = null
this.#render()
}, ms)
this.#initialTimeout.unref()
}

#render () {
if (!this.#enabled || this.#initialTimeout) {
return
}
this.#progress = proggy.createClient({ normalize: true })
// TODO: implement proggy trackers in arborist/doctor
// TODO: listen to progress events here and build progress UI
// TODO: see deprecated gauge package for what unicode chars were used
this.#renderFrame(Date.now() - this.#lastUpdate >= this.#spinner.duration)
clearInterval(this.#interval)
this.#interval = setInterval(() => this.#renderFrame(true), this.#spinner.duration)
}

#renderFrame (next) {
if (next) {
this.#lastUpdate = Date.now()
this.#frameIndex++
if (this.#frameIndex >= this.#spinner.frames.length) {
this.#frameIndex = 0
}
}
this.#stream.cursorTo(0)
this.#stream.write(this.#spinner.frames[this.#frameIndex])
}
}

Expand Down
4 changes: 4 additions & 0 deletions lib/utils/input.js
@@ -0,0 +1,4 @@
const { input, output } = require('proc-log')

// Don't forget to clear the prompt line after getting user input
module.exports = (fn) => input.start(fn).finally(() => output.standard(''))
10 changes: 3 additions & 7 deletions lib/utils/open-url-prompt.js
@@ -1,5 +1,5 @@
const readline = require('readline')
const { output } = require('proc-log')
const { input, output } = require('proc-log')
const open = require('./open-url.js')

function print (npm, title, url) {
Expand Down Expand Up @@ -34,7 +34,7 @@ const promptOpen = async (npm, url, title, prompt, emitter) => {
output: process.stdout,
})

const tryOpen = await new Promise(resolve => {
const tryOpen = await input.start(() => new Promise(resolve => {
rl.on('SIGINT', () => {
rl.close()
resolve('SIGINT')
Expand All @@ -47,14 +47,10 @@ const promptOpen = async (npm, url, title, prompt, emitter) => {
if (emitter && emitter.addListener) {
emitter.addListener('abort', () => {
rl.close()

// clear the prompt line
output.standard('')

resolve(false)
})
}
})
}))

if (tryOpen === 'SIGINT') {
throw new Error('canceled')
Expand Down
12 changes: 7 additions & 5 deletions lib/utils/read-user-info.js
@@ -1,6 +1,6 @@
const { read } = require('read')
const userValidate = require('npm-user-validate')
const { log } = require('proc-log')
const { log, input } = require('proc-log')

exports.otp = readOTP
exports.password = readPassword
Expand All @@ -16,12 +16,14 @@ const passwordPrompt = 'npm password: '
const usernamePrompt = 'npm username: '
const emailPrompt = 'email (this IS public): '

const procLogRead = (...args) => input.start(() => read(...args))

function readOTP (msg = otpPrompt, otp, isRetry) {
if (isRetry && otp && /^[\d ]+$|^[A-Fa-f0-9]{64,64}$/.test(otp)) {
return otp.replace(/\s+/g, '')
}

return read({ prompt: msg, default: otp || '' })
return procLogRead({ prompt: msg, default: otp || '' })
.then((rOtp) => readOTP(msg, rOtp, true))
}

Expand All @@ -30,7 +32,7 @@ function readPassword (msg = passwordPrompt, password, isRetry) {
return password
}

return read({ prompt: msg, silent: true, default: password || '' })
return procLogRead({ prompt: msg, silent: true, default: password || '' })
.then((rPassword) => readPassword(msg, rPassword, true))
}

Expand All @@ -44,7 +46,7 @@ function readUsername (msg = usernamePrompt, username, isRetry) {
}
}

return read({ prompt: msg, default: username || '' })
return procLogRead({ prompt: msg, default: username || '' })
.then((rUsername) => readUsername(msg, rUsername, true))
}

Expand All @@ -58,6 +60,6 @@ function readEmail (msg = emailPrompt, email, isRetry) {
}
}

return read({ prompt: msg, default: email || '' })
return procLogRead({ prompt: msg, default: email || '' })
.then((username) => readEmail(msg, username, true))
}
1 change: 1 addition & 0 deletions tap-snapshots/test/lib/utils/open-url-prompt.js.test.cjs
Expand Up @@ -8,6 +8,7 @@
exports[`test/lib/utils/open-url-prompt.js TAP does not error when opener can not find command > Outputs extra Browser unavailable message and url 1`] = `
npm home:
https://www.npmjs.com
Browser unavailable. Please open the URL manually:
https://www.npmjs.com
`
Expand Down

0 comments on commit 8705cbf

Please sign in to comment.