Redraw command prompt after network command

Presently if a server command comes in while in the shell, the client
output can appear on the same line as the command prompt and the command
prompt will not appear again until the user hits enter. This is a
confusing ux. For example, if I start an sbt server and type
the partial command "comp" and then start up a client and run the clean
command followed by a compile, the output looks like:

[info] sbt server started at local:///Users/ethanatkins/.sbt/1.0/server/51cfad3281b3a8a1820a/sock
sbt:scala-compile> comp[info] new client connected: network-1
[success] Total time: 0 s, completed Dec 12, 2019, 7:23:24 PM
[success] Total time: 0 s, completed Dec 12, 2019, 7:23:27 PM
[success] Total time: 2 s, completed Dec 12, 2019, 7:23:31 PM

Now, if I type "ile\n", I get:
[info] sbt server started at local:///Users/ethanatkins/.sbt/1.0/server/51cfad3281b3a8a1820a/sock
ile
[success] Total time: 0 s, completed Dec 12, 2019, 7:23:34 PM
sbt:scala-compile>

Following the same set of inputs after this change, I get:
[info] sbt server started at local:///Users/ethanatkins/.sbt/1.0/server/51cfad3281b3a8a1820a/sock
sbt:scala-compile> comp
[info] new client connected: network-1
[success] Total time: 0 s, completed Dec 12, 2019, 7:25:58 PM
sbt:scala-compile> comp
[success] Total time: 0 s, completed Dec 12, 2019, 7:26:14 PM
sbt:scala-compile> comp
[success] Total time: 1 s, completed Dec 12, 2019, 7:26:17 PM
sbt:scala-compile> compile
[success] Total time: 0 s, completed Dec 12, 2019, 7:26:19 PM
sbt:scala-compile>

To implement this change, I added the redraw() method to LineReader
which is a wrapper around ConsoleReader.drawLine; ConsoleReader.flush().
We invoke LineReader.redraw whenever the ConsoleChannel receives a
ConsolePromptEvent and there is a running thread.

To prevent log lines from being appended to the prompt line, in the
CommandExchange we print a newline character whenever a new command is
received from the network or a network client connects and we believe
that there is an active prompt.
This commit is contained in:
Ethan Atkins 2019-12-12 19:13:42 -08:00
parent 08091d64c1
commit 9218d3c087
3 changed files with 55 additions and 29 deletions

View File

