diff --git a/internal/util-logging/src/main/scala/sbt/internal/util/JLine3.scala b/internal/util-logging/src/main/scala/sbt/internal/util/JLine3.scala index 6f34a39a1..ccb3fad79 100644 --- a/internal/util-logging/src/main/scala/sbt/internal/util/JLine3.scala +++ b/internal/util-logging/src/main/scala/sbt/internal/util/JLine3.scala @@ -261,6 +261,13 @@ private[sbt] object JLine3 { LocalFlag.values.map(f => f.name.toLowerCase -> f).toMap private[this] val charMap: Map[String, Attributes.ControlChar] = Attributes.ControlChar.values().map(f => f.name.toLowerCase -> f).toMap + private[sbt] def setMode(term: Terminal, canonical: Boolean, echo: Boolean): Unit = { + val prev = attributesFromMap(term.getAttributes) + val newAttrs = new Attributes(prev) + newAttrs.setLocalFlag(LocalFlag.ICANON, canonical) + newAttrs.setLocalFlag(LocalFlag.ECHO, echo) + term.setAttributes(toMap(newAttrs)) + } private[util] def attributesFromMap(map: Map[String, String]): Attributes = { val attributes = new Attributes map.get("iflag").foreach { flags => diff --git a/main/src/main/scala/sbt/CommandLineUIService.scala b/main/src/main/scala/sbt/CommandLineUIService.scala index 319021645..39c3db302 100644 --- a/main/src/main/scala/sbt/CommandLineUIService.scala +++ b/main/src/main/scala/sbt/CommandLineUIService.scala @@ -7,12 +7,12 @@ package sbt -import sbt.internal.util.{ SimpleReader, Terminal } +import sbt.internal.util.{ SimpleReader, Terminal => ITerminal } trait CommandLineUIService extends InteractionService { override def readLine(prompt: String, mask: Boolean): Option[String] = { val maskChar = if (mask) Some('*') else None - SimpleReader(Terminal.get).readLine(prompt, maskChar) + SimpleReader(ITerminal.get).readLine(prompt, maskChar) } // TODO - Implement this better. override def confirm(msg: String): Boolean = { @@ -21,15 +21,15 @@ trait CommandLineUIService extends InteractionService { (in == "y" || in == "yes") } } - SimpleReader(Terminal.get).readLine(msg + " (yes/no): ", None) match { + SimpleReader(ITerminal.get).readLine(msg + " (yes/no): ", None) match { case Some(Assent()) => true case _ => false } } - override def terminalWidth: Int = Terminal.get.getWidth + override def terminalWidth: Int = ITerminal.get.getWidth - override def terminalHeight: Int = Terminal.get.getHeight + override def terminalHeight: Int = ITerminal.get.getHeight } object CommandLineUIService extends CommandLineUIService diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 1fc41c277..28fa89f30 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -57,7 +57,7 @@ import sbt.internal.server.{ import sbt.internal.testing.TestLogger import sbt.internal.util.Attributed.data import sbt.internal.util.Types._ -import sbt.internal.util._ +import sbt.internal.util.{ Terminal => ITerminal, _ } import sbt.internal.util.complete._ import sbt.io.Path._ import sbt.io._ @@ -333,7 +333,7 @@ object Defaults extends BuildCommon { if (!useScalaReplJLine.value) classOf[org.jline.terminal.Terminal].getClassLoader else appConfiguration.value.provider.scalaProvider.launcher.topLoader.getParent }, - useSuperShell := { if (insideCI.value) false else Terminal.console.isSupershellEnabled }, + useSuperShell := { if (insideCI.value) false else ITerminal.console.isSupershellEnabled }, superShellThreshold :== SysProp.supershellThreshold, superShellMaxTasks :== SysProp.supershellMaxTasks, superShellSleep :== SysProp.supershellSleep.millis, @@ -388,6 +388,7 @@ object Defaults extends BuildCommon { pollInterval :== Watch.defaultPollInterval, canonicalInput :== true, echoInput :== true, + terminal := state.value.get(terminalKey).getOrElse(Terminal(ITerminal.get)), ) ++ LintUnused.lintSettings ++ DefaultBackgroundJobService.backgroundJobServiceSettings ++ RemoteCache.globalSettings @@ -1663,13 +1664,13 @@ object Defaults extends BuildCommon { def askForMainClass(classes: Seq[String]): Option[String] = sbt.SelectMainClass( - if (classes.length >= 10) Some(SimpleReader(Terminal.get).readLine(_)) + if (classes.length >= 10) Some(SimpleReader(ITerminal.get).readLine(_)) else Some(s => { def print(st: String) = { scala.Console.out.print(st); scala.Console.out.flush() } print(s) - Terminal.get.withRawInput { - try Terminal.get.inputStream.read match { + ITerminal.get.withRawInput { + try ITerminal.get.inputStream.read match { case -1 | -2 => None case b => val res = b.toChar.toString @@ -1705,7 +1706,7 @@ object Defaults extends BuildCommon { private[this] def termWrapper(canonical: Boolean, echo: Boolean): (() => Unit) => (() => Unit) = (f: () => Unit) => () => { - val term = Terminal.get + val term = ITerminal.get if (!canonical) { term.enterRawMode() if (echo) term.setEchoEnabled(echo) @@ -3952,7 +3953,7 @@ object Classpaths { } } - def shellPromptFromState: State => String = shellPromptFromState(Terminal.console.isColorEnabled) + def shellPromptFromState: State => String = shellPromptFromState(ITerminal.console.isColorEnabled) def shellPromptFromState(isColorEnabled: Boolean): State => String = { s: State => val extracted = Project.extract(s) (name in extracted.currentRef).get(extracted.structure.data) match { diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 2e25ea715..f5ea65228 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -70,6 +70,8 @@ object Keys { val serverLog = taskKey[Unit]("A dummy task to set server log level using Global / serverLog / logLevel.").withRank(CTask) val canonicalInput = settingKey[Boolean]("Toggles whether a task should use canonical input (line buffered with echo) or raw input").withRank(DSetting) val echoInput = settingKey[Boolean]("Toggles whether a task should echo user input").withRank(DSetting) + val terminal = taskKey[Terminal]("The Terminal associated with a task").withRank(DTask) + private[sbt] val terminalKey = AttributeKey[Terminal]("terminal-key", Invisible) // Project keys val autoGeneratedProject = settingKey[Boolean]("If it exists, represents that the project (and name) were automatically created, rather than user specified.").withRank(DSetting) diff --git a/main/src/main/scala/sbt/Main.scala b/main/src/main/scala/sbt/Main.scala index 3e08c60a4..4207d7fd0 100644 --- a/main/src/main/scala/sbt/Main.scala +++ b/main/src/main/scala/sbt/Main.scala @@ -26,7 +26,7 @@ import sbt.internal.inc.ScalaInstance import sbt.internal.nio.{ CheckBuildSources, FileTreeRepository } import sbt.internal.server.{ BuildServerProtocol, NetworkChannel } import sbt.internal.util.Types.{ const, idFun } -import sbt.internal.util._ +import sbt.internal.util.{ Terminal => ITerminal, _ } import sbt.internal.util.complete.{ Parser, SizeParser } import sbt.io._ import sbt.io.syntax._ @@ -78,8 +78,8 @@ private[sbt] object xMain { if (userCommands.exists(isBsp)) { BspClient.run(dealiasBaseDirectory(configuration)) } else { - bootServerSocket.foreach(l => Terminal.setBootStreams(l.inputStream, l.outputStream)) - Terminal.withStreams(true) { + bootServerSocket.foreach(l => ITerminal.setBootStreams(l.inputStream, l.outputStream)) + ITerminal.withStreams(true) { if (clientModByEnv || userCommands.exists(isClient)) { val args = userCommands.toList.filterNot(isClient) NetworkClient.run(dealiasBaseDirectory(configuration), args) @@ -105,7 +105,7 @@ private[sbt] object xMain { } finally { // Clear any stray progress lines ShutdownHooks.close() - if (Terminal.formatEnabledInEnv) { + if (ITerminal.formatEnabledInEnv) { System.out.print(ConsoleAppender.ClearScreenAfterCursor) System.out.flush() } @@ -118,9 +118,9 @@ private[sbt] object xMain { try (Some(new BootServerSocket(configuration)) -> None) catch { case _: ServerAlreadyBootingException - if System.console != null && !Terminal.startedByRemoteClient => + if System.console != null && !ITerminal.startedByRemoteClient => println("sbt server is already booting. Create a new server? y/n (default y)") - val exit = Terminal.get.withRawInput(System.in.read) match { + val exit = ITerminal.get.withRawInput(System.in.read) match { case 110 => Some(Exit(1)) case _ => None } @@ -841,7 +841,7 @@ object BuiltinCommands { @tailrec private[this] def doLoadFailed(s: State, loadArg: String): State = { s.log.warn("Project loading failed: (r)etry, (q)uit, (l)ast, or (i)gnore? (default: r)") - val result = try Terminal.get.withRawInput(System.in.read) match { + val result = try ITerminal.get.withRawInput(System.in.read) match { case -1 => 'q'.toInt case b => b } catch { case _: ClosedChannelException => 'q' } @@ -957,7 +957,7 @@ object BuiltinCommands { val threshold = extracted.getOpt(Keys.superShellThreshold).getOrElse(SysProp.supershellThreshold) val maxItems = extracted.getOpt(Keys.superShellMaxTasks).getOrElse(SysProp.supershellMaxTasks) - Terminal.setConsoleProgressState(new ProgressState(1, maxItems)) + ITerminal.setConsoleProgressState(new ProgressState(1, maxItems)) s.put(Keys.superShellSleep.key, sleep) .put(Keys.superShellThreshold.key, threshold) .put(Keys.superShellMaxTasks.key, maxItems) @@ -1032,10 +1032,10 @@ object BuiltinCommands { * by a remote client and only one is able to start a server. This seems to * happen primarily on windows. */ - if (Terminal.startedByRemoteClient && !exchange.hasServer) { + if (ITerminal.startedByRemoteClient && !exchange.hasServer) { Exec(Shutdown, None) +: s1 } else { - if (Terminal.console.prompt == Prompt.Batch) Terminal.console.setPrompt(Prompt.Pending) + if (ITerminal.console.prompt == Prompt.Batch) ITerminal.console.setPrompt(Prompt.Pending) exchange prompt ConsolePromptEvent(s0) val minGCInterval = Project .extract(s1) diff --git a/main/src/main/scala/sbt/MainLoop.scala b/main/src/main/scala/sbt/MainLoop.scala index 1fa2f686d..00205d251 100644 --- a/main/src/main/scala/sbt/MainLoop.scala +++ b/main/src/main/scala/sbt/MainLoop.scala @@ -15,7 +15,7 @@ import sbt.internal.ShutdownHooks import sbt.internal.langserver.ErrorCodes import sbt.internal.protocol.JsonRpcResponseError import sbt.internal.nio.CheckBuildSources.CheckBuildSourcesKey -import sbt.internal.util.{ ErrorHandling, GlobalLogBacking, Prompt, Terminal } +import sbt.internal.util.{ ErrorHandling, GlobalLogBacking, Prompt, Terminal => ITerminal } import sbt.internal.{ ShutdownHooks, TaskProgress } import sbt.io.{ IO, Using } import sbt.protocol._ @@ -35,7 +35,7 @@ object MainLoop { // We've disabled jline shutdown hooks to prevent classloader leaks, and have been careful to always restore // the jline terminal in finally blocks, but hitting ctrl+c prevents finally blocks from being executed, in that // case the only way to restore the terminal is in a shutdown hook. - val shutdownHook = ShutdownHooks.add(Terminal.restore) + val shutdownHook = ShutdownHooks.add(ITerminal.restore) try { runLoggedLoop(state, state.globalLogging.backing) @@ -219,19 +219,19 @@ object MainLoop { } exchange.setState(progressState) exchange.setExec(Some(exec)) - val restoreTerminal = channelName.flatMap(exchange.channelForName) match { + val (restoreTerminal, termState) = channelName.flatMap(exchange.channelForName) match { case Some(c) => - val prevTerminal = Terminal.set(c.terminal) + val prevTerminal = ITerminal.set(c.terminal) val prevPrompt = c.terminal.prompt // temporarily set the prompt to running during task evaluation c.terminal.setPrompt(Prompt.Running) - () => { + (() => { c.terminal.setPrompt(prevPrompt) - Terminal.set(prevTerminal) + ITerminal.set(prevTerminal) c.terminal.setPrompt(prevPrompt) c.terminal.flush() - } - case _ => () => () + }) -> progressState.put(Keys.terminalKey, Terminal(c.terminal)) + case _ => (() => ()) -> progressState.put(Keys.terminalKey, Terminal(ITerminal.get)) } /* * FastTrackCommands.evaluate can be significantly faster than Command.process because @@ -241,8 +241,8 @@ object MainLoop { */ val newState = try { FastTrackCommands - .evaluate(progressState, exec.commandLine) - .getOrElse(Command.process(exec.commandLine, progressState)) + .evaluate(termState, exec.commandLine) + .getOrElse(Command.process(exec.commandLine, termState)) } finally { // Flush the terminal output after command evaluation to ensure that all output // is displayed in the thin client before we report the command status. Also @@ -262,7 +262,7 @@ object MainLoop { } exchange.setExec(None) newState.get(sbt.Keys.currentTaskProgress).foreach(_.progress.stop()) - newState.remove(sbt.Keys.currentTaskProgress) + newState.remove(sbt.Keys.currentTaskProgress).remove(Keys.terminalKey) } state.get(CheckBuildSourcesKey) match { case Some(cbs) => diff --git a/main/src/main/scala/sbt/Terminal.scala b/main/src/main/scala/sbt/Terminal.scala new file mode 100644 index 000000000..8d84cd167 --- /dev/null +++ b/main/src/main/scala/sbt/Terminal.scala @@ -0,0 +1,73 @@ +/* + * sbt + * Copyright 2011 - 2018, Lightbend, Inc. + * Copyright 2008 - 2010, Mark Harrah + * Licensed under Apache License 2.0 (see LICENSE) + */ + +package sbt + +import java.io.{ InputStream, PrintStream } +import sbt.internal.util.{ JLine3, Terminal => ITerminal } + +/** + * A Terminal represents a ui connection to sbt. It may control the embedded console + * for an sbt server or it may control a remote client connected through sbtn. The + * Terminal is particularly useful whenever an sbt task needs to receive input from + * the user. + * + */ +trait Terminal { + + /** + * Returns the width of the terminal. + * + * @return the width ot the terminal + */ + def getWidth: Int + + /** + * Returns the height of the terminal + * + * @return the height of the terminal + */ + def getHeight: Int + + /** + * An input stream associated with the terminal. Bytes inputted by the + * user may be read from this input stream. + * + * @return the terminal's input stream + */ + def inputStream: InputStream + + /** + * A print stream associated with the terminal. Writing to this output + * stream should display text on the terminal. + * + * @return the terminal's input stream + */ + def printStream: PrintStream + + /** + * Sets the mode of the terminal. By default,the terminal will be in canonical mode + * with echo enabled. This means that the terminal's inputStream will not return any + * bytes until a newline is received and that all of the characters inputed by the + * user will be echoed to the terminal's output stream. + * + * @param canonical toggles whether or not the terminal input stream is line buffered + * @param echo toggles whether or not to echo the characters received from the terminal input stream + */ + def setMode(canonical: Boolean, echo: Boolean): Unit + +} +private[sbt] object Terminal { + private[sbt] def apply(term: ITerminal): Terminal = new Terminal { + override def getHeight: Int = term.getHeight + override def getWidth: Int = term.getWidth + override def inputStream: InputStream = term.inputStream + override def printStream: PrintStream = term.printStream + override def setMode(canonical: Boolean, echo: Boolean): Unit = + JLine3.setMode(term, canonical, echo) + } +} diff --git a/main/src/test/scala/PluginCommandTest.scala b/main/src/test/scala/PluginCommandTest.scala index 0ea4c5303..abca2df02 100644 --- a/main/src/test/scala/PluginCommandTest.scala +++ b/main/src/test/scala/PluginCommandTest.scala @@ -18,7 +18,7 @@ import sbt.internal.util.{ GlobalLogging, MainAppender, Settings, - Terminal + Terminal => ITerminal, } object PluginCommandTestPlugin0 extends AutoPlugin { override def requires = empty } @@ -77,7 +77,7 @@ object FakeState { val logFile = File.createTempFile("sbt", ".log") try { val state = FakeState(logFile, enabledPlugins: _*) - Terminal.withOut(new PrintStream(outBuffer, true)) { + ITerminal.withOut(new PrintStream(outBuffer, true)) { MainLoop.processCommand(Exec(input, None), state) } new String(outBuffer.toByteArray)