Add support to rebuild a '~' task by pressing 'r'

Sometimes a user may want to rerun their task even if the source files
haven't changed. Presently this is a little annoying because you have to
hit enter to stop the build and then up arrow or <ctrl+r> plus enter to
rebuild. It's more convenient to just be able to press the 'r' key to
re-run the task.

To implement this, I had to make the watch task set up a jline terminal
so that System.in would be character buffered instead of line buffered.
Furthermore, I took advantage of the NonBlockingInputStream
implementation provided by jline to wrap System.in. This was necessary
because even with the jline terminal, System.in.available doesn't return
> 0 until a newline character is entered. Instead, the
NonBlockingInputStream does provide a peek api with a timeout that will
return the next unread key off of System.in if there is one available.
This can be use to proxy available in the WrappedNonBlockingInputStream.

To ensure maximum user flexibility, I also update the watchHandleInput Key to
take an InputStream and return an Action. This setting will now receive
the wrapped System.in, which will allow the user to create their own
keybindings for watch actions without needing to use jline themselves.

Future work might make it more straightforward to go back to a line
buffered input if that is what the user desires.
This commit is contained in:
Ethan Atkins 2018-10-05 10:27:50 -07:00
parent 25e97f99f5
commit dc4f705500
3 changed files with 59 additions and 38 deletions

View File

