From cb19265eac1140c593ab98b438a6e6b560901097 Mon Sep 17 00:00:00 2001 From: Anton Sviridov Date: Mon, 29 Aug 2022 15:11:41 +0100 Subject: [PATCH] Fix various frontend issues (#42) Fix independent scrolling Improve UI especially around request/response matching Match requests and responses on the server Move server summary to a separate page Move navigation bar to top right corner Use split rendering Debounce event stream Code refactoring --- .../backend/src/main/scala/TracerServer.scala | 55 ++- modules/tracer/frontend/index.html | 6 + .../frontend/src/main/scala/Frontend.scala | 328 ++---------------- .../frontend/src/main/scala/Styles.scala | 121 ++++++- .../src/main/scala/component.jsonviewer.scala | 92 +++++ .../src/main/scala/component.message.scala | 102 ++++++ .../src/main/scala/component.navigation.scala | 33 ++ .../src/main/scala/component.timeline.scala | 48 +++ .../src/main/scala/page.commands.scala | 33 ++ .../frontend/src/main/scala/page.logs.scala | 28 ++ .../src/main/scala/page.summary.scala | 22 ++ .../frontend/src/main/scala/websockets.scala | 29 ++ .../shared/src/main/scala/Protocol.scala | 17 +- 13 files changed, 588 insertions(+), 326 deletions(-) create mode 100644 modules/tracer/frontend/src/main/scala/component.jsonviewer.scala create mode 100644 modules/tracer/frontend/src/main/scala/component.message.scala create mode 100644 modules/tracer/frontend/src/main/scala/component.navigation.scala create mode 100644 modules/tracer/frontend/src/main/scala/component.timeline.scala create mode 100644 modules/tracer/frontend/src/main/scala/page.commands.scala create mode 100644 modules/tracer/frontend/src/main/scala/page.logs.scala create mode 100644 modules/tracer/frontend/src/main/scala/page.summary.scala create mode 100644 modules/tracer/frontend/src/main/scala/websockets.scala diff --git a/modules/tracer/backend/src/main/scala/TracerServer.scala b/modules/tracer/backend/src/main/scala/TracerServer.scala index c5d44dc3a..20edeb9cf 100644 --- a/modules/tracer/backend/src/main/scala/TracerServer.scala +++ b/modules/tracer/backend/src/main/scala/TracerServer.scala @@ -221,6 +221,7 @@ object TracerServer: rf: SignallingRef[IO, Vector[Received[LspMessage]]], raw: SignallingRef[IO, Vector[Received[RawMessage]]], logBuf: Ref[IO, Vector[String]], + responseIdMapping: Ref[IO, Map[MessageId, String]], random: UUIDGen[IO] ) @@ -231,7 +232,8 @@ object TracerServer: rf <- SignallingRef[IO].of(Vector.empty[Received[LspMessage]]) logBuf <- IO.ref(Vector.empty[String]) ch <- Channel.bounded[IO, String](128) - yield State(ch, rf, raw, logBuf, UUIDGen[IO]) + rpid <- IO.ref(Map.empty[MessageId, String]) + yield State(ch, rf, raw, logBuf, rpid, UUIDGen[IO]) end State def dump(stream: fs2.Stream[IO, Byte], state: State, direction: Direction) = @@ -242,19 +244,52 @@ object TracerServer: .collect { case Some(v) => v } - .evalTap { rw => + .evalTap { rawMessage => random.randomUUID .map(u => MessageId.StringId("generated-" + u.toString)) .flatMap { generatedId => - val rawMessage = rw.copy(value = - rw.value.copy(id = rw.value.id.orElse(Some(generatedId))) + val receivedRawMessage = rawMessage.copy(value = + rawMessage.value + .copy(id = rawMessage.value.id.orElse(Some(generatedId))) ) - raw.update(_ :+ rawMessage) *> - rf.update( - _ ++ LspMessage - .from(rw.value, direction, generatedId) - .map(Received(rw.timestamp, _)) - ) + raw.update(_ :+ receivedRawMessage) *> { + val msg = LspMessage + .from(rawMessage.value, direction, generatedId) + val receivedMsg = msg.map(Received(rawMessage.timestamp, _)) + + LspMessage + .from(rawMessage.value, direction, generatedId) + .map { lspMessage => + + val withUpdatedMapping = lspMessage match + case LspMessage.Request(method, id, _) => + responseIdMapping + .update(_.updated(id, method)) + .as(lspMessage) + + case LspMessage.Response(id, _) => + rf.update { received => + received.collect { + case Received(ts, req: LspMessage.Request) + if req.id == id => + Received(ts, req.copy(responded = true)) + case other => other + } + } *> + responseIdMapping.get + .map(_.get(id)) + .map(LspMessage.Response(id, _)) + + case other => IO.pure(other) + + withUpdatedMapping + .flatMap(msg => + rf.update(_ :+ Received(rawMessage.timestamp, msg)) + ) + + } + .getOrElse(IO.unit) + } } } end dump diff --git a/modules/tracer/frontend/index.html b/modules/tracer/frontend/index.html index 502309a9b..46aba56f7 100644 --- a/modules/tracer/frontend/index.html +++ b/modules/tracer/frontend/index.html @@ -9,6 +9,12 @@ href="//cdnjs.cloudflare.com/ajax/libs/highlight.js/11.6.0/styles/github-dark.min.css"> + diff --git a/modules/tracer/frontend/src/main/scala/Frontend.scala b/modules/tracer/frontend/src/main/scala/Frontend.scala index 4822d1691..c5795d3db 100644 --- a/modules/tracer/frontend/src/main/scala/Frontend.scala +++ b/modules/tracer/frontend/src/main/scala/Frontend.scala @@ -2,7 +2,7 @@ package langoustine.tracer import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future -import scala.concurrent.duration.* +import scala.concurrent.duration.given import scala.scalajs.js import scala.scalajs.js.Thenable.Implicits.* import com.github.plokhotnyuk.jsoniter_scala.core.* @@ -17,6 +17,7 @@ import org.scalajs.dom.WebSocket import scala.scalajs.js.Date import scala.scalajs.js.JSON import scala.scalajs.js.annotation.JSGlobal +import com.raquo.airstream.core.Signal @js.native @JSGlobal @@ -24,313 +25,54 @@ object hljs extends js.Object: def highlightAll(): Unit = js.native enum Page: - case Logs, Commands + case Logs, Commands, Summary -object Frontend: - - val showing = Var(Option.empty[Message]) - val page = Var(Page.Commands) - val logs = Var(Vector.empty[String]) - val commandFilter = Var(Option.empty[String]) - val logFilter = Var(Option.empty[String]) - val bus = new EventBus[Double] - val logBus = new EventBus[Double] - - def cid(c: MessageId) = c match - case MessageId.NumberId(n) => n.toString - case MessageId.StringId(s) => s - - def timeline(msgs: Vector[Message]) = - import Message.* - val fromClient = textAlign.left - val fromServer = textAlign.right - - inline def select(req: Message) = - backgroundColor <-- showing.signal.map { - case Some(`req`) => "black" - case _ => "" - } - - div( - display.flex, - flexDirection.column, - borderLeft := "1px solid black", - borderRight := "1px solid black", - overflow.scroll, - div( - width := "100%", - display.flex, - alignContent.spaceBetween, - div(h2("client", marginTop := "0px"), width := "100%"), - div(h2("server", marginTop := "0px"), textAlign.right, width := "100%") - ), - children <-- commandFilter.signal.map { filter => - msgs - .filter(m => - filter.isEmpty || m.methodName.exists( - _.toLowerCase.contains(filter.getOrElse("")) - ) - ) - .reverse - .collect { - case rq: Request => - div( - Styles.timeline.row, - fromClient, - select(rq), - button( - Styles.timeline.requestButton, - b(rq.method), - ": ", - cid(rq.id), - onClick.preventDefault.mapTo(rq) --> showing.someWriter - ) - ) - case rp: Response => - div( - select(rp), - Styles.timeline.row, - fromServer, - button( - Styles.timeline.requestButton, - b("Response for"), - ": ", - cid(rp.id), - onClick.preventDefault.mapTo(rp) --> showing.someWriter - ) - ) - case cm: Notification => - div( - select(cm), - Styles.timeline.row, - if (cm.direction == Direction.ToClient) - then fromServer - else fromClient, - button( - Styles.timeline.notificationButton, - cm.method, - onClick.preventDefault.mapTo(cm) --> showing.someWriter - ) - ) - } - } - ) - end timeline - - def displayJson[T: JsonValueCodec](rmsg: T) = - val js = - writeToString( - rmsg, - WriterConfig.withIndentionStep(4) - ) - - pre( - code( - className := "language-json", - JSON.stringify(JSON.parse(js), space = 2), - onMountCallback(ctx => hljs.highlightAll()) - ) - ) - end displayJson - - import jsonrpclib.{ErrorPayload, Payload} - - def displayErr(ep: ErrorPayload) = - div(b(color := "pink", "Error"), displayJson(ep)) - - given JsonValueCodec[Option[Payload]] = JsonCodecMaker.make - - def displayPayload(name: String, op: Option[Payload]) = - div(b(color := "lightgreen", name, displayJson(op))) - - val commandTracer = - div( - display.flex, - alignItems.flexStart, - columnGap := "15px", - div( - Styles.jsonViewer, - display <-- showing.signal - .map(_.isDefined) - .map(if (_) then "block" else "none"), - overflow.scroll, - child <-- showing.signal.flatMap { - case None => - Signal.fromValue( - i("Select any operation on the right to see its result") - ) - - case Some(req: Message.Request) => - Signal - .fromFuture(Api.request(cid(req.id))) - .map(_.flatten) - .map { raw => - displayPayload("Params", raw.flatMap(_.params)) - } +def cid(c: MessageId) = c match + case MessageId.NumberId(n) => n.toString + case MessageId.StringId(s) => s - case Some(req: Message.Response) => - Signal - .fromFuture(Api.response(cid(req.id))) - .map(_.flatten) - .map { - case None => i("...") - case Some(raw) => - def makeResult( - rm: RawMessage - ): Either[ErrorPayload, Option[Payload]] = - rm.error match - case None => Right(rm.result) - case Some(er) => Left(er) - makeResult(raw) match - case Left(ep) => - displayErr(ep) - case Right(op) => - displayPayload("Result", op) - } +def uniqueId(m: Message) = m match + case _: Message.Request => "request-" + cid(m.id) + case _: Message.Response => "response-" + cid(m.id) + case _: Message.Notification => "notification-" + cid(m.id) - case Some(cm: Message.Notification) => - Signal - .fromFuture(Api.notification(cid(cm.generatedId))) - .map(_.flatten) - .map { - case None => i("...") - case Some(raw) => - (raw.params, raw.error) match - case (None, None) => i("no error or payload") - case (_, Some(err)) => - displayErr(err) - case (p, None) => - displayPayload("Params", p) - } - } - ), - div( - width <-- showing.signal - .map(_.isDefined) - .map(if (_) then "30%" else "100%"), - input( - Styles.filterBox, - onInput.mapToValue.map(s => - Option.when(s.nonEmpty)(s.trim.toLowerCase) - ) --> commandFilter.writer - ), - child <-- bus.events - .startWith(Date.now()) - .flatMap { _ => - Signal - .fromFuture(Api.all) - .map(_.toVector.flatten) - .map(timeline) - } - ) - ) - - val switcher = - inline def thing(name: String, set: Page) = - div( - fontSize := "1.5rem", - padding := "10px", - child <-- page.signal.map { - case `set` => b(name) - case other => - a( - href := "#", - onClick.preventDefault.mapTo(set) --> page.writer, - name - ) - } - ) - - div( - display.flex, - maxWidth := "300px", - alignContent.stretch, - thing("Interactions", Page.Commands), - thing("Logs", Page.Logs) - ) - end switcher - - val logTracer = - div( - width := "95%", - borderRadius := "5px", - backgroundColor := "black", - color.white, - fontSize := "1.3rem", - padding := "10px", - overflow.scroll, - input( - Styles.filterBox, - onInput.mapToValue.map(s => - Option.when(s.nonEmpty)(s.trim.toLowerCase) - ) --> logFilter.writer - ), - pre( - code( - children <-- - logs.signal.combineWith(logFilter.signal).map { case (lines, f) => - lines - .filter(l => f.isEmpty || f.exists(l.toLowerCase().contains)) - .map(p(_)) - } - ) - ) - ) +object Frontend: + val page = Var(Page.Commands) + val logs = Var(Vector.empty[String]) + val bus = new EventBus[Double] + val commandFilter: Var[Option[String]] = Var(Option.empty[String]) + val logFilter: Var[Option[String]] = Var(Option.empty[String]) + val messagesState: Var[Vector[Message]] = Var(Vector.empty[Message]) + val showing: Var[Option[Message]] = Var(Option.empty[Message]) val app = div( - fontFamily := "'Wotfard',Futura,-apple-system,sans-serif", - margin := "0px", - padding := "0px", - height := "100vh", + Styles.staticContainer, div( - display.flex, - justifyContent.spaceBetween, + Styles.dynamicContainer, div( - h1(marginTop := "0px", "Langoustine tracer"), - p( - "welcome to the future of LSP tooling" - ) + display.flex, + justifyContent.spaceBetween, + div( + h1(marginTop := "0px", "Langoustine tracer") + ), + switcher(page) ), - child.maybe <-- Signal.fromFuture(Api.summary).map { - _.map { summary => - val cmd = summary.serverCommand.mkString(" ") - dom.document.title = s"Tracer: $cmd" - div( - marginLeft := "15px", - p(b("In folder: "), summary.workingFolder), - p(b("LSP command: "), cmd) - ) - } + child <-- page.signal.map { + case Page.Commands => + commandTracer(bus, commandFilter, messagesState, showing) + case Page.Logs => + logsTracer(logs, logFilter) + case Page.Summary => + summaryPage } - ), - switcher, - child <-- page.signal.map { - case Page.Commands => - commandTracer - case Page.Logs => - logTracer - } + ) ) def main(args: Array[String]): Unit = documentEvents.onDomContentLoaded.foreach { _ => - val hs = dom.window.location.host - val sock = new WebSocket(s"ws://$hs/api/ws/events") + listenToWebsockets(logs, bus, dom.window.location.host) - sock.addEventListener[Event]( - "message", - (event: Event) => - event match - case event: MessageEvent => - val data = event.data.toString - readFromStringReentrant[TracerEvent](data) match - case TracerEvent.Update => bus.emit(Date.now()) - case TracerEvent.LogLine(l) => - logs.update(v => v.drop(v.length + 1 - 1000) :+ l) - case TracerEvent.LogLines(l) => - logs.update(v => v.drop(v.length + l.length - 1000) ++ l) - ) render(dom.document.getElementById("appContainer"), app) }(unsafeWindowOwner) end Frontend diff --git a/modules/tracer/frontend/src/main/scala/Styles.scala b/modules/tracer/frontend/src/main/scala/Styles.scala index 7ed5762fc..d48edaf6a 100644 --- a/modules/tracer/frontend/src/main/scala/Styles.scala +++ b/modules/tracer/frontend/src/main/scala/Styles.scala @@ -3,44 +3,133 @@ package langoustine.tracer import com.raquo.laminar.api.L.* object Styles: + val staticContainer = + Seq( + display.flex, + flexDirection.row, + justifyContent.center, + alignItems.center, + width := "100%" + ) + + val dynamicContainer = + Seq( + fontFamily := "sans-serif", + margin := "0px", + padding := "0px", + height := "100%", + backgroundColor := "#e3e3e3", + padding := "15px", + maxWidth := "1500px", + width := "100%" + ) + val filterBox = Seq( margin := "auto", padding := "5px", fontSize := "1.3rem", placeholder := "filter...", borderRadius := "2px", - border := "2px solid lightgrey", + border := "0 solid lightgrey", position := "sticky", top := "0" ) - val jsonViewer = Seq( - width := "70%", - borderRadius := "5px", - backgroundColor := "#0d1117", - color.white, - fontSize := "1.3rem", - padding := "10px", - position := "sticky", - top := "0" - ) + object commandTracer: + val container = Seq( + display.flex, + alignItems.flexStart, + columnGap := "15px", + backgroundColor := "white", + padding := "10px", + borderRadius := "10px" + ) + + val jsonViewer = Seq( + width := "70%", + borderRadius := "5px", + backgroundColor := "#0d1117", + color.white, + fontSize := "1.3rem", + padding := "10px", + position := "sticky", + top := "0", + overflowX.scroll, + overflowY.scroll, + maxHeight := "80vh" + ) + end commandTracer + + object logTracer: + val container = + Seq( + borderRadius := "5px", + backgroundColor := "black", + color.white, + fontSize := "1.3rem", + padding := "10px", + overflow.auto + ) + end logTracer + + object summaryPage: + val container = + Seq( + borderRadius := "5px", + backgroundColor := "white", + padding := "10px" + ) object timeline: val requestButton = Seq( - padding := "8px", + padding := "3px", backgroundColor := "lightgreen", - fontSize := "1.3rem", + fontSize := "1.1rem", border := "1px solid darkgrey" ) val notificationButton = Seq( - padding := "8px", + padding := "3px", backgroundColor := "yellow", - fontSize := "1.3rem", + fontSize := "1.1rem", border := "1px solid darkgrey" ) - val row = Seq(borderBottom := "1px dotted lightgrey", width := "100%") + val row = Seq( + borderBottom := "1px dotted lightgrey", + width := "100%" + ) + + val seeLink = Seq( + color.black, + textDecoration.none, + borderBottom := "1px dotted black" + ) + val clientServerHeader = Seq( + width := "100%", + display.flex, + alignContent.spaceBetween, + borderBottom := "1px solid lightgrey" + ) end timeline + + object pageSwitcher: + val focused = Seq( + fontSize := "1.2rem", + padding := "10px", + borderLeft := "4px solid maroon", + backgroundColor := "white" + ) + val unfocused = Seq( + fontSize := "1.2rem", + padding := "10px", + borderLeft := "4px solid blue", + textDecoration.none + ) + val link = Seq( + textDecoration.none, + color.black + ) + end pageSwitcher end Styles diff --git a/modules/tracer/frontend/src/main/scala/component.jsonviewer.scala b/modules/tracer/frontend/src/main/scala/component.jsonviewer.scala new file mode 100644 index 000000000..d8ae48753 --- /dev/null +++ b/modules/tracer/frontend/src/main/scala/component.jsonviewer.scala @@ -0,0 +1,92 @@ +package langoustine.tracer + +import com.raquo.laminar.api.L.* +import com.github.plokhotnyuk.jsoniter_scala.core.* + +import jsonrpclib.* +import scala.scalajs.js.JSON +import com.github.plokhotnyuk.jsoniter_scala.macros.JsonCodecMaker + +def jsonViewer(showing: Var[Option[Message]]) = + div( + flexGrow := 0, + Styles.commandTracer.jsonViewer, + display <-- showing.signal + .map(_.isDefined) + .map(if (_) then "block" else "none"), + child <-- showing.signal.flatMap { + case None => + Signal.fromValue( + i("Select any operation on the right to see its result") + ) + + case Some(req: Message.Request) => + Signal + .fromFuture(Api.request(cid(req.id))) + .map(_.flatten) + .map { raw => + displayPayload("Params", raw.flatMap(_.params)) + } + + case Some(req: Message.Response) => + Signal + .fromFuture(Api.response(cid(req.id))) + .map(_.flatten) + .map { + case None => i("...") + case Some(raw) => + def makeResult( + rm: RawMessage + ): Either[ErrorPayload, Option[Payload]] = + rm.error match + case None => Right(rm.result) + case Some(er) => Left(er) + makeResult(raw) match + case Left(ep) => + displayErr(ep) + case Right(op) => + displayPayload("Result", op) + } + + case Some(cm: Message.Notification) => + Signal + .fromFuture(Api.notification(cid(cm.generatedId))) + .map(_.flatten) + .map { + case None => i("...") + case Some(raw) => + (raw.params, raw.error) match + case (None, None) => i("no error or payload") + case (_, Some(err)) => + displayErr(err) + case (p, None) => + displayPayload("Params", p) + } + } + ) + +def displayJson[T: JsonValueCodec](rmsg: T) = + val js = + writeToString( + rmsg, + WriterConfig.withIndentionStep(4) + ) + + pre( + code( + className := "language-json", + JSON.stringify(JSON.parse(js), space = 2), + onMountCallback(ctx => hljs.highlightAll()) + ) + ) +end displayJson + +import jsonrpclib.{ErrorPayload, Payload} + +def displayErr(ep: ErrorPayload) = + div(b(color := "pink", "Error"), displayJson(ep)) + +given JsonValueCodec[Option[Payload]] = JsonCodecMaker.make + +def displayPayload(name: String, op: Option[Payload]) = + div(b(color := "lightgreen", name, displayJson(op))) diff --git a/modules/tracer/frontend/src/main/scala/component.message.scala b/modules/tracer/frontend/src/main/scala/component.message.scala new file mode 100644 index 000000000..80d3f7883 --- /dev/null +++ b/modules/tracer/frontend/src/main/scala/component.message.scala @@ -0,0 +1,102 @@ +package langoustine.tracer + +import com.raquo.laminar.api.L.* + +val fromClient = textAlign.left +val fromServer = textAlign.right + +def renderMessage( + showing: Var[Option[Message]] +)(id: String, initial: Message, stream: Signal[Message]) = + import Message.* + + inline def select(req: Message) = + Seq( + background <-- showing.signal.map { + case Some(`req`) => + "repeating-linear-gradient(45deg, #ededed, #ededed 10px, white 10px, white 20px)" + case _ => "" + } + ) + + println(s"Being called for $id") + div( + child <-- stream.map { + case rq: Request => + div( + Styles.timeline.row, + fromClient, + select(rq), + button( + Styles.timeline.requestButton, + b(rq.method), + ": ", + cid(rq.id), + onClick.preventDefault.mapTo(rq) --> showing.someWriter + ), + Option.when(rq.responded) { + p( + margin := "0px", + a( + Styles.timeline.seeLink, + href := "#", + small("see response"), + onClick.preventDefault.mapTo( + Response(rq.id, method = Some(rq.method)) + ) --> showing.someWriter + ) + ) + } + ) + case rp: Response => + div( + select(rp), + Styles.timeline.row, + fromServer, + div( + button( + Styles.timeline.requestButton, + rp.method match + case Some(m) => + span( + b(m), + " response" + ) + case None => + b(s"Response for ${cid(rp.id)}") + , + onClick.preventDefault.mapTo(rp) --> showing.someWriter + ), + Option.when(rp.method.isDefined) { + p( + margin := "0px", + a( + Styles.timeline.seeLink, + href := "#", + small("see request ", b(cid(rp.id))), + onClick.preventDefault.mapTo( + rp.method.map(method => + Request(method, rp.id, responded = true) + ) + ) --> showing.writer + ) + ) + } + ) + ) + case cm: Notification => + div( + select(cm), + Styles.timeline.row, + if (cm.direction == Direction.ToClient) + then fromServer + else fromClient, + button( + Styles.timeline.notificationButton, + cm.method, + onClick.preventDefault.mapTo(cm) --> showing.someWriter + ) + ) + } + ) +end renderMessage diff --git a/modules/tracer/frontend/src/main/scala/component.navigation.scala b/modules/tracer/frontend/src/main/scala/component.navigation.scala new file mode 100644 index 000000000..2fc036c94 --- /dev/null +++ b/modules/tracer/frontend/src/main/scala/component.navigation.scala @@ -0,0 +1,33 @@ +package langoustine.tracer + +import com.raquo.laminar.api.L.* +import com.github.plokhotnyuk.jsoniter_scala.core.* + +def switcher(page: Var[Page]) = + inline def thing(name: String, set: Page) = + div( + child <-- page.signal.map { + case `set` => div(Styles.pageSwitcher.focused, name) + case other => + div( + Styles.pageSwitcher.unfocused, + a( + Styles.pageSwitcher.link, + href := "#", + onClick.preventDefault.mapTo(set) --> page.writer, + name + ) + ) + } + ) + + div( + display.flex, + alignContent.flexEnd, + columnGap := "10px", + flexGrow := 0, + thing("Interactions", Page.Commands), + thing("Logs", Page.Logs), + thing("Server summary", Page.Summary) + ) +end switcher diff --git a/modules/tracer/frontend/src/main/scala/component.timeline.scala b/modules/tracer/frontend/src/main/scala/component.timeline.scala new file mode 100644 index 000000000..39bc5c540 --- /dev/null +++ b/modules/tracer/frontend/src/main/scala/component.timeline.scala @@ -0,0 +1,48 @@ +package langoustine.tracer + +import com.raquo.laminar.api.L.* +import com.github.plokhotnyuk.jsoniter_scala.core.* + +def timeline( + messagesState: Var[Vector[Message]], + commandFilter: Var[Option[String]], + showing: Var[Option[Message]] +) = + import Message.* + + import scala.util.chaining.* + + val filteredMessages: Signal[Vector[Message]] = + messagesState.signal.combineWithFn(commandFilter.signal) { + (messages, filter) => + messages + .filter(m => + filter.isEmpty || m.methodName.exists( + _.toLowerCase.contains(filter.getOrElse("")) + ) + ) + } + + val splitStream = + filteredMessages + .split(e => uniqueId(e))(renderMessage(showing)) + .debugSpyEvents(els => println(els.length)) + + div( + display.flex, + flexDirection.column, + overflow.auto, + div( + Styles.timeline.clientServerHeader, + div(h2("client", marginTop := "0px"), width := "100%"), + div(h2("server", marginTop := "0px"), textAlign.right, width := "100%") + ), + input( + Styles.filterBox, + onInput.mapToValue.map(s => + Option.when(s.nonEmpty)(s.trim.toLowerCase) + ) --> commandFilter.writer + ), + children <-- splitStream + ) +end timeline diff --git a/modules/tracer/frontend/src/main/scala/page.commands.scala b/modules/tracer/frontend/src/main/scala/page.commands.scala new file mode 100644 index 000000000..d3cb6e3ea --- /dev/null +++ b/modules/tracer/frontend/src/main/scala/page.commands.scala @@ -0,0 +1,33 @@ +package langoustine.tracer + +import com.raquo.laminar.api.L.* +import com.github.plokhotnyuk.jsoniter_scala.core.* +import scala.scalajs.js.Date +import com.raquo.airstream.state.Var + +def commandTracer( + bus: EventBus[Double], + commandFilter: Var[Option[String]], + messagesState: Var[Vector[Message]], + showing: Var[Option[Message]] +) = + import Message.* + div( + Styles.commandTracer.container, + jsonViewer(showing), + div( + width <-- showing.signal + .map(_.isDefined) + .map(if (_) then "40%" else "100%"), + bus.events + .debounce(500) + .startWith(Date.now()) + .flatMap { _ => + Signal + .fromFuture(Api.all) + .map(_.toVector.flatten.reverse) + } --> messagesState.writer, + timeline(messagesState, commandFilter, showing) + ) + ) +end commandTracer diff --git a/modules/tracer/frontend/src/main/scala/page.logs.scala b/modules/tracer/frontend/src/main/scala/page.logs.scala new file mode 100644 index 000000000..808966e08 --- /dev/null +++ b/modules/tracer/frontend/src/main/scala/page.logs.scala @@ -0,0 +1,28 @@ +package langoustine.tracer + +import com.raquo.laminar.api.L.* +import com.github.plokhotnyuk.jsoniter_scala.core.* +import scala.scalajs.js.Date + +def logsTracer(logs: Var[Vector[String]], logFilter: Var[Option[String]]) = + div( + Styles.logTracer.container, + input( + Styles.filterBox, + onInput.mapToValue.map(s => + Option.when(s.nonEmpty)(s.trim.toLowerCase) + ) --> logFilter.writer + ), + pre( + code( + children <-- + logs.signal + .combineWithFn(logFilter.signal) { case (lines, f) => + lines + .filter(l => f.isEmpty || f.exists(l.toLowerCase().contains)) + } + .map(_.map(p(_))) + ) + ) + ) +end logsTracer diff --git a/modules/tracer/frontend/src/main/scala/page.summary.scala b/modules/tracer/frontend/src/main/scala/page.summary.scala new file mode 100644 index 000000000..c2772f904 --- /dev/null +++ b/modules/tracer/frontend/src/main/scala/page.summary.scala @@ -0,0 +1,22 @@ +package langoustine.tracer + +import com.raquo.laminar.api.L.* +import com.github.plokhotnyuk.jsoniter_scala.core.* +import scala.scalajs.js.Date + +import org.scalajs.dom + +def summaryPage = div( + Styles.summaryPage.container, + child.maybe <-- Signal.fromFuture(Api.summary).map { + _.map { summary => + val cmd = summary.serverCommand.mkString(" ") + dom.document.title = s"Tracer: $cmd" + div( + marginLeft := "15px", + p(b("In folder: "), summary.workingFolder), + p(b("LSP command: "), cmd) + ) + } + } +) diff --git a/modules/tracer/frontend/src/main/scala/websockets.scala b/modules/tracer/frontend/src/main/scala/websockets.scala new file mode 100644 index 000000000..768d79110 --- /dev/null +++ b/modules/tracer/frontend/src/main/scala/websockets.scala @@ -0,0 +1,29 @@ +package langoustine.tracer + +import org.scalajs.dom.* +import com.github.plokhotnyuk.jsoniter_scala.core.* +import com.raquo.airstream.state.Var +import com.raquo.airstream.eventbus.EventBus +import scala.scalajs.js.Date + +def listenToWebsockets( + logs: Var[Vector[String]], + bus: EventBus[Double], + host: String +) = + val sock = new WebSocket(s"ws://$host/api/ws/events") + + sock.addEventListener[Event]( + "message", + (event: Event) => + event match + case event: MessageEvent => + val data = event.data.toString + readFromStringReentrant[TracerEvent](data) match + case TracerEvent.Update => bus.emit(Date.now()) + case TracerEvent.LogLine(l) => + logs.update(v => v.drop(v.length + 1 - 1000) :+ l) + case TracerEvent.LogLines(l) => + logs.update(v => v.drop(v.length + l.length - 1000) ++ l) + ) +end listenToWebsockets diff --git a/modules/tracer/shared/src/main/scala/Protocol.scala b/modules/tracer/shared/src/main/scala/Protocol.scala index 5d2ee1530..97f02e461 100644 --- a/modules/tracer/shared/src/main/scala/Protocol.scala +++ b/modules/tracer/shared/src/main/scala/Protocol.scala @@ -26,21 +26,24 @@ enum TracerEvent: object TracerEvent: given JsonValueCodec[TracerEvent] = JsonCodecMaker.make -enum Message: - case Request(method: String, id: MessageId) extends Message +enum Message(val id: MessageId): + case Request(method: String, override val id: MessageId, responded: Boolean) + extends Message(id) - case Response(id: MessageId) extends Message + case Response(override val id: MessageId, method: Option[String]) + extends Message(id) case Notification( generatedId: MessageId, method: String, direction: Direction - ) extends Message + ) extends Message(generatedId) def methodName: Option[String] = this match case r: Request => Some(r.method) case r: Notification => Some(r.method) - case _ => None + case r: Response => r.method + end Message object Message: @@ -60,10 +63,10 @@ object Message: case Some(id) => direction match case Direction.ToServer => - raw.method.map(Message.Request.apply(_, id)) + raw.method.map(Message.Request.apply(_, id, responded = false)) case Direction.ToClient => raw.method match - case None => Some(Message.Response(id)) + case None => Some(Message.Response(id, None)) case Some(what) => None end Message