From dc4f705500f37bcfa3ec817bacafcf8287df71ec Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Fri, 5 Oct 2018 10:27:50 -0700 Subject: [PATCH] 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 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. --- main-command/src/main/scala/sbt/Watched.scala | 73 ++++++++++++------- .../src/test/scala/sbt/WatchedSpec.scala | 20 +++-- main/src/main/scala/sbt/Keys.scala | 4 +- 3 files changed, 59 insertions(+), 38 deletions(-) diff --git a/main-command/src/main/scala/sbt/Watched.scala b/main-command/src/main/scala/sbt/Watched.scala index 9f8dc25a6..0e9bc670e 100644 --- a/main-command/src/main/scala/sbt/Watched.scala +++ b/main-command/src/main/scala/sbt/Watched.scala @@ -7,7 +7,7 @@ package sbt -import java.io.File +import java.io.{ File, InputStream } import java.nio.file.FileSystems import sbt.BasicCommandStrings.{ @@ -20,9 +20,9 @@ import sbt.BasicCommands.otherCommandParser import sbt.internal.LegacyWatched import sbt.internal.inc.Stamper import sbt.internal.io.{ EventMonitor, Source, WatchState } -import sbt.internal.util.AttributeKey import sbt.internal.util.Types.const import sbt.internal.util.complete.DefaultParsers +import sbt.internal.util.{ AttributeKey, JLine } import sbt.io.FileEventMonitor.Event import sbt.io._ import sbt.util.{ Level, Logger } @@ -108,25 +108,44 @@ object Watched { type WatchSource = Source def terminateWatch(key: Int): Boolean = Watched.isEnter(key) - /* - * Without jline, checking for enter is nearly pointless because System.in.available will not - * return a non-zero value until the user presses enter. - */ - @tailrec - final def shouldTerminate: Boolean = - (System.in.available > 0) && (terminateWatch(System.in.read()) || shouldTerminate) - final val handleInput: () => Action = () => if (shouldTerminate) CancelWatch else Ignore - val defaultStartWatch: Int => Option[String] = count => - Some(s"$count. Waiting for source changes... (press enter to interrupt)") + + private def withCharBufferedStdIn[R](f: InputStream => R): R = JLine.usingTerminal { terminal => + 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 + def scanInput(): Action = { + if (in.available > 0) { + in.read() match { + case key if isEnter(key) => CancelWatch + 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") val defaultWatchingMessage: WatchState => String = ws => defaultStartWatch(ws.count).get def projectWatchingMessage(projectId: String): WatchState => String = ws => projectOnWatchMessage(projectId)(ws.count).get def projectOnWatchMessage(project: String): Int => Option[String] = - count => - Some( - s"$count. Waiting for source changes in project $project... (press enter to interrupt)" - ) + count => Some(s"$count. ${waitMessage(s" in project $project")}") val defaultOnTriggerMessage: Int => Option[String] = _ => None @deprecated( @@ -182,6 +201,7 @@ object Watched { val PollDelay: FiniteDuration = 500.milliseconds val AntiEntropy: FiniteDuration = 40.milliseconds 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) private type RunCommand = () => State @@ -231,7 +251,7 @@ object Watched { state: State, command: String, setup: WatchSetup, - ): State = { + ): State = withCharBufferedStdIn { in => val (s0, config, newState) = setup(state, command) val failureCommandName = "SbtContinuousWatchOnFail" val onFail = Command.command(failureCommandName)(identity) @@ -263,7 +283,7 @@ object Watched { case (status, Right(t)) => if (status.getOrElse(true)) t() else status case _ => throw new IllegalStateException("Should be unreachable") } - val terminationAction = watch(task, config) + val terminationAction = watch(in, task, config) config.onWatchTerminated(terminationAction, command, state) } else { config.logger.error( @@ -274,6 +294,7 @@ object Watched { } private[sbt] def watch( + in: InputStream, task: () => Either[Exception, Boolean], config: WatchConfig ): Action = { @@ -284,7 +305,7 @@ object Watched { def impl(count: Int): Action = { @tailrec def nextAction(): Action = { - config.handleInput() match { + config.handleInput(in) match { case action @ (CancelWatch | HandleError | Reload | _: Custom) => action case Trigger => Trigger case _ => @@ -348,12 +369,8 @@ object Watched { HandleError } } - try { - impl(count = 1) - } finally { - config.fileEventMonitor.close() - while (System.in.available() > 0) System.in.read() - } + try impl(count = 1) + finally config.fileEventMonitor.close() } @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. * @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. @@ -496,7 +513,7 @@ object WatchConfig { def default( logger: Logger, fileEventMonitor: FileEventMonitor[StampedFile], - handleInput: () => Watched.Action, + handleInput: InputStream => Watched.Action, preWatch: (Int, Boolean) => Watched.Action, onWatchEvent: Event[StampedFile] => Watched.Action, onWatchTerminated: (Watched.Action, String, State) => State, @@ -514,7 +531,7 @@ object WatchConfig { new WatchConfig { override def logger: Logger = l 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 = pw(count, lastResult) override def onWatchEvent(event: Event[StampedFile]): Watched.Action = owe(event) diff --git a/main-command/src/test/scala/sbt/WatchedSpec.scala b/main-command/src/test/scala/sbt/WatchedSpec.scala index 9d66b8f84..70d386cab 100644 --- a/main-command/src/test/scala/sbt/WatchedSpec.scala +++ b/main-command/src/test/scala/sbt/WatchedSpec.scala @@ -7,7 +7,7 @@ package sbt -import java.io.File +import java.io.{ File, InputStream } import java.nio.file.Files import java.util.concurrent.atomic.AtomicBoolean @@ -28,7 +28,7 @@ class WatchedSpec extends FlatSpec with Matchers { sources: Seq[WatchSource], fileEventMonitor: Option[FileEventMonitor[StampedFile]] = None, logger: Logger = NullLogger, - handleInput: () => Action = () => Ignore, + handleInput: InputStream => Action = _ => Ignore, preWatch: (Int, Boolean) => Action = (_, _) => CancelWatch, onWatchEvent: Event[StampedFile] => Action = _ => Ignore, 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 => 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 => val triggered = new AtomicBoolean(false) @@ -63,7 +67,7 @@ class WatchedSpec extends FlatSpec with Matchers { new File(dir, "file").createNewFile; None } ) - Watched.watch(() => Right(true), config) shouldBe CancelWatch + Watched.watch(NullInputStream, () => Right(true), config) shouldBe CancelWatch assert(triggered.get()) } it should "filter events" in IO.withTemporaryDirectory { dir => @@ -78,7 +82,7 @@ class WatchedSpec extends FlatSpec with Matchers { triggeredMessage = (tp, _) => { queue += tp; 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) } it should "enforce anti-entropy" in IO.withTemporaryDirectory { dir => @@ -102,7 +106,7 @@ class WatchedSpec extends FlatSpec with Matchers { None } ) - Watched.watch(() => Right(true), config) shouldBe CancelWatch + Watched.watch(NullInputStream, () => Right(true), config) shouldBe CancelWatch queue.toIndexedSeq.map(_.getPath) shouldBe Seq(bar, foo) } it should "halt on error" in IO.withTemporaryDirectory { dir => @@ -111,7 +115,7 @@ class WatchedSpec extends FlatSpec with Matchers { sources = Seq(WatchSource(dir.toRealPath)), 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()) } it should "reload" in IO.withTemporaryDirectory { dir => @@ -121,7 +125,7 @@ class WatchedSpec extends FlatSpec with Matchers { onWatchEvent = _ => Reload, watchingMessage = _ => { new File(dir, "file").createNewFile(); None } ) - Watched.watch(() => Right(true), config) shouldBe Reload + Watched.watch(NullInputStream, () => Right(true), config) shouldBe Reload } } diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 9970f037b..fcd817bc3 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -7,7 +7,7 @@ package sbt -import java.io.File +import java.io.{ File, InputStream } import java.net.URL import scala.concurrent.duration.{ FiniteDuration, Duration } 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 watchConfig = taskKey[WatchConfig]("The configuration for continuous execution.").withRank(BMinusSetting) 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 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)