Rework watch options

In this commit, I both restore some sbt 1.2.8 behavior and enhance the
api for setting keyboard shortcuts in watch. I change the default start
message to just show the watch count, the tasks that are being monitored
and, on a new line, the instructions to terminate the watch or show more
options.

Here's what it looks like:
[info] 1. Monitoring source files for spark/compile...
[info]    Press <enter> to interrupt or '?' for more options.
?
[info] Options:
[info]   <enter>  : interrupt (exits sbt in batch mode)
[info]   <ctrl-d> : interrupt (exits sbt in batch mode)
[info]   'r'      : re-run the command
[info]   's'      : return to shell
[info]   'q'      : quit sbt
[info]   '?'      : print options

I also made it so that the new options can be added (and old options
removed) with the watchInputOptions key. For example, to add an option
to reload the build with the key 'l', you could add
ThisBuild / watchInputOptions += Watch.InputOption('l', "reload", Watch.Reload)
to your global build.sbt.

After adding that to my global ~/sbt/1.0/global.sbt file, the output of
'?' became:
[info] Options:
[info]   <ctrl-d> : interrupt (exits sbt in batch mode)
[info]   <enter>  : interrupt (exits sbt in batch mode)
[info]   '?'      : print options
[info]   'l'      : reload
[info]   'q'      : quit sbt
[info]   'r'      : re-run the command
[info]   's'      : return to shell
This commit is contained in:
Ethan Atkins 2019-07-12 12:21:11 -07:00
parent 680659210f
commit 263f00f3b2
3 changed files with 170 additions and 49 deletions

View File

