simplywatch
Version:
Watches files and upon change executes a command for each file INDIVIDUALLY with file-related params
273 lines (206 loc) • 8.04 kB
text/coffeescript
Promise = require 'bluebird'
Path = require 'path'
ActionBuffer = require 'actionbuffer'
cliTruncate = require 'cli-truncate'
promiseBreak = require 'promise-break'
extend = require 'smart-extend'
symbols = require 'log-symbols'
stringify = require 'json-stringify-safe'
spinner = require('ora')()
uniq = require 'uniq'
chalk = require 'chalk'
globMatch = require 'micromatch'
CommandExecution = require './commandExec'
MATCH_BASE = matchBase:true
debug =
ignored: require('debug')('simplywatch:ignored')
tasklist: require('debug')('simplywatch:tasklist')
instance: require('debug')('simplywatch:instance')
class Queue
constructor: (@settings, @watchTask)->
debug.instance 'creating queue'
@watchPaths = @settings.globs.map((glob)-> Path.relative(process.cwd(),glob)).join(', ')
@cycles = @finalCycles = 0
@logHeader = "#{chalk.bgYellow.black 'Watching'} #{chalk.dim @watchPaths}"
@logBuffer = before:[], after:[], history:[]
@logUpdate = require('log-update').create(@settings.stdout)
@finalCommandSpinner = require('ora')(stream:@settings.stdout) if @settings.finalCommand
@buffer = new ActionBuffer (list)=>
@process uniq(list)
, @settings.bufferTimeout
@logRender()
add: (file, eventType, depStack=[])->
depStack.push(file) unless depStack.includes(file)
Promise.resolve().bind(@)
.then ()->
if file.commandExecution
file.commandExecution.cancel() if @settings.haltSerial
return file.commandExecution
.then ()->
logEvent = ()=>
notes = if not file.deps.length then '' else do ()->
depNames = file.deps.map((file)-> file.pathParams.base).join(', ')
return " [imported by #{depNames}]"
# notes += " (waiting for initial scan to complete)" if file.scanProcedure?.isPending()
@log chalk.bgGreen.bgGreen.black(eventType)+' '+chalk.dim(file.path+notes)
if eventType
logEvent() unless isIgnored=@isIgnored(file.filePath)
Promise.resolve(file.scanProcedure).bind(@)
.then ()-> file.deps.filter (depFile)=> not @isIgnored(depFile.filePath)
.then (fileDeps)->
if fileDeps.length is 0
@buffer.push(file) unless @isIgnored(file.filePath)
else
if isIgnored
logEvent()
else
@buffer.push(file) if @settings.processImports
for depFile in file.deps when not depStack.includes(depFile)
@add(depFile, null, depStack)
return
process: (list)->
debug.tasklist "prep #{list.length} files"
if @finalCommandPromise and not @finalCommandPromise.cancelled
@finalCommandPromise.cancelled = true
debug.tasklist 'final command - aborting previous'
@taskListPromise =
Promise.all([@taskListPromise, @finalCommandExecPromise]).bind(@)
.tap ()-> debug.tasklist "start #{list.length} files"
.return @settings.command
.then (command)->
@logStart()
@running = new ()->
for file in list
@[file.path] = file.commandExecution =
CommandExecution(command, file.path, file.pathParams)
return @
return list
.map((file)->
file.commandExecution.start()
, concurrency:@settings.concurrency)
.then ()->
finalOutput = []
for filePath,report of @running
fileOutput = ''
fileOutput += report.result.stdout if @isValidOutput(report.result.stdout)
fileOutput += report.result.stderr if @isValidOutput(report.result.stderr)
if report.status is 'cancel'
finalOutput.push "#{symbols.warning} #{chalk.dim filePath} (cancelled)"
if report.status is 'failure' and not fileOutput
fileOutput =
if report.result instanceof Error
report.result.message
else
try stringify(report.result) catch then String(report.result)
if fileOutput
fileOutput = cliTruncate(fileOutput, @settings.trim, position:'end') if @settings.trim
icon = if report.status is 'success' then symbols.success else symbols.error
finalOutput.push "#{icon} #{chalk.dim filePath}\n#{@formatOutput fileOutput}"
failedItems = Object.keys(@running).filter (item)=> @running[item].status is 'failure'
return [finalOutput, failedItems]
.spread (finalOutput, failedItems)->
debug.tasklist "end #{failedItems.length} failures"
@watchTask.emit 'cycle', ++@cycles
@logHeader = "#{chalk.bgYellow.black 'Watching'} #{chalk.dim @watchPaths} #{chalk.dim "(x#{@cycles})"}"
@logStop()
@logRender(finalOutput)
@logBuffer.history.push finalOutput... if finalOutput.length
@startFinalCommand failedItems.length if @settings.finalCommand
@running = false
startFinalCommand: (hasFailedTasks)->
if hasFailedTasks
setTimeout ()=>
@log "#{chalk.blue.bold 'Final Command'} #{chalk.red.bold 'Aborted'} #{chalk.dim '(because some tasks failed)'}"
else
@finalCommandPromise = thisPromise =
Promise.bind(@)
.delay @settings.finalCommandDelay
.then ()-> promiseBreak('aborted') if @running or @finalCommandPromise?.cancelled or thisPromise.cancelled
.then ()-> @executeFinalCommand()
.then ()-> @watchTask.emit 'finalCycle', ++@finalCycles
.catch promiseBreak.end
.then (reason)->
if reason is 'aborted' then debug.tasklist 'final command - aborted'
delete @finalCommandPromise if thisPromise is @finalCommandPromise
executeFinalCommand: ()->
debug.tasklist 'final command - running'
@finalCommandSpinner.start().text = "#{chalk.blue.bold('Final Command')}"
commandExec = CommandExecution(@settings.finalCommand)
Promise.bind(@)
.then ()-> @finalCommandExecPromise = commandExec.start()
.then (result)->
output = ''
output += result.stdout if @isValidOutput(result.stdout)
output += result.stderr if @isValidOutput(result.stderr)
output = result.stack if result instanceof Error and not output
output = cliTruncate(output, @settings.trim, position:'end') if @settings.trim
if output
status = if commandExec.status is 'success' then 'succeed' else 'fail'
@finalCommandSpinner[status] "#{@finalCommandSpinner.text}\n#{@formatOutput output}"
else
@finalCommandSpinner.stop()
debug.tasklist 'final command - finish'
isIgnored: (path)->
for glob in @settings.ignoreGlobs
if globMatch.isMatch(path, glob, MATCH_BASE) or
globMatch.isMatch(path, glob) or
globMatch.contains(path, glob)
debug.ignored path
return true
return false
isValidOutput: (output)->
output and
output isnt 'null' and
(
typeof output is 'string' and output.length >= 1 or
typeof output is 'object'
)
formatOutput: (output)->
output = output.replace /\n+$/, ''
return output+'\n'
log: (item)->
if @running
@logBuffer.after.push(item)
else
@logBuffer.before.push(item)
@logRender()
logStart: ()->
@logRender()
@logInterval = setInterval ()=>
@logRender()
, 50
logStop: ()->
clearInterval(@logInterval)
@logBuffer.before = @logBuffer.after
@logBuffer.after = []
@logUpdate.clear()
@logUpdate @logBuffer.before.join('\n') if @logBuffer.before.length
logRender: (output)->
if output
if @settings.retainHistory
output = @logBuffer.history.concat(output, @logHeader)
else
output.unshift @logHeader
else
output = if @settings.retainHistory then @logBuffer.history.slice() else []
output.push @logHeader
output.push @logBuffer.before...
if @running
pendingFrame = spinner.frame()
for filePath,report of @running
reason = if report.status is 'cancel' then ' (cancelled)' else ''
icon = switch report.status
when 'pending' then pendingFrame
when 'success' then symbols.success+' '
when 'failure' then symbols.error+' '
when 'cancel' then symbols.warning+' '
output.push "#{icon}#{chalk.dim filePath}#{reason}"
output.push @logBuffer.after...
@logUpdate output.join('\n')
stop: ()->
debug.instance 'destroying queue'
@logBuffer.before.length = 0
@logBuffer.after.length = 0
@logStop()
@buffer.stop()
module.exports = Queue