@ -7,7 +7,7 @@
package sbt package sbt
import java.io.File import java.io.{ File, InputStream }
import java.nio.file.FileSystems import java.nio.file.FileSystems
import sbt.BasicCommandStrings.{ import sbt.BasicCommandStrings.{
@ -20,9 +20,9 @@ import sbt.BasicCommands.otherCommandParser
import sbt.internal.LegacyWatched import sbt.internal.LegacyWatched
import sbt.internal.inc.Stamper import sbt.internal.inc.Stamper
import sbt.internal.io.{ EventMonitor, Source, WatchState } import sbt.internal.io.{ EventMonitor, Source, WatchState }
import sbt.internal.util.AttributeKey
import sbt.internal.util.Types.const import sbt.internal.util.Types.const
import sbt.internal.util.complete.DefaultParsers import sbt.internal.util.complete.DefaultParsers
import sbt.internal.util.{ AttributeKey, JLine }
import sbt.io.FileEventMonitor.Event import sbt.io.FileEventMonitor.Event
import sbt.io._ import sbt.io._
import sbt.util.{ Level, Logger } import sbt.util.{ Level, Logger }
@ -108,25 +108,44 @@ object Watched {
type WatchSource = Source type WatchSource = Source
def terminateWatch(key: Int): Boolean = Watched.isEnter(key) def terminateWatch(key: Int): Boolean = Watched.isEnter(key)
/*
* Without jline, checking for enter is nearly pointless because System.in.available will not private def withCharBufferedStdIn[R](f: InputStream => R): R = JLine.usingTerminal { terminal =>
* return a non-zero value until the user presses enter. val in = terminal.wrapInIfNeeded(System.in)
*/ try {
while (in.available > 0) in.read()
terminal.init()
f(in)
} finally {
while (in.available > 0) in.read()
terminal.reset()
}
}
private[sbt] final val handleInput: InputStream => Action = in => {
@tailrec @tailrec
final def shouldTerminate: Boolean = def scanInput(): Action = {
(System.in.available > 0) && (terminateWatch(System.in.read()) || shouldTerminate) if (in.available > 0) {
final val handleInput: () => Action = () => if (shouldTerminate) CancelWatch else Ignore in.read() match {
val defaultStartWatch: Int => Option[String] = count => case key if isEnter(key) => CancelWatch
Some(s"$count. Waiting for source changes... (press enter to interrupt)") case key if isR(key) => Trigger
case key if key >= 0 => scanInput()
case _ => Ignore
}
} else {
Ignore
}
}
scanInput()
}
private def waitMessage(project: String): String =
s"Waiting for source changes$project... (press enter to interrupt or 'r' to re-run the command)"
val defaultStartWatch: Int => Option[String] = count => Some(s"$count. ${waitMessage("")}")
@deprecated("Use defaultStartWatch in conjunction with the watchStartMessage key", "1.3.0") @deprecated("Use defaultStartWatch in conjunction with the watchStartMessage key", "1.3.0")
val defaultWatchingMessage: WatchState => String = ws => defaultStartWatch(ws.count).get val defaultWatchingMessage: WatchState => String = ws => defaultStartWatch(ws.count).get
def projectWatchingMessage(projectId: String): WatchState => String = def projectWatchingMessage(projectId: String): WatchState => String =
ws => projectOnWatchMessage(projectId)(ws.count).get ws => projectOnWatchMessage(projectId)(ws.count).get
def projectOnWatchMessage(project: String): Int => Option[String] = def projectOnWatchMessage(project: String): Int => Option[String] =
count => count => Some(s"$count. ${waitMessage(s" in project $project")}")
Some(
s"$count. Waiting for source changes in project $project... (press enter to interrupt)"
)
val defaultOnTriggerMessage: Int => Option[String] = _ => None val defaultOnTriggerMessage: Int => Option[String] = _ => None
@deprecated( @deprecated(
@ -182,6 +201,7 @@ object Watched {
val PollDelay: FiniteDuration = 500.milliseconds val PollDelay: FiniteDuration = 500.milliseconds
val AntiEntropy: FiniteDuration = 40.milliseconds val AntiEntropy: FiniteDuration = 40.milliseconds
def isEnter(key: Int): Boolean = key == 10 || key == 13 def isEnter(key: Int): Boolean = key == 10 || key == 13
def isR(key: Int): Boolean = key == 82 || key == 114
def printIfDefined(msg: String): Unit = if (!msg.isEmpty) System.out.println(msg) def printIfDefined(msg: String): Unit = if (!msg.isEmpty) System.out.println(msg)
private type RunCommand = () => State private type RunCommand = () => State
@ -231,7 +251,7 @@ object Watched {
state: State, state: State,
command: String, command: String,
setup: WatchSetup, setup: WatchSetup,
): State = { ): State = withCharBufferedStdIn { in =>
val (s0, config, newState) = setup(state, command) val (s0, config, newState) = setup(state, command)
val failureCommandName = "SbtContinuousWatchOnFail" val failureCommandName = "SbtContinuousWatchOnFail"
val onFail = Command.command(failureCommandName)(identity) val onFail = Command.command(failureCommandName)(identity)
@ -263,7 +283,7 @@ object Watched {
case (status, Right(t)) => if (status.getOrElse(true)) t() else status case (status, Right(t)) => if (status.getOrElse(true)) t() else status
case _ => throw new IllegalStateException("Should be unreachable") case _ => throw new IllegalStateException("Should be unreachable")
} }
val terminationAction = watch(task, config) val terminationAction = watch(in, task, config)
config.onWatchTerminated(terminationAction, command, state) config.onWatchTerminated(terminationAction, command, state)
} else { } else {
config.logger.error( config.logger.error(
@ -274,6 +294,7 @@ object Watched {
} }
private[sbt] def watch( private[sbt] def watch(
in: InputStream,
task: () => Either[Exception, Boolean], task: () => Either[Exception, Boolean],
config: WatchConfig config: WatchConfig
): Action = { ): Action = {
@ -284,7 +305,7 @@ object Watched {
def impl(count: Int): Action = { def impl(count: Int): Action = {
@tailrec @tailrec
def nextAction(): Action = { def nextAction(): Action = {
config.handleInput() match { config.handleInput(in) match {
case action @ (CancelWatch | HandleError | Reload | _: Custom) => action case action @ (CancelWatch | HandleError | Reload | _: Custom) => action
case Trigger => Trigger case Trigger => Trigger
case _ => case _ =>
@ -348,12 +369,8 @@ object Watched {
HandleError HandleError
} }
} }
try { try impl(count = 1)
impl(count = 1) finally config.fileEventMonitor.close()
} finally {
config.fileEventMonitor.close()
while (System.in.available() > 0) System.in.read()
}
} }
@deprecated("Replaced by Watched.command", "1.3.0") @deprecated("Replaced by Watched.command", "1.3.0")
@ -423,7 +440,7 @@ trait WatchConfig {
* trigger. Usually this will read from System.in to react to user input. * trigger. Usually this will read from System.in to react to user input.
* @return an [[Watched.Action Action]] that will determine the next step in the watch. * @return an [[Watched.Action Action]] that will determine the next step in the watch.
*/ */
def handleInput(): Watched.Action def handleInput(inputStream: InputStream): Watched.Action
/** /**
* This is run before each watch iteration and if it returns true, the watch is terminated. * This is run before each watch iteration and if it returns true, the watch is terminated.
@ -496,7 +513,7 @@ object WatchConfig {
def default( def default(
logger: Logger, logger: Logger,
fileEventMonitor: FileEventMonitor[StampedFile], fileEventMonitor: FileEventMonitor[StampedFile],
handleInput: () => Watched.Action, handleInput: InputStream => Watched.Action,
preWatch: (Int, Boolean) => Watched.Action, preWatch: (Int, Boolean) => Watched.Action,
onWatchEvent: Event[StampedFile] => Watched.Action, onWatchEvent: Event[StampedFile] => Watched.Action,
onWatchTerminated: (Watched.Action, String, State) => State, onWatchTerminated: (Watched.Action, String, State) => State,
@ -514,7 +531,7 @@ object WatchConfig {
new WatchConfig { new WatchConfig {
override def logger: Logger = l override def logger: Logger = l
override def fileEventMonitor: FileEventMonitor[StampedFile] = fem override def fileEventMonitor: FileEventMonitor[StampedFile] = fem
override def handleInput(): Watched.Action = hi() override def handleInput(inputStream: InputStream): Watched.Action = hi(inputStream)
override def preWatch(count: Int, lastResult: Boolean): Watched.Action = override def preWatch(count: Int, lastResult: Boolean): Watched.Action =
pw(count, lastResult) pw(count, lastResult)
override def onWatchEvent(event: Event[StampedFile]): Watched.Action = owe(event) override def onWatchEvent(event: Event[StampedFile]): Watched.Action = owe(event)

View File

@ -7,7 +7,7 @@
package sbt package sbt
import java.io.File import java.io.{ File, InputStream }
import java.nio.file.Files import java.nio.file.Files
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@ -28,7 +28,7 @@ class WatchedSpec extends FlatSpec with Matchers {
sources: Seq[WatchSource], sources: Seq[WatchSource],
fileEventMonitor: Option[FileEventMonitor[StampedFile]] = None, fileEventMonitor: Option[FileEventMonitor[StampedFile]] = None,
logger: Logger = NullLogger, logger: Logger = NullLogger,
handleInput: () => Action = () => Ignore, handleInput: InputStream => Action = _ => Ignore,
preWatch: (Int, Boolean) => Action = (_, _) => CancelWatch, preWatch: (Int, Boolean) => Action = (_, _) => CancelWatch,
onWatchEvent: Event[StampedFile] => Action = _ => Ignore, onWatchEvent: Event[StampedFile] => Action = _ => Ignore,
triggeredMessage: (TypedPath, Int) => Option[String] = (_, _) => None, triggeredMessage: (TypedPath, Int) => Option[String] = (_, _) => None,
@ -49,9 +49,13 @@ class WatchedSpec extends FlatSpec with Matchers {
) )
} }
} }
object NullInputStream extends InputStream {
override def available(): Int = 0
override def read(): Int = -1
}
"Watched.watch" should "stop" in IO.withTemporaryDirectory { dir => "Watched.watch" should "stop" in IO.withTemporaryDirectory { dir =>
val config = Defaults.config(sources = Seq(WatchSource(dir.toRealPath))) val config = Defaults.config(sources = Seq(WatchSource(dir.toRealPath)))
Watched.watch(() => Right(true), config) shouldBe CancelWatch Watched.watch(NullInputStream, () => Right(true), config) shouldBe CancelWatch
} }
it should "trigger" in IO.withTemporaryDirectory { dir => it should "trigger" in IO.withTemporaryDirectory { dir =>
val triggered = new AtomicBoolean(false) val triggered = new AtomicBoolean(false)
@ -63,7 +67,7 @@ class WatchedSpec extends FlatSpec with Matchers {
new File(dir, "file").createNewFile; None new File(dir, "file").createNewFile; None
} }
) )
Watched.watch(() => Right(true), config) shouldBe CancelWatch Watched.watch(NullInputStream, () => Right(true), config) shouldBe CancelWatch
assert(triggered.get()) assert(triggered.get())
} }
it should "filter events" in IO.withTemporaryDirectory { dir => it should "filter events" in IO.withTemporaryDirectory { dir =>
@ -78,7 +82,7 @@ class WatchedSpec extends FlatSpec with Matchers {
triggeredMessage = (tp, _) => { queue += tp; None }, triggeredMessage = (tp, _) => { queue += tp; None },
watchingMessage = _ => { Files.createFile(bar); Thread.sleep(5); Files.createFile(foo); None } watchingMessage = _ => { Files.createFile(bar); Thread.sleep(5); Files.createFile(foo); None }
) )
Watched.watch(() => Right(true), config) shouldBe CancelWatch Watched.watch(NullInputStream, () => Right(true), config) shouldBe CancelWatch
queue.toIndexedSeq.map(_.getPath) shouldBe Seq(foo) queue.toIndexedSeq.map(_.getPath) shouldBe Seq(foo)
} }
it should "enforce anti-entropy" in IO.withTemporaryDirectory { dir => it should "enforce anti-entropy" in IO.withTemporaryDirectory { dir =>
@ -102,7 +106,7 @@ class WatchedSpec extends FlatSpec with Matchers {
None None
} }
) )
Watched.watch(() => Right(true), config) shouldBe CancelWatch Watched.watch(NullInputStream, () => Right(true), config) shouldBe CancelWatch
queue.toIndexedSeq.map(_.getPath) shouldBe Seq(bar, foo) queue.toIndexedSeq.map(_.getPath) shouldBe Seq(bar, foo)
} }
it should "halt on error" in IO.withTemporaryDirectory { dir => it should "halt on error" in IO.withTemporaryDirectory { dir =>
@ -111,7 +115,7 @@ class WatchedSpec extends FlatSpec with Matchers {
sources = Seq(WatchSource(dir.toRealPath)), sources = Seq(WatchSource(dir.toRealPath)),
preWatch = (_, lastStatus) => if (lastStatus) Ignore else { halted.set(true); HandleError } preWatch = (_, lastStatus) => if (lastStatus) Ignore else { halted.set(true); HandleError }
) )
Watched.watch(() => Right(false), config) shouldBe HandleError Watched.watch(NullInputStream, () => Right(false), config) shouldBe HandleError
assert(halted.get()) assert(halted.get())
} }
it should "reload" in IO.withTemporaryDirectory { dir => it should "reload" in IO.withTemporaryDirectory { dir =>
@ -121,7 +125,7 @@ class WatchedSpec extends FlatSpec with Matchers {
onWatchEvent = _ => Reload, onWatchEvent = _ => Reload,
watchingMessage = _ => { new File(dir, "file").createNewFile(); None } watchingMessage = _ => { new File(dir, "file").createNewFile(); None }
) )
Watched.watch(() => Right(true), config) shouldBe Reload Watched.watch(NullInputStream, () => Right(true), config) shouldBe Reload
} }
} }

View File

@ -7,7 +7,7 @@
package sbt package sbt
import java.io.File import java.io.{ File, InputStream }
import java.net.URL import java.net.URL
import scala.concurrent.duration.{ FiniteDuration, Duration } import scala.concurrent.duration.{ FiniteDuration, Duration }
import Def.ScopedKey import Def.ScopedKey
@ -152,7 +152,7 @@ object Keys {
val watchAntiEntropy = settingKey[FiniteDuration]("Duration for which the watch EventMonitor will ignore events for a file after that file has triggered a build.").withRank(BMinusSetting) val watchAntiEntropy = settingKey[FiniteDuration]("Duration for which the watch EventMonitor will ignore events for a file after that file has triggered a build.").withRank(BMinusSetting)
val watchConfig = taskKey[WatchConfig]("The configuration for continuous execution.").withRank(BMinusSetting) val watchConfig = taskKey[WatchConfig]("The configuration for continuous execution.").withRank(BMinusSetting)
val watchLogger = taskKey[Logger]("A logger that reports watch events.").withRank(DSetting) val watchLogger = taskKey[Logger]("A logger that reports watch events.").withRank(DSetting)
val watchHandleInput = settingKey[() => Watched.Action]("Function that is periodically invoked to determine if the continous build should be stopped or if a build should be triggered. It will usually read from stdin to respond to user commands.").withRank(BMinusSetting) val watchHandleInput = settingKey[InputStream => Watched.Action]("Function that is periodically invoked to determine if the continous build should be stopped or if a build should be triggered. It will usually read from stdin to respond to user commands.").withRank(BMinusSetting)
val watchOnEvent = taskKey[Event[StampedFile] => Watched.Action]("Determines how to handle a file event").withRank(BMinusSetting) val watchOnEvent = taskKey[Event[StampedFile] => Watched.Action]("Determines how to handle a file event").withRank(BMinusSetting)
val watchOnTermination = taskKey[(Watched.Action, String, State) => State]("Transforms the input state after the continuous build completes.").withRank(BMinusSetting) val watchOnTermination = taskKey[(Watched.Action, String, State) => State]("Transforms the input state after the continuous build completes.").withRank(BMinusSetting)
val watchService = settingKey[() => WatchService]("Service to use to monitor file system changes.").withRank(BMinusSetting) val watchService = settingKey[() => WatchService]("Service to use to monitor file system changes.").withRank(BMinusSetting)