@ -28,7 +28,7 @@ import sbt.internal.util.complete.{ Parser, Parsers }
import sbt.internal.util.{ AttributeKey, JLine, Util }
import sbt.nio.FileStamper.LastModified
import sbt.nio.Keys.{ fileInputs, _ }
import sbt.nio.Watch.{ Creation, Deletion, Update }
import sbt.nio.Watch.{ Creation, Deletion, ShowOptions, Update }
import sbt.nio.file.FileAttributes
import sbt.nio.{ FileStamp, FileStamper, Watch }
import sbt.util.{ Level, _ }
@ -468,11 +468,13 @@ private[sbt] object Continuous extends DeprecatedContinuous {
val beforeCommand = () => configs.foreach(_.watchSettings.beforeCommand())
val onStart: () => Watch.Action =
getOnStart(project, commands, configs, rawLogger, count, extracted)
val nextInputEvent: () => Watch.Action = parseInputEvents(configs, state, inputStream, logger)
val (message, parser, altParser) = getWatchInputOptions(configs, extracted)
val nextInputEvent: () => Watch.Action =
parseInputEvents(parser, altParser, state, inputStream, logger)
val (nextFileEvent, cleanupFileMonitor): (() => Option[(Watch.Event, Watch.Action)], () => Unit) =
getFileEvents(configs, rawLogger, state, count, commands, fileStampCache)
val nextEvent: () => Watch.Action =
combineInputAndFileEvents(nextInputEvent, nextFileEvent, logger)
combineInputAndFileEvents(nextInputEvent, nextFileEvent, message, logger, rawLogger)
val onExit = () => cleanupFileMonitor()
val onTermination = getOnTermination(configs, isCommand)
new Callbacks(nextEvent, beforeCommand, onExit, onStart, onTermination)
@ -494,6 +496,38 @@ private[sbt] object Continuous extends DeprecatedContinuous {
}
}
private def getWatchInputOptions(
configs: Seq[Config],
extracted: Extracted
): (String, Parser[Watch.Action], Option[(TaskKey[InputStream], InputStream => Watch.Action)]) = {
configs match {
case Seq(h) =>
val settings = h.watchSettings
val parser = settings.inputParser
val alt = settings.inputStream.map { k =>
k -> settings.inputHandler.getOrElse(defaultInputHandler(parser))
}
(settings.inputOptionsMessage, parser, alt)
case _ =>
val options =
extracted.getOpt(watchInputOptions in ThisBuild).getOrElse(Watch.defaultInputOptions)
val message = extracted
.getOpt(watchInputOptionsMessage in ThisBuild)
.getOrElse(Watch.defaultInputOptionsMessage(options))
val parser = extracted
.getOpt(watchInputParser in ThisBuild)
.getOrElse(Watch.defaultInputParser(options))
val alt = extracted
.getOpt(watchInputStream in ThisBuild)
.map { _ =>
(watchInputStream in ThisBuild) -> extracted
.getOpt(watchInputHandler in ThisBuild)
.getOrElse(defaultInputHandler(parser))
}
(message, parser, alt)
}
}
private def getOnStart(
project: ProjectRef,
commands: Seq[String],
@ -708,7 +742,7 @@ private[sbt] object Continuous extends DeprecatedContinuous {
}
logger.debug(s"Received file event actions: $builder. Returning: $min")
if (min._2 == Watch.Trigger) onTrigger(min._1)
Some(min)
if (min._2 == Watch.ShowOptions) None else Some(min)
} else None
}, () => monitor.close())
}
@ -729,7 +763,8 @@ private[sbt] object Continuous extends DeprecatedContinuous {
* `() => Seq[Watch.Action]` which avoids actually exposing the InputStream anywhere.
*/
private def parseInputEvents(
configs: Seq[Config],
parser: Parser[Watch.Action],
alternative: Option[(TaskKey[InputStream], InputStream => Watch.Action)],
state: State,
inputStream: InputStream,
logger: Logger
@ -751,10 +786,9 @@ private[sbt] object Continuous extends DeprecatedContinuous {
// If the Config.watchSettings.inputStream is set, the same process is applied except that
// instead of passing in the wrapped InputStream for the input string, we directly pass
// in the inputStream provided by Config.watchSettings.inputStream.
val inputHandlers: Seq[ActionParser] = configs.map { c =>
val inputHandler: String => Watch.Action = {
val any = Parsers.any.*
val inputParser = c.watchSettings.inputParser
val parser = any ~> inputParser ~ matched(any)
val fullParser = any ~> parser ~ matched(any)
// Each parser gets its own copy of System.in that it can modify while parsing.
val systemInBuilder = new StringBuilder
@ -762,34 +796,33 @@ private[sbt] object Continuous extends DeprecatedContinuous {
// This string is provided in the closure below by reading from System.in
val default: String => Watch.Action =
string => parse(inputStream(string), systemInBuilder, parser)
val alternative = c.watchSettings.inputStream
.map { inputStreamKey =>
val is = extracted.runTask(inputStreamKey, state)._2
val handler = c.watchSettings.inputHandler.getOrElse(defaultInputHandler(inputParser))
() => handler(is)
string => parse(inputStream(string), systemInBuilder, fullParser)
val alt = alternative
.map {
case (key, handler) =>
val is = extracted.runTask(key, state)._2
() => handler(is)
}
.getOrElse(() => Watch.Ignore)
(string: String) => (default(string) :: alternative() :: Nil).min
string: String => (default(string) :: alt() :: Nil).min
}
() => {
val stringBuilder = new StringBuilder
while (inputStream.available > 0) stringBuilder += inputStream.read().toChar
val newBytes = stringBuilder.toString
val parse: ActionParser => Watch.Action = parser => parser(newBytes)
val allEvents = inputHandlers.map(parse).filterNot(_ == Watch.Ignore)
if (allEvents.exists(_ != Watch.Ignore)) {
val res = allEvents.min
logger.debug(s"Received input events: ${allEvents mkString ","}. Taking $res")
res
} else Watch.Ignore
val event = parse(inputHandler)
if (event != Watch.Ignore) logger.debug(s"Received input event: $event.")
event
}
}
private def combineInputAndFileEvents(
nextInputAction: () => Watch.Action,
nextFileEvent: () => Option[(Watch.Event, Watch.Action)],
logger: Logger
options: String,
logger: Logger,
rawLogger: Logger
): () => Watch.Action = () => {
val (inputAction: Watch.Action, fileEvent: Option[(Watch.Event, Watch.Action)] @unchecked) =
Seq(nextInputAction, nextFileEvent).map(_.apply()).toIndexedSeq match {
@ -808,7 +841,13 @@ private[sbt] object Continuous extends DeprecatedContinuous {
(if (action != min) s" Dropping in favor of input event: $min" else "")
}
.foreach(logger.debug(_))
min
min match {
case ShowOptions =>
println("") // This is so the [info] tag appears at the head of a newline
rawLogger.info(options)
Watch.Ignore
case m => m
}
}
@tailrec
@ -896,8 +935,12 @@ private[sbt] object Continuous extends DeprecatedContinuous {
val deletionQuarantinePeriod: FiniteDuration =
key.get(watchDeletionQuarantinePeriod).getOrElse(Watch.defaultDeletionQuarantinePeriod)
val inputHandler: Option[InputStream => Watch.Action] = key.get(watchInputHandler)
val inputOptions: Seq[Watch.InputOption] =
key.get(watchInputOptions).getOrElse(Watch.defaultInputOptions)
val inputOptionsMessage: String =
key.get(watchInputOptionsMessage).getOrElse(Watch.defaultInputOptionsMessage(inputOptions))
val inputParser: Parser[Watch.Action] =
key.get(watchInputParser).getOrElse(Watch.defaultInputParser)
key.get(watchInputParser).getOrElse(Watch.defaultInputParser(inputOptions))
val logLevel: Level.Value = key.get(watchLogLevel).getOrElse(Level.Info)
val beforeCommand: () => Unit = key.get(watchBeforeCommand).getOrElse(() => {})
val onFileInputEvent: WatchOnEvent =

View File

@ -76,6 +76,8 @@ object Keys {
private[sbt] val watchInputHandler = settingKey[InputStream => Watch.Action](
"Function that is periodically invoked to determine if the continuous build should be stopped or if a build should be triggered. It will usually read from stdin to respond to user commands. This is only invoked if watchInputStream is set."
).withRank(DSetting)
val watchInputOptionsMessage = settingKey[String]("The help message for the watch input options")
val watchInputOptions = settingKey[Seq[Watch.InputOption]]("The available input options")
val watchInputStream = taskKey[InputStream](
"The input stream to read for user input events. This will usually be System.in"
).withRank(DSetting)

View File

@ -25,6 +25,7 @@ import sbt.nio.file.FileAttributes
import sbt.util.{ Level, Logger }
import scala.annotation.tailrec
import scala.collection.mutable
import scala.concurrent.duration._
import scala.util.control.NonFatal
@ -164,8 +165,9 @@ object Watch {
*/
implicit object ordering extends Ordering[ContinueWatch] {
override def compare(left: ContinueWatch, right: ContinueWatch): Int = left match {
case Ignore => if (right == Ignore) 0 else 1
case Trigger => if (right == Trigger) 0 else -1
case ShowOptions => if (right == ShowOptions) 0 else -1
case Ignore => if (right == Ignore) 0 else 1
case Trigger => if (right == Trigger) 0 else if (right == ShowOptions) 1 else -1
}
}
}
@ -236,11 +238,24 @@ object Watch {
override def toString: String = s"HandleUnexpectedError($throwable)"
}
/**
* Action that indicates that the watch should continue as though nothing happened. This may be
* because, for example, no user input was yet available. This trait can be used when we don't
* want to take action but we do want to perform a side effect like printing options to the
* terminal.
*/
sealed trait Ignore extends ContinueWatch
/**
* Action that indicates that the available options should be printed.
*/
case object ShowOptions extends Ignore
/**
* Action that indicates that the watch should continue as though nothing happened. This may be
* because, for example, no user input was yet available.
*/
case object Ignore extends ContinueWatch
case object Ignore extends Ignore
/**
* Action that indicates that the watch should pause while the build is reloaded. This is used to
@ -258,7 +273,8 @@ object Watch {
}
// For now leave this private in case this isn't the best unapply type signature since it can't
// be evolved in a binary compatible way.
private object Run {
object Run {
def apply(commands: String*): Run = new Watch.Run(commands: _*)
def unapply(r: Run): Option[List[Exec]] = Some(r.commands.toList.map(Exec(_, None)))
}
@ -274,6 +290,45 @@ object Watch {
*/
trait Custom extends CancelWatch
trait InputOption {
private[sbt] def parser: Parser[Watch.Action]
def input: String
def display: String
def description: String
override def toString: String =
s"InputOption(input = $input, display = $display, description = $description)"
}
object InputOption {
private class impl(
override val input: String,
override val display: String,
override val description: String,
action: Action
) extends InputOption {
override private[sbt] def parser: Parser[Watch.Action] = input ^^^ action
}
def apply(key: Char, description: String, action: Action): InputOption =
new impl(key.toString, s"'$key'", description, action)
def apply(key: Char, display: String, description: String, action: Action): InputOption =
new impl(key.toString, display, description, action)
def apply(
display: String,
description: String,
action: Action,
chars: Char*
): InputOption =
new impl(chars.mkString("|"), display, description, action) {
override private[sbt] def parser: Parser[Watch.Action] = chars match {
case Seq(c) => c ^^^ action
case Seq(h, rest @ _*) => rest.foldLeft(h: Parser[Char])(_ | _) ^^^ action
}
}
def apply(input: String, description: String, action: Action): InputOption =
new impl(input, s"'$input'", description, action)
def apply(input: String, display: String, description: String, action: Action): InputOption =
new impl(input, display, description, action)
}
private type NextAction = () => Watch.Action
/**
@ -385,27 +440,44 @@ object Watch {
* 2) 'r' or 'R' will trigger a build
* 3) new line characters cancel the watch and return to the shell
*/
final val defaultInputParser: Parser[Action] = {
val exitParser: Parser[Action] = chars("xX") ^^^ new Run("exit")
val rebuildParser: Parser[Action] = chars("rR") ^^^ Trigger
val cancelParser: Parser[Action] = chars(legal = "\n\r") ^^^ new Run("iflast shell")
exitParser | rebuildParser | cancelParser
}
final def defaultInputParser(options: Seq[Watch.InputOption]): Parser[Action] =
distinctOptions(options) match {
case Seq() => (('\n': Parser[Char]) | '\r' | 4.toChar) ^^^ Run("")
case Seq(h, rest @ _*) => rest.foldLeft(h.parser)(_ | _.parser)
}
final val defaultInputOptions: Seq[Watch.InputOption] = Seq(
Watch.InputOption("<enter>", "interrupt (exits sbt in batch mode)", Run(""), '\n', '\r'),
Watch.InputOption(4.toChar, "<ctrl-d>", "interrupt (exits sbt in batch mode)", Run("")),
Watch.InputOption('r', "re-run the command", Trigger),
Watch.InputOption('s', "return to shell", Run("iflast shell")),
Watch.InputOption('q', "quit sbt", Run("exit")),
Watch.InputOption('?', "print options", ShowOptions)
)
private[this] val options = {
val enter = "<enter>"
val opts = Seq(
s"$enter: return to the shell",
s"'r': repeat the current command",
s"'x': exit sbt"
)
s"Options:\n${opts.mkString(" ", "\n ", "")}"
def defaultInputOptionsMessage(options: Seq[InputOption]): String = {
val opts = distinctOptions(options).sortBy(_.input)
val alignmentLength = opts.map(_.display.length).max + 1
val formatted =
opts.map(o => s"${o.display}${" " * (alignmentLength - o.display.length)}: ${o.description}")
s"Options:\n${formatted.mkString(" ", "\n ", "")}"
}
private def waitMessage(project: ProjectRef, commands: Seq[String]): String = {
val plural = if (commands.size > 1) "s" else ""
val cmds = commands.mkString("; ")
s"Monitoring source files for updates...\n" +
s"Project: ${project.project}\nCommand$plural: $cmds\n$options"
private def distinctOptions(options: Seq[InputOption]): Seq[InputOption] = {
val distinctOpts = mutable.Set.empty[String]
val opts = new mutable.ArrayBuffer[InputOption]
(options match {
case Seq() => defaultInputOptions.headOption.toSeq
case s => s
}).reverse.foreach { o =>
if (distinctOpts.add(o.input)) opts += o
}
opts.reverse
}
private def waitMessage(project: ProjectRef, commands: Seq[String]): Seq[String] = {
val cmds = commands.map(project.project + "/" + _.trim).mkString("; ")
Seq(
s"Monitoring source files for $cmds...",
s"Press <enter> to interrupt or '?' for more options."
)
}
/**
@ -414,13 +486,16 @@ object Watch {
*/
val defaultStartWatch: (Int, ProjectRef, Seq[String]) => Option[String] = {
(count: Int, project: ProjectRef, commands: Seq[String]) =>
Some(s"$count. ${waitMessage(project, commands)}")
{
val countStr = s"$count. "
Some(s"$countStr${waitMessage(project, commands).mkString(s"\n${" " * countStr.length}")}")
}
}.label("Watched.defaultStartWatch")
/**
* Default no-op callback.
*/
val defaultBeforeCommand: () => Unit = () => {}
val defaultBeforeCommand: () => Unit = (() => {}).label("Watch.defaultBeforeCommand")
private[sbt] val defaultCommandOnTermination: (Action, String, Int, State) => State =
onTerminationImpl(ContinuousExecutePrefix).label("Watched.defaultCommandOnTermination")
@ -495,6 +570,7 @@ object Watch {
watchOnFileInputEvent :== Watch.trigger,
watchDeletionQuarantinePeriod :== Watch.defaultDeletionQuarantinePeriod,
sbt.Keys.watchService :== Watched.newWatchService,
watchInputOptions :== Watch.defaultInputOptions,
watchStartMessage :== Watch.defaultStartWatch,
watchTasks := Continuous.continuousTask.evaluated,
sbt.Keys.aggregate in watchTasks :== false,