@ -33,6 +33,11 @@ abstract class JLine extends LineReader {
Option("") Option("")
} }
override def redraw(): Unit = {
reader.drawLine()
reader.flush()
}
private[this] def unsynchronizedReadLine(prompt: String, mask: Option[Char]): Option[String] = private[this] def unsynchronizedReadLine(prompt: String, mask: Option[Char]): Option[String] =
readLineWithHistory(prompt, mask) map { x => readLineWithHistory(prompt, mask) map { x =>
x.trim x.trim
@ -169,6 +174,7 @@ private[sbt] class InputStreamWrapper(is: InputStream, val poll: Duration)
trait LineReader { trait LineReader {
def readLine(prompt: String, mask: Option[Char] = None): Option[String] def readLine(prompt: String, mask: Option[Char] = None): Option[String]
def redraw(): Unit = ()
} }
final class FullReader( final class FullReader(

View File

@ -10,27 +10,29 @@ package internal
import java.io.File import java.io.File
import java.nio.channels.ClosedChannelException import java.nio.channels.ClosedChannelException
import java.util.concurrent.atomic.AtomicReference
import sbt.BasicKeys._ import sbt.BasicKeys._
import sbt.internal.util.Util.AnyOps
import sbt.internal.util._ import sbt.internal.util._
import sbt.protocol.EventMessage import sbt.protocol.EventMessage
import sjsonnew.JsonFormat import sjsonnew.JsonFormat
private[sbt] final class ConsoleChannel(val name: String) extends CommandChannel { private[sbt] final class ConsoleChannel(val name: String) extends CommandChannel {
private var askUserThread: Option[Thread] = None private[this] val askUserThread = new AtomicReference[AskUserThread]
def makeAskUserThread(s: State): Thread = new Thread("ask-user-thread") { private[this] def getPrompt(s: State): String = s.get(shellPrompt) match {
val history = (s get historyPath) getOrElse (new File(s.baseDir, ".history")).some case Some(pf) => pf(s)
val prompt = (s get shellPrompt) match { case None =>
case Some(pf) => pf(s) def ansi(s: String): String = if (ConsoleAppender.formatEnabledInEnv) s"$s" else ""
case None => s"${ansi(ConsoleAppender.DeleteLine)}> ${ansi(ConsoleAppender.clearScreen(0))}"
def ansi(s: String): String = if (ConsoleAppender.formatEnabledInEnv) s"$s" else "" }
s"${ansi(ConsoleAppender.DeleteLine)}> ${ansi(ConsoleAppender.clearScreen(0))}" private[this] class AskUserThread(s: State) extends Thread("ask-user-thread") {
} private val history = s.get(historyPath).getOrElse(Some(new File(s.baseDir, ".history")))
val reader = private val prompt = getPrompt(s)
private val reader =
new FullReader(history, s.combinedParser, JLine.HandleCONT, Terminal.throwOnClosedSystemIn) new FullReader(history, s.combinedParser, JLine.HandleCONT, Terminal.throwOnClosedSystemIn)
override def run(): Unit = { setDaemon(true)
// This internally handles thread interruption and returns Some("") start()
override def run(): Unit =
try { try {
reader.readLine(prompt) match { reader.readLine(prompt) match {
case Some(cmd) => append(Exec(cmd, Some(Exec.newExecId), Some(CommandSource(name)))) case Some(cmd) => append(Exec(cmd, Some(Exec.newExecId), Some(CommandSource(name))))
@ -38,12 +40,18 @@ private[sbt] final class ConsoleChannel(val name: String) extends CommandChannel
println("") // Prevents server shutdown log lines from appearing on the prompt line println("") // Prevents server shutdown log lines from appearing on the prompt line
append(Exec("exit", Some(Exec.newExecId), Some(CommandSource(name)))) append(Exec("exit", Some(Exec.newExecId), Some(CommandSource(name))))
} }
()
} catch { } catch {
case _: ClosedChannelException => case _: ClosedChannelException =>
} } finally askUserThread.synchronized(askUserThread.set(null))
askUserThread = None def redraw(): Unit = {
System.out.print(ConsoleAppender.clearLine(0))
reader.redraw()
System.out.print(ConsoleAppender.clearScreen(0))
System.out.flush()
} }
} }
private[this] def makeAskUserThread(s: State): AskUserThread = new AskUserThread(s)
def run(s: State): State = s def run(s: State): State = s
@ -54,21 +62,24 @@ private[sbt] final class ConsoleChannel(val name: String) extends CommandChannel
def publishEventMessage(event: EventMessage): Unit = def publishEventMessage(event: EventMessage): Unit =
event match { event match {
case e: ConsolePromptEvent => case e: ConsolePromptEvent =>
askUserThread match { if (Terminal.systemInIsAttached) {
case Some(_) => askUserThread.synchronized {
case _ => askUserThread.get match {
val x = makeAskUserThread(e.state) case null => askUserThread.set(makeAskUserThread(e.state))
askUserThread = Some(x) case t => t.redraw()
x.start() }
}
} }
case _ => // case _ => //
} }
def shutdown(): Unit = def shutdown(): Unit = askUserThread.synchronized {
askUserThread match { askUserThread.get match {
case Some(x) if x.isAlive => case null =>
x.interrupt() case t if t.isAlive =>
askUserThread = None t.interrupt()
askUserThread.set(null)
case _ => () case _ => ()
} }
}
} }

View File

@ -19,7 +19,7 @@ import sbt.internal.protocol.JsonRpcResponseError
import sbt.internal.langserver.{ LogMessageParams, MessageType } import sbt.internal.langserver.{ LogMessageParams, MessageType }
import sbt.internal.server._ import sbt.internal.server._
import sbt.internal.util.codec.JValueFormats import sbt.internal.util.codec.JValueFormats
import sbt.internal.util.{ MainAppender, ObjectEvent, StringEvent } import sbt.internal.util.{ ConsoleOut, MainAppender, ObjectEvent, StringEvent, Terminal }
import sbt.io.syntax._ import sbt.io.syntax._
import sbt.io.{ Hash, IO } import sbt.io.{ Hash, IO }
import sbt.protocol.{ EventMessage, ExecStatusEvent } import sbt.protocol.{ EventMessage, ExecStatusEvent }
@ -50,6 +50,7 @@ private[sbt] final class CommandExchange {
private val channelBufferLock = new AnyRef {} private val channelBufferLock = new AnyRef {}
private val commandChannelQueue = new LinkedBlockingQueue[CommandChannel] private val commandChannelQueue = new LinkedBlockingQueue[CommandChannel]
private val nextChannelId: AtomicInteger = new AtomicInteger(0) private val nextChannelId: AtomicInteger = new AtomicInteger(0)
private[this] val activePrompt = new AtomicBoolean(false)
private lazy val jsonFormat = new sjsonnew.BasicJsonProtocol with JValueFormats {} private lazy val jsonFormat = new sjsonnew.BasicJsonProtocol with JValueFormats {}
def channels: List[CommandChannel] = channelBuffer.toList def channels: List[CommandChannel] = channelBuffer.toList
@ -83,7 +84,11 @@ private[sbt] final class CommandExchange {
commandChannelQueue.poll(1, TimeUnit.SECONDS) commandChannelQueue.poll(1, TimeUnit.SECONDS)
slurpMessages() slurpMessages()
Option(commandQueue.poll) match { Option(commandQueue.poll) match {
case Some(x) => x case Some(exec) =>
val needFinish = needToFinishPromptLine()
if (exec.source.fold(needFinish)(s => needFinish && s.channelName != "console0"))
ConsoleOut.systemOut.println("")
exec
case None => case None =>
val newDeadline = if (deadline.fold(false)(_.isOverdue())) { val newDeadline = if (deadline.fold(false)(_.isOverdue())) {
GCUtil.forceGcWithInterval(interval, logger) GCUtil.forceGcWithInterval(interval, logger)
@ -129,6 +134,7 @@ private[sbt] final class CommandExchange {
def onIncomingSocket(socket: Socket, instance: ServerInstance): Unit = { def onIncomingSocket(socket: Socket, instance: ServerInstance): Unit = {
val name = newNetworkName val name = newNetworkName
if (needToFinishPromptLine()) ConsoleOut.systemOut.println("")
s.log.info(s"new client connected: $name") s.log.info(s"new client connected: $name")
val logger: Logger = { val logger: Logger = {
val log = LogExchange.logger(name, None, None) val log = LogExchange.logger(name, None, None)
@ -362,7 +368,9 @@ private[sbt] final class CommandExchange {
// Special treatment for ConsolePromptEvent since it's hand coded without codec. // Special treatment for ConsolePromptEvent since it's hand coded without codec.
case entry: ConsolePromptEvent => case entry: ConsolePromptEvent =>
channels collect { channels collect {
case c: ConsoleChannel => c.publishEventMessage(entry) case c: ConsoleChannel =>
c.publishEventMessage(entry)
activePrompt.set(Terminal.systemInIsAttached)
} }
case entry: ExecStatusEvent => case entry: ExecStatusEvent =>
channels collect { channels collect {
@ -380,4 +388,5 @@ private[sbt] final class CommandExchange {
removeChannels(toDel.toList) removeChannels(toDel.toList)
} }
private[this] def needToFinishPromptLine(): Boolean = activePrompt.compareAndSet(true, false)
} }