Add sbt.Terminal trait

It can be useful for plugin and build authors to have access to some of
the virtual terminal properties. For instance, when writing a task that
needs a password, the author may wish to put the terminal in raw mode
with echo disabled. This commit introduces a new Terminal trait at the
sbt level and a corresponding task, terminal, that provides a basic
terminal api. The Terminal returned by the terminal task will correspond
to the terminal that initiated the task so that it should work with sbtn
as well as in console mode.
This commit is contained in:
Ethan Atkins 2020-09-27 11:50:04 -07:00
parent 411c1365ef
commit b85209be78
8 changed files with 118 additions and 35 deletions

View File

@ -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 =>

View File

@ -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

View File

@ -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 {

View File

@ -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)

View File

@ -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)

View File

@ -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) =>

View File

@ -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)
}
}

View File

@ -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)