diff --git a/workflow-trace-viewer/README.md b/workflow-trace-viewer/README.md index 57735624af..77cfc65ae0 100644 --- a/workflow-trace-viewer/README.md +++ b/workflow-trace-viewer/README.md @@ -10,6 +10,12 @@ It can be run via Gradle using: ./gradlew :workflow-trace-viewer:run ``` +By Default, the app will be in file parsing mode, where you are able to select a previously recorded workflow trace file for it to visualize the data. + +By hitting the bottom switch, you are able to toggle to live stream mode, where data is directly pulled from the emulator into the visualizer. A connection can only happen once. If there needs to be rerecording of the trace, the emulator must first be restarted, and then the app must be restarted as well. This is due to the fact that any open socket will consume all render pass data, meaning there is nothing to read from the emulator. + +It is ***important*** to run the emulator first before toggling to live mode. + ### Terminology **Trace**: A trace is a file — made up of frames — that contains the execution history of a Workflow. It includes information about render passes, how states have changed within workflows, and the specific props being passed through. The data collected to generate these should be in chronological order, and allows developers to step through the process easily. diff --git a/workflow-trace-viewer/api/workflow-trace-viewer.api b/workflow-trace-viewer/api/workflow-trace-viewer.api index 698647f649..377827f5c5 100644 --- a/workflow-trace-viewer/api/workflow-trace-viewer.api +++ b/workflow-trace-viewer/api/workflow-trace-viewer.api @@ -1,7 +1,3 @@ -public final class com/squareup/workflow1/traceviewer/AppKt { - public static final fun App (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V -} - public final class com/squareup/workflow1/traceviewer/ComposableSingletons$MainKt { public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/ComposableSingletons$MainKt; public static field lambda-1 Lkotlin/jvm/functions/Function3; diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt index e8b1d19639..968d8108f2 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -16,8 +17,9 @@ import androidx.compose.ui.geometry.Offset import com.squareup.workflow1.traceviewer.model.Node import com.squareup.workflow1.traceviewer.model.NodeUpdate import com.squareup.workflow1.traceviewer.ui.FrameSelectTab -import com.squareup.workflow1.traceviewer.ui.RenderDiagram import com.squareup.workflow1.traceviewer.ui.RightInfoPanel +import com.squareup.workflow1.traceviewer.ui.TraceModeToggleSwitch +import com.squareup.workflow1.traceviewer.util.RenderTrace import com.squareup.workflow1.traceviewer.util.SandboxBackground import com.squareup.workflow1.traceviewer.util.UploadFile import io.github.vinceglb.filekit.PlatformFile @@ -26,15 +28,18 @@ import io.github.vinceglb.filekit.PlatformFile * Main composable that provides the different layers of UI. */ @Composable -public fun App( +internal fun App( modifier: Modifier = Modifier ) { - var selectedTraceFile by remember { mutableStateOf(null) } var selectedNode by remember { mutableStateOf(null) } - var workflowFrames by remember { mutableStateOf>(emptyList()) } + val workflowFrames = remember { mutableStateListOf() } var frameIndex by remember { mutableIntStateOf(0) } val sandboxState = remember { SandboxState() } + // Default to File mode, and can be toggled to be in Live mode. + var traceMode by remember { mutableStateOf(TraceMode.File(null)) } + var selectedTraceFile by remember { mutableStateOf(null) } + LaunchedEffect(sandboxState) { snapshotFlow { frameIndex }.collect { sandboxState.reset() @@ -44,18 +49,29 @@ public fun App( Box( modifier = modifier ) { + fun resetStates() { + selectedTraceFile = null + selectedNode = null + frameIndex = 0 + workflowFrames.clear() + } + // Main content - if (selectedTraceFile != null) { - SandboxBackground( - sandboxState = sandboxState, - ) { - RenderDiagram( - traceFile = selectedTraceFile!!, + SandboxBackground( + sandboxState = sandboxState, + ) { + // if there is not a file selected and trace mode is live, then don't render anything. + val readyForFileTrace = traceMode is TraceMode.File && selectedTraceFile != null + val readyForLiveTrace = traceMode is TraceMode.Live + if (readyForFileTrace || readyForLiveTrace) { + RenderTrace( + traceSource = traceMode, frameInd = frameIndex, - onFileParse = { workflowFrames = it }, + onFileParse = { workflowFrames.addAll(it) }, onNodeSelect = { node, prevNode -> selectedNode = NodeUpdate(node, prevNode) - } + }, + onNewFrame = { frameIndex += 1 } ) } } @@ -73,16 +89,37 @@ public fun App( .align(Alignment.TopEnd) ) - // The states are reset when a new file is selected. - UploadFile( - resetOnFileSelect = { - selectedTraceFile = it - selectedNode = null - frameIndex = 0 - workflowFrames = emptyList() + TraceModeToggleSwitch( + onToggle = { + resetStates() + traceMode = if (traceMode is TraceMode.Live) { + frameIndex = 0 + TraceMode.File(null) + } else { + // TODO: TraceRecorder needs to be able to take in multiple clients if this is the case + /* + We set the frame to -1 here since we always increment it during Live mode as the list of + frames get populated, so we avoid off by one when indexing into the frames. + */ + frameIndex = -1 + TraceMode.Live + } }, - modifier = Modifier.align(Alignment.BottomStart) + traceMode = traceMode, + modifier = Modifier.align(Alignment.BottomCenter) ) + + // The states are reset when a new file is selected. + if (traceMode is TraceMode.File) { + UploadFile( + resetOnFileSelect = { + resetStates() + selectedTraceFile = it + traceMode = TraceMode.File(it) + }, + modifier = Modifier.align(Alignment.BottomStart) + ) + } } } @@ -92,6 +129,10 @@ internal class SandboxState { fun reset() { offset = Offset.Zero - scale = 1f } } + +internal sealed interface TraceMode { + data class File(val file: PlatformFile?) : TraceMode + data object Live : TraceMode +} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt index 485c98c12b..17954f90ca 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.window.singleWindowApplication * Main entry point for the desktop application, see [README.md] for more details. */ fun main() { - singleWindowApplication(title = "Workflow Trace Viewer") { + singleWindowApplication(title = "Workflow Trace Viewer", exitProcessOnExit = false) { App(Modifier.fillMaxSize()) } } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt index 1cdcc013b8..efe6f31c74 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -31,7 +32,12 @@ internal fun FrameSelectTab( modifier: Modifier = Modifier ) { val lazyListState = rememberLazyListState() - + if (currentIndex >= 0) { + LaunchedEffect(currentIndex) { + lazyListState.animateScrollToItem(currentIndex) + } + } + Surface( modifier = modifier, color = Color.White, diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt new file mode 100644 index 0000000000..0c88df899c --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt @@ -0,0 +1,48 @@ +package com.squareup.workflow1.traceviewer.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.squareup.workflow1.traceviewer.TraceMode + +@Composable +internal fun TraceModeToggleSwitch( + onToggle: () -> Unit, + traceMode: TraceMode, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Switch( + checked = traceMode is TraceMode.Live, + onCheckedChange = { + onToggle() + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.Black, + checkedTrackColor = Color.Black, + ) + ) + + Text( + text = if (traceMode is TraceMode.Live) { + "Live Mode" + } else { + "File Mode" + }, + fontSize = 12.sp, + fontStyle = FontStyle.Italic + ) + } +} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt index 7ece7092ef..d7bf164fd7 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt @@ -11,12 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -27,62 +21,6 @@ import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.unit.dp import com.squareup.workflow1.traceviewer.model.Node -import com.squareup.workflow1.traceviewer.util.ParseResult -import com.squareup.workflow1.traceviewer.util.parseTrace -import io.github.vinceglb.filekit.PlatformFile - -/** - * Access point for drawing the main content of the app. It will load the trace for given files and - * tabs. This will also all errors related to errors parsing a given trace JSON file. - */ -@Composable -internal fun RenderDiagram( - traceFile: PlatformFile, - frameInd: Int, - onFileParse: (List) -> Unit, - onNodeSelect: (Node, Node?) -> Unit, - modifier: Modifier = Modifier -) { - var isLoading by remember(traceFile) { mutableStateOf(true) } - var error by remember(traceFile) { mutableStateOf(null) } - var frames = remember { mutableStateListOf() } - var fullTree = remember { mutableStateListOf() } - var affectedNodes = remember { mutableStateListOf>() } - - LaunchedEffect(traceFile) { - val parseResult = parseTrace(traceFile) - - when (parseResult) { - is ParseResult.Failure -> { - error = parseResult.error - } - is ParseResult.Success -> { - val parsedFrames = parseResult.trace ?: emptyList() - frames.addAll(parsedFrames) - fullTree.addAll(parseResult.trees) - affectedNodes.addAll(parseResult.affectedNodes) - onFileParse(parsedFrames) - isLoading = false - } - } - } - - if (error != null) { - Text("Error parsing file: ${error?.message}") - return - } - - if (!isLoading) { - val previousFrame = if (frameInd > 0) fullTree[frameInd - 1] else null - DrawTree( - node = fullTree[frameInd], - previousNode = previousFrame, - affectedNodes = affectedNodes[frameInd], - expandedNodes = remember(frameInd) { mutableStateMapOf() }, - onNodeSelect = onNodeSelect, - ) - } -} /** * Since the workflow nodes present a tree structure, we utilize a recursive function to draw the tree @@ -92,7 +30,7 @@ internal fun RenderDiagram( * closed from user clicks. */ @Composable -private fun DrawTree( +internal fun DrawTree( node: Node, previousNode: Node?, affectedNodes: Set, diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt index 334ea59a01..abe6acaf02 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt @@ -9,7 +9,8 @@ import com.squareup.workflow1.traceviewer.model.addChild import com.squareup.workflow1.traceviewer.model.replaceChild import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.readString -import java.util.LinkedHashMap +import kotlin.reflect.jvm.javaType +import kotlin.reflect.typeOf /* The root workflow Node uses an ID of 0, and since we are filtering childrenByParent by the @@ -24,11 +25,11 @@ const val ROOT_ID: String = "-1" * @return A [ParseResult] representing result of parsing, either an error related to the * format of the JSON, or a success and a parsed trace. */ -internal suspend fun parseTrace( +internal suspend fun parseFileTrace( file: PlatformFile, ): ParseResult { val jsonString = file.readString() - val workflowAdapter = createMoshiAdapter() + val workflowAdapter = createMoshiAdapter>() val parsedRenderPasses = try { workflowAdapter.fromJson(jsonString) ?: return ParseResult.Failure( IllegalArgumentException("Provided trace file is empty or malformed.") @@ -44,21 +45,58 @@ internal suspend fun parseTrace( frameTrees.add(mergedTree) mergedTree } - return ParseResult.Success(parsedFrames, frameTrees, parsedRenderPasses) + return ParseResult.Success( + trace = parsedFrames, + trees = frameTrees, + affectedNodes = parsedRenderPasses + ) +} + +/** + * Parses a single render pass from a live trace stream. + * Similar to parseFileTrace but handles one render pass at a time. + * + * @return [ParseResult] containing the new frame, merged tree, and current render pass nodes. + */ +internal fun parseLiveTrace( + renderPass: String, + adapter: JsonAdapter>, + currentTree: Node? = null +): ParseResult { + val parsedRenderPass = try { + adapter.fromJson(renderPass) ?: return ParseResult.Failure( + IllegalArgumentException("Provided trace data is empty or malformed.") + ) + } catch (e: Exception) { + return ParseResult.Failure(e) + } + + val parsedFrame = getFrameFromRenderPass(parsedRenderPass) + + // Merge Frame into full tree if we have an existing tree + val mergedTree = if (currentTree == null) { + parsedFrame + } else { + mergeFrameIntoMainTree(parsedFrame, currentTree) + } + + // Since live tracing handles one frame at a time, we generalize and return listOf for each. + return ParseResult.Success( + trace = listOf(parsedFrame), + trees = listOf(mergedTree), + affectedNodes = listOf(parsedRenderPass) + ) } /** * Creates a Moshi adapter for parsing the JSON trace file. */ -private fun createMoshiAdapter(): JsonAdapter>> { +internal inline fun createMoshiAdapter(): JsonAdapter> { val moshi = Moshi.Builder() .add(KotlinJsonAdapterFactory()) .build() - val workflowList = Types.newParameterizedType( - List::class.java, - Types.newParameterizedType(List::class.java, Node::class.java) - ) - val adapter: JsonAdapter>> = moshi.adapter(workflowList) + val workflowList = Types.newParameterizedType(List::class.java, typeOf().javaType) + val adapter: JsonAdapter> = moshi.adapter(workflowList) return adapter } @@ -102,7 +140,6 @@ internal fun mergeFrameIntoMainTree( main: Node ): Node { require(frame.id == main.id) - val updatedNode = frame.copy(children = main.children) return frame.children.values.fold(updatedNode) { mergedTree, frameChild -> @@ -116,7 +153,7 @@ internal fun mergeFrameIntoMainTree( } internal sealed interface ParseResult { - class Success(val trace: List?, val trees: List, affectedNodes: List>) : ParseResult { + class Success(val trace: List, val trees: List, affectedNodes: List>) : ParseResult { val affectedNodes = affectedNodes.map { it.toSet() } } class Failure(val error: Throwable) : ParseResult diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt new file mode 100644 index 0000000000..61fbd74d90 --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SocketClient.kt @@ -0,0 +1,118 @@ +package com.squareup.workflow1.traceviewer.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext +import okio.IOException +import java.net.Socket + +internal suspend fun streamRenderPassesFromDevice(parseOnNewRenderPass: (String) -> Unit) { + val renderPassChannel: Channel = Channel(Channel.BUFFERED) + coroutineScope { + launch { + try { + pollSocket(onNewRenderPass = renderPassChannel::send) + } finally { + renderPassChannel.close() + } + } + + // Since channel implements ChannelIterator, we can for-loop through on the receiver end. + for (renderPass in renderPassChannel) { + parseOnNewRenderPass(renderPass) + } + } +} + +/** + * Collects data from a server socket and serves them back to the caller via callback. + * + * Two cases that are guaranteed to fail: + * 1) The app is not running + * 2) A reattempt at establishing socket connection without restarting the app + * + * @param onNewRenderPass is called from an arbitrary thread, so it is important to ensure that the + * caller is thread safe + */ +private suspend fun pollSocket(onNewRenderPass: suspend (String) -> Unit) { + withContext(Dispatchers.IO) { + try { + runForwardingPortThroughAdb { port -> + Socket("localhost", port).useWithCancellation { socket -> + val reader = socket.getInputStream().bufferedReader() + do { + ensureActive() + val input = reader.readLine() + if (input != null) { + onNewRenderPass(input) + } + } while (input != null) + } + } + } catch (e: IOException) { + // NoOp + } + } +} + +/** + * Force [pollSocket] to exit with exception if the coroutine is cancelled. See comment below. + */ +private suspend fun Socket.useWithCancellation(block: suspend (Socket) -> Unit) { + val socket = this + coroutineScope { + // This coroutine is responsible for forcibly closing the socket when the coroutine is + // cancelled. This causes any code reading from the socket to throw a CancellationException. + // We also need to explicitly cancel this coroutine if the block returns on its own, otherwise + // the coroutineScope will never exit. + val socketJob = launch { + socket.use { + awaitCancellation() + } + } + + block(socket) + socketJob.cancel() + } +} + +/** + * Call adb to setup a port forwarding to the server socket, and calls block with the allocated + * port number if successful. + * + * If block throws or returns on finish, the port forwarding is removed via adb (best effort). + */ +@Suppress("BlockingMethodInNonBlockingContext") +private suspend inline fun runForwardingPortThroughAdb(block: (port: Int) -> Unit) { + val process = ProcessBuilder( + "adb", "forward", "tcp:0", "localabstract:workflow-trace" + ).start() + + // The adb forward command will output the port number it picks to connect. + val forwardReturnCode = runInterruptible { + process.waitFor() + } + if (forwardReturnCode != 0) { + return + } + + val port = process.inputStream.bufferedReader().readText() + .trim().toInt() + + try { + block(port) + } finally { + // We don't care if this fails since there's nothing we can do then anyway. It just means + // there's an extra forward left open, but that's not a big deal. + runCatching { + ProcessBuilder( + "adb", "forward", "--remove", "tcp:$port" + ).start() + } + } +} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt new file mode 100644 index 0000000000..55d5d00c54 --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt @@ -0,0 +1,107 @@ +package com.squareup.workflow1.traceviewer.util + +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.squareup.moshi.JsonAdapter +import com.squareup.workflow1.traceviewer.TraceMode +import com.squareup.workflow1.traceviewer.model.Node +import com.squareup.workflow1.traceviewer.ui.DrawTree + +/** + * Handles parsing the trace's after JsonParser has turned all render passes into frames. Also calls + * the UI composables to render the full trace. + * + * This handles either File or Live trace modes, and will parse equally + */ +@Composable +internal fun RenderTrace( + traceSource: TraceMode, + frameInd: Int, + onFileParse: (List) -> Unit, + onNodeSelect: (Node, Node?) -> Unit, + onNewFrame: () -> Unit, + modifier: Modifier = Modifier +) { + var isLoading by remember(traceSource) { mutableStateOf(true) } + var error by remember(traceSource) { mutableStateOf(null) } + val frames = remember { mutableStateListOf() } + val fullTree = remember { mutableStateListOf() } + val affectedNodes = remember { mutableStateListOf>() } + + // Updates current state with the new data from trace source. + fun addToStates(frame: List, tree: List, affected: List>) { + frames.addAll(frame) + fullTree.addAll(tree) + affectedNodes.addAll(affected) + isLoading = false + onFileParse(frame) + } + + // Handles the result of parsing a trace, either from file or live. Live mode includes callback + // for when a new frame is received. + fun handleParseResult( + parseResult: ParseResult, + onNewFrame: (() -> Unit)? = null + ) { + when (parseResult) { + is ParseResult.Failure -> { + error = parseResult.error.toString() + } + + is ParseResult.Success -> { + addToStates( + frame = parseResult.trace, + tree = parseResult.trees, + affected = parseResult.affectedNodes + ) + onNewFrame?.invoke() + } + } + } + + LaunchedEffect(traceSource) { + when (traceSource) { + is TraceMode.File -> { + checkNotNull(traceSource.file) { + "TraceMode.File should have a non-null file to parse." + } + val parseResult = parseFileTrace(traceSource.file) + handleParseResult(parseResult) + } + + is TraceMode.Live -> { + val adapter: JsonAdapter> = createMoshiAdapter() + streamRenderPassesFromDevice { renderPass -> + val currentTree = fullTree.lastOrNull() + val parseResult = parseLiveTrace(renderPass, adapter, currentTree) + handleParseResult(parseResult, onNewFrame) + } + error = "Socket has already been closed or is not available." + } + } + } + + if (error != null) { + Text("Error parsing: $error") + return + } + + if (!isLoading) { + val previousFrame = if (frameInd > 0) fullTree[frameInd - 1] else null + DrawTree( + node = fullTree[frameInd], + previousNode = previousFrame, + affectedNodes = affectedNodes[frameInd], + expandedNodes = remember(frameInd) { mutableStateMapOf() }, + onNodeSelect = onNodeSelect, + ) + } +}