diff --git a/dependencies/classpath.txt b/dependencies/classpath.txt
index e3a47bd6a..27785a8e0 100644
--- a/dependencies/classpath.txt
+++ b/dependencies/classpath.txt
@@ -163,11 +163,11 @@ org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21
org.jetbrains.kotlin:kotlin-gradle-plugins-bom:2.0.21
org.jetbrains.kotlin:kotlin-klib-commonizer-api:2.0.21
org.jetbrains.kotlin:kotlin-native-utils:2.0.21
-org.jetbrains.kotlin:kotlin-reflect:2.0.20
+org.jetbrains.kotlin:kotlin-reflect:2.0.21
org.jetbrains.kotlin:kotlin-serialization:2.0.21
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.0.21
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.0.21
-org.jetbrains.kotlin:kotlin-stdlib:2.0.20
+org.jetbrains.kotlin:kotlin-stdlib:2.0.21
org.jetbrains.kotlin:kotlin-tooling-core:2.0.21
org.jetbrains.kotlin:kotlin-util-io:2.0.21
org.jetbrains.kotlin:kotlin-util-klib:2.0.21
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 2a3999324..08667b88e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -97,7 +97,6 @@ turbine = "1.0.0"
vanniktech-publish = "0.32.0"
[plugins]
-
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index bbb57cf06..2733ed5dc 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/workflow-core/dependencies/jvmMainRuntimeClasspath.txt b/workflow-core/dependencies/jvmMainRuntimeClasspath.txt
index 16c625fdd..c6198373d 100644
--- a/workflow-core/dependencies/jvmMainRuntimeClasspath.txt
+++ b/workflow-core/dependencies/jvmMainRuntimeClasspath.txt
@@ -1,11 +1,11 @@
com.squareup.okio:okio-jvm:3.3.0
com.squareup.okio:okio:3.3.0
-org.jetbrains.kotlin:kotlin-bom:2.1.21
-org.jetbrains.kotlin:kotlin-stdlib-common:2.1.21
-org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.21
-org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.21
-org.jetbrains.kotlin:kotlin-stdlib:2.1.21
-org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3
-org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3
-org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3
+org.jetbrains.kotlin:kotlin-bom:2.2.0
+org.jetbrains.kotlin:kotlin-stdlib-common:2.2.0
+org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.0
+org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.0
+org.jetbrains.kotlin:kotlin-stdlib:2.2.0
+org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0
+org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0
+org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0
org.jetbrains:annotations:23.0.0
diff --git a/workflow-runtime/dependencies/jvmMainRuntimeClasspath.txt b/workflow-runtime/dependencies/jvmMainRuntimeClasspath.txt
index 16c625fdd..4edfcd59d 100644
--- a/workflow-runtime/dependencies/jvmMainRuntimeClasspath.txt
+++ b/workflow-runtime/dependencies/jvmMainRuntimeClasspath.txt
@@ -1,11 +1,22 @@
-com.squareup.okio:okio-jvm:3.3.0
-com.squareup.okio:okio:3.3.0
-org.jetbrains.kotlin:kotlin-bom:2.1.21
-org.jetbrains.kotlin:kotlin-stdlib-common:2.1.21
-org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.21
-org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.21
-org.jetbrains.kotlin:kotlin-stdlib:2.1.21
-org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3
-org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3
-org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3
+com.fasterxml.jackson.core:jackson-annotations:2.12.7
+com.fasterxml.jackson.core:jackson-core:2.12.7
+com.fasterxml.jackson.core:jackson-databind:2.12.7.1
+com.fasterxml.jackson.module:jackson-module-kotlin:2.12.7
+com.fasterxml.jackson:jackson-bom:2.12.7
+org.freemarker:freemarker:2.3.32
+org.jetbrains.dokka:all-modules-page-plugin:2.0.0
+org.jetbrains.dokka:analysis-markdown:2.0.0
+org.jetbrains.dokka:dokka-base:2.0.0
+org.jetbrains.dokka:templating-plugin:2.0.0
+org.jetbrains.kotlin:kotlin-bom:2.2.0
+org.jetbrains.kotlin:kotlin-reflect:2.2.0
+org.jetbrains.kotlin:kotlin-stdlib-common:2.2.0
+org.jetbrains.kotlin:kotlin-stdlib:2.2.0
+org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0
+org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0
+org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0
+org.jetbrains.kotlinx:kotlinx-html-jvm:0.9.1
org.jetbrains:annotations:23.0.0
+org.jetbrains:markdown-jvm:0.7.3
+org.jetbrains:markdown:0.7.3
+org.jsoup:jsoup:1.16.1
diff --git a/workflow-trace-viewer/README.md b/workflow-trace-viewer/README.md
index 77cfc65ae..9f5fd1896 100644
--- a/workflow-trace-viewer/README.md
+++ b/workflow-trace-viewer/README.md
@@ -1,6 +1,8 @@
# Workflow Trace Viewer
-A Compose for Desktop app that can be used to view Workflow traces.
+A Compose for Desktop application that visualizes and debugs Workflow execution traces. This tool
+helps developers understand the hierarchical structure and execution flow of their Workflow
+applications by providing both file-based and live streaming trace visualization.
## Running
@@ -10,20 +12,75 @@ 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.
+## Usage Guide
-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.
+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. Once the workflow tree is rendered
+in [File](#file-mode) or [Live](#live-mode) mode, you can switch frames (
+see [Terminology](#Terminology)) to see different events that fired. All nodes are color coded based
+on what had happened during this frame, and a text diff will show the specific changes. You can open
+the right node panel and left click a box get a more detailed view of the specific node, or right
+click to expand/collapse a specific node's children.
-It is ***important*** to run the emulator first before toggling to live mode.
+
+
+#### File Mode
+
+Once a file of the live data is saved, it can easily be uploaded to retrace the steps taken during
+the live session. Currently, text/json files that are saved from recordings only contain raw data,
+meaning it is simply a list of lists of node renderings.
+
+
+
+#### Live Mode
+
+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. To do so:
+
+1. Start the app (on any device)
+2. Start the app, and toggle the switch to enter Live mode
+3. Select the desired device
+
+Once in Live mode, frames will appear as you interact with the app. You may also save the current
+data into a file saved in `~/Downloads` to be used later (this action will take some time, so it may
+not appear immediately)
+
+Render pass data is passively stored in a buffer before being sent to the visualizer, so you do not
+need to immediately open/run the app to "catch" everything. However, since the the buffer has
+limited size, it's strongly recommended to avoid interacting with the app — beyond starting it —
+before Live mode has been triggered; this helps to avoid losing data.
+
+
+
+### Note
+
+A connection can only happen once. There is currently no support for a recording of the trace data
+due to the fact that an open socket will consume all render pass data when a connection begins. To
+restart the recording:
+
+1. (optional) Save the current trace
+2. Switch out of Live mode
+3. Restart the app
+4. Switch back to Live mode, and the
### 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.
+`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.
-**Frame**: Essentially a "snapshot" of the current "state" of the whole Workflow tree. It contains relevant information about the changes in workflow states and how props are passed throughout.
+`Frame`: Essentially a "snapshot" of the current "state" of the whole Workflow tree. It contains
+relevant information about the changes in workflow states and how props are passed throughout.
-- Note that "snapshot" and "state" are different from `snapshotState` and `State`, which are idiomatic to the Workflow library.
+- Note that "snapshot" and "state" are different from `snapshotState` and `State`, which are
+ idiomatic to the Workflow library.
### External Libraries
-[FileKit](https://github.com/vinceglb/FileKit) is an external library made to apply file operations on Kotlin and KMP projects. It's purpose in this app is to allow developers to upload their own json trace files. The motivation for its use is to quickly implement a file picker.
+[FileKit](https://github.com/vinceglb/FileKit) is an external library made to apply file operations
+on Kotlin and KMP projects. This simplified the development process of allowing file selection
+
+## Future
+
+This app can be integrated into the process of anyone working with Workflow, so it's highly
+encouraged for anyone to make improvements that makes their life a little easier using this app.
diff --git a/workflow-trace-viewer/api/workflow-trace-viewer.api b/workflow-trace-viewer/api/workflow-trace-viewer.api
index 377827f5c..0f7504f36 100644
--- a/workflow-trace-viewer/api/workflow-trace-viewer.api
+++ b/workflow-trace-viewer/api/workflow-trace-viewer.api
@@ -10,13 +10,27 @@ public final class com/squareup/workflow1/traceviewer/MainKt {
public static synthetic fun main ([Ljava/lang/String;)V
}
+public final class com/squareup/workflow1/traceviewer/model/NodeState : java/lang/Enum {
+ public static final field CHILDREN_CHANGED Lcom/squareup/workflow1/traceviewer/model/NodeState;
+ public static final field NEW Lcom/squareup/workflow1/traceviewer/model/NodeState;
+ public static final field PROPS_CHANGED Lcom/squareup/workflow1/traceviewer/model/NodeState;
+ public static final field STATE_CHANGED Lcom/squareup/workflow1/traceviewer/model/NodeState;
+ public static final field UNCHANGED Lcom/squareup/workflow1/traceviewer/model/NodeState;
+ public final fun getColor-0d7_KjU ()J
+ public static fun getEntries ()Lkotlin/enums/EnumEntries;
+ public static fun valueOf (Ljava/lang/String;)Lcom/squareup/workflow1/traceviewer/model/NodeState;
+ public static fun values ()[Lcom/squareup/workflow1/traceviewer/model/NodeState;
+}
+
+public final class com/squareup/workflow1/traceviewer/ui/ColorLegendKt {
+ public static final fun ColorLegend (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V
+}
+
public final class com/squareup/workflow1/traceviewer/ui/ComposableSingletons$WorkflowInfoPanelKt {
public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/ui/ComposableSingletons$WorkflowInfoPanelKt;
public static field lambda-1 Lkotlin/jvm/functions/Function3;
- public static field lambda-2 Lkotlin/jvm/functions/Function3;
public fun ()V
public final fun getLambda-1$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3;
- public final fun getLambda-2$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3;
}
public final class com/squareup/workflow1/traceviewer/util/ComposableSingletons$UploadFileKt {
@@ -26,6 +40,20 @@ public final class com/squareup/workflow1/traceviewer/util/ComposableSingletons$
public final fun getLambda-1$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3;
}
+public final class com/squareup/workflow1/traceviewer/util/DiffStyles {
+ public static final field $stable I
+ public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/util/DiffStyles;
+ public final fun buildStringWithStyle (Landroidx/compose/ui/text/SpanStyle;Ljava/lang/String;Landroidx/compose/ui/text/AnnotatedString$Builder;)V
+ public final fun getDELETE ()Landroidx/compose/ui/text/SpanStyle;
+ public final fun getINSERT ()Landroidx/compose/ui/text/SpanStyle;
+ public final fun getNO_CHANGE ()Landroidx/compose/ui/text/SpanStyle;
+ public final fun getUNCHANGED ()Landroidx/compose/ui/text/SpanStyle;
+}
+
+public final class com/squareup/workflow1/traceviewer/util/DiffUtilsKt {
+ public static final fun computeAnnotatedDiff (Ljava/lang/String;Ljava/lang/String;)Landroidx/compose/ui/text/AnnotatedString;
+}
+
public final class com/squareup/workflow1/traceviewer/util/JsonParserKt {
public static final field ROOT_ID Ljava/lang/String;
}
diff --git a/workflow-trace-viewer/build.gradle.kts b/workflow-trace-viewer/build.gradle.kts
index cee4e4e65..6bfd41a3e 100644
--- a/workflow-trace-viewer/build.gradle.kts
+++ b/workflow-trace-viewer/build.gradle.kts
@@ -15,6 +15,7 @@ kotlin {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
+ implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
@@ -25,6 +26,7 @@ kotlin {
implementation(compose.materialIconsExtended)
implementation(libs.squareup.moshi.kotlin)
implementation(libs.filekit.dialogs.compose)
+ implementation(libs.java.diff.utils)
}
}
jvmTest {
diff --git a/workflow-trace-viewer/docs/demo.gif b/workflow-trace-viewer/docs/demo.gif
new file mode 100644
index 000000000..fef58817d
Binary files /dev/null and b/workflow-trace-viewer/docs/demo.gif differ
diff --git a/workflow-trace-viewer/docs/file_mode.gif b/workflow-trace-viewer/docs/file_mode.gif
new file mode 100644
index 000000000..283f7f3da
Binary files /dev/null and b/workflow-trace-viewer/docs/file_mode.gif differ
diff --git a/workflow-trace-viewer/docs/live_mode.gif b/workflow-trace-viewer/docs/live_mode.gif
new file mode 100644
index 000000000..b9308f2a8
Binary files /dev/null and b/workflow-trace-viewer/docs/live_mode.gif differ
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 968d8108f..b4c705e7c 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
@@ -1,27 +1,37 @@
package com.squareup.workflow1.traceviewer
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
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.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.dp
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.RightInfoPanel
-import com.squareup.workflow1.traceviewer.ui.TraceModeToggleSwitch
-import com.squareup.workflow1.traceviewer.util.RenderTrace
+import com.squareup.workflow1.traceviewer.ui.control.DisplayDevices
+import com.squareup.workflow1.traceviewer.ui.control.FileDump
+import com.squareup.workflow1.traceviewer.ui.control.FrameNavigator
+import com.squareup.workflow1.traceviewer.ui.control.SearchBox
+import com.squareup.workflow1.traceviewer.ui.control.TraceModeToggleSwitch
+import com.squareup.workflow1.traceviewer.ui.control.UploadFile
import com.squareup.workflow1.traceviewer.util.SandboxBackground
-import com.squareup.workflow1.traceviewer.util.UploadFile
+import com.squareup.workflow1.traceviewer.util.parser.RenderTrace
import io.github.vinceglb.filekit.PlatformFile
/**
@@ -31,14 +41,20 @@ import io.github.vinceglb.filekit.PlatformFile
internal fun App(
modifier: Modifier = Modifier
) {
+ var appWindowSize by remember { mutableStateOf(IntSize(0, 0)) }
var selectedNode by remember { mutableStateOf(null) }
- val workflowFrames = remember { mutableStateListOf() }
+ var frameSize by remember { mutableIntStateOf(0) }
+ var rawRenderPass by remember { mutableStateOf("") }
var frameIndex by remember { mutableIntStateOf(0) }
val sandboxState = remember { SandboxState() }
+ val nodeLocations = remember { mutableListOf>() }
// Default to File mode, and can be toggled to be in Live mode.
+ var active by remember { mutableStateOf(false) }
var traceMode by remember { mutableStateOf(TraceMode.File(null)) }
var selectedTraceFile by remember { mutableStateOf(null) }
+ // frameIndex is set to -1 when app is in Live Mode, so we increment it by one to avoid off-by-one errors
+ val frameInd = if (traceMode is TraceMode.Live) frameIndex + 1 else frameIndex
LaunchedEffect(sandboxState) {
snapshotFlow { frameIndex }.collect {
@@ -47,47 +63,77 @@ internal fun App(
}
Box(
- modifier = modifier
+ modifier = modifier.onGloballyPositioned {
+ appWindowSize = it.size
+ }
) {
fun resetStates() {
selectedTraceFile = null
selectedNode = null
frameIndex = 0
- workflowFrames.clear()
+ frameSize = 0
+ active = false
+ nodeLocations.clear()
}
// Main content
SandboxBackground(
+ appWindowSize = appWindowSize,
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
+ val readyForFileTrace = TraceMode.validateFileMode(traceMode)
+ val readyForLiveTrace = TraceMode.validateLiveMode(traceMode)
+
if (readyForFileTrace || readyForLiveTrace) {
+ active = true
RenderTrace(
traceSource = traceMode,
frameInd = frameIndex,
- onFileParse = { workflowFrames.addAll(it) },
- onNodeSelect = { node, prevNode ->
- selectedNode = NodeUpdate(node, prevNode)
- },
- onNewFrame = { frameIndex += 1 }
+ onFileParse = { frameSize += it },
+ onNodeSelect = { selectedNode = it },
+ onNewFrame = { frameIndex += 1 },
+ onNewData = { rawRenderPass += "$it," },
+ storeNodeLocation = { node, loc -> nodeLocations[frameInd] += (node to loc) }
)
}
}
- FrameSelectTab(
- frames = workflowFrames,
- currentIndex = frameIndex,
- onIndexChange = { frameIndex = it },
- modifier = Modifier.align(Alignment.TopCenter)
- )
-
- RightInfoPanel(
- selectedNode = selectedNode,
+ Column(
modifier = Modifier
- .align(Alignment.TopEnd)
- )
+ .align(Alignment.TopCenter)
+ .padding(top = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ if (active) {
+ // Since we can jump from frame to frame, we fill in the map during each recomposition
+ if (nodeLocations.getOrNull(frameInd) == null) {
+ // frameSize has not been updated yet, so on the first frame, frameSize = nodeLocations.size = 0,
+ // and it will append a new map
+ while (nodeLocations.size <= frameSize) {
+ nodeLocations.add(mutableStateMapOf())
+ }
+ }
+
+ SearchBox(
+ nodes = nodeLocations[frameInd].keys.toList(),
+ onSearch = { name ->
+ sandboxState.scale = 1f
+ val node = nodeLocations[frameInd].keys.firstOrNull { it.name == name }
+ val newX = sandboxState.offset.x - nodeLocations[frameInd][node]!!.x + appWindowSize.width / 2
+ val newY = sandboxState.offset.y - nodeLocations[frameInd][node]!!.y + appWindowSize.height / 2
+ sandboxState.offset = Offset(x = newX, y = newY)
+ },
+ )
+
+ FrameNavigator(
+ totalFrames = frameSize,
+ currentIndex = frameIndex,
+ onIndexChange = { frameIndex = it },
+ )
+ }
+ }
TraceModeToggleSwitch(
onToggle = {
@@ -96,13 +142,12 @@ internal fun App(
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
+ TraceMode.Live()
}
},
traceMode = traceMode,
@@ -120,6 +165,29 @@ internal fun App(
modifier = Modifier.align(Alignment.BottomStart)
)
}
+
+ if (traceMode is TraceMode.Live) {
+ if ((traceMode as TraceMode.Live).device == null) {
+ DisplayDevices(
+ onDeviceSelect = { selectedDevice ->
+ traceMode = TraceMode.Live(selectedDevice)
+ },
+ devices = listDevices(),
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+
+ FileDump(
+ trace = rawRenderPass,
+ modifier = Modifier.align(Alignment.BottomStart)
+ )
+ }
+
+ RightInfoPanel(
+ selectedNode = selectedNode,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ )
}
}
@@ -134,5 +202,25 @@ internal class SandboxState {
internal sealed interface TraceMode {
data class File(val file: PlatformFile?) : TraceMode
- data object Live : TraceMode
+ data class Live(val device: String? = null) : TraceMode
+
+ companion object {
+ fun validateLiveMode(traceMode: TraceMode): Boolean {
+ return traceMode is Live && traceMode.device != null
+ }
+
+ fun validateFileMode(traceMode: TraceMode): Boolean {
+ return traceMode is File && traceMode.file != null
+ }
+ }
+}
+
+/**
+ * Allows users to select from multiple devices that are currently running.
+ */
+internal fun listDevices(): List {
+ val process = ProcessBuilder("adb", "devices", "-l").start()
+ process.waitFor()
+ // We drop the header "List of devices attached"
+ return process.inputStream.bufferedReader().readLines().drop(1).dropLast(1)
}
diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/Node.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/Node.kt
index 30f000e28..82a84c418 100644
--- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/Node.kt
+++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/Node.kt
@@ -31,6 +31,21 @@ internal data class Node(
override fun hashCode(): Int {
return id.hashCode()
}
+
+ companion object {
+ fun getNodeFields(): List {
+ return listOf("Props", "State", "Rendering")
+ }
+
+ fun getNodeData(node: Node, field: String): String {
+ return when (field.lowercase()) {
+ "props" -> node.props
+ "state" -> node.state
+ "rendering" -> node.rendering
+ else -> throw IllegalArgumentException("Unknown field: $field")
+ }
+ }
+ }
}
internal fun Node.addChild(child: Node): Node {
diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/NodeUpdate.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/NodeUpdate.kt
index c97557757..87c562e6c 100644
--- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/NodeUpdate.kt
+++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/NodeUpdate.kt
@@ -1,12 +1,38 @@
package com.squareup.workflow1.traceviewer.model
+import androidx.compose.ui.graphics.Color
+
/**
* Represents the difference between the current and previous state of a node in the workflow trace.
- * This will be what is passed as a state between UI to display the diff.
+ * This will be what is passed as a state between UI to display the diff. The states all have an
+ * associated color
*
- * If it's the first node in the frame, [previous] will be null and there is no difference to show.
+ * If it's the first node in the frame, [past] will be null and there is no difference to show.
*/
-internal class NodeUpdate(
+internal data class NodeUpdate(
val current: Node,
- val previous: Node?,
-)
+ val past: Node?,
+ val state: NodeState
+) {
+ companion object {
+ fun create(current: Node, past: Node?, isAffected: Boolean): NodeUpdate {
+ val state = when {
+ !isAffected -> NodeState.UNCHANGED
+ past == null -> NodeState.NEW
+ current.props != past.props -> NodeState.PROPS_CHANGED
+ current.state != past.state -> NodeState.STATE_CHANGED
+ else -> NodeState.CHILDREN_CHANGED
+ }
+
+ return NodeUpdate(current, past, state)
+ }
+ }
+}
+
+enum class NodeState(val color: Color) {
+ NEW(Color(0x804CAF50)), // green
+ STATE_CHANGED(Color(0xFFE57373)), // red
+ PROPS_CHANGED(Color(0xFFFF8A65)), // orange
+ CHILDREN_CHANGED(Color(0x802196F3)), // blue
+ UNCHANGED(Color.LightGray.copy(alpha = 0.3f)),
+}
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
deleted file mode 100644
index efe6f31c7..000000000
--- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-package com.squareup.workflow1.traceviewer.ui
-
-import androidx.compose.foundation.MutatePriority
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.awaitEachGesture
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.rememberLazyListState
-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
-import androidx.compose.ui.input.pointer.PointerEventType
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.unit.dp
-import com.squareup.workflow1.traceviewer.model.Node
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.launch
-
-/**
- * A trace tab selector that allows devs to switch between different states within the provided trace.
- */
-@Composable
-internal fun FrameSelectTab(
- frames: List,
- currentIndex: Int,
- onIndexChange: (Int) -> Unit,
- modifier: Modifier = Modifier
-) {
- val lazyListState = rememberLazyListState()
- if (currentIndex >= 0) {
- LaunchedEffect(currentIndex) {
- lazyListState.animateScrollToItem(currentIndex)
- }
- }
-
- Surface(
- modifier = modifier,
- color = Color.White,
- ) {
- LazyRow(
- state = lazyListState,
- modifier = Modifier
- .padding(8.dp)
- .pointerInput(Unit) {
- coroutineScope {
- awaitEachGesture {
- val event = awaitPointerEvent()
- if (event.type == PointerEventType.Scroll) {
- val scrollDeltaY = event.changes.first().scrollDelta.y
- launch {
- lazyListState.scroll(MutatePriority.Default) {
- scrollBy(scrollDeltaY * 10f)
- }
- }
- }
- }
- }
- },
- ) {
- items(frames.size) { index ->
- Text(
- text = "Frame ${index + 1}",
- color = if (index == currentIndex) Color.Black else Color.LightGray,
- modifier = Modifier
- .clip(RoundedCornerShape(16.dp))
- .clickable { onIndexChange(index) }
- .padding(10.dp)
- )
- }
- }
- }
-}
diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowInfoPanel.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowInfoPanel.kt
index bcf09fddc..1308bc892 100644
--- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowInfoPanel.kt
+++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowInfoPanel.kt
@@ -12,12 +12,14 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons.AutoMirrored.Filled
+import androidx.compose.material.icons.Icons.AutoMirrored.Rounded
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.runtime.Composable
@@ -27,14 +29,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.squareup.workflow1.traceviewer.model.Node
import com.squareup.workflow1.traceviewer.model.NodeUpdate
-import kotlin.reflect.full.memberProperties
+import com.squareup.workflow1.traceviewer.util.parser.computeAnnotatedDiff
/**
* A panel that displays information about the selected workflow node.
@@ -55,6 +58,9 @@ internal fun RightInfoPanel(
IconButton(
onClick = { panelOpen = !panelOpen },
modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(Color.White)
.padding(8.dp)
.size(40.dp)
.align(Alignment.Top)
@@ -98,19 +104,33 @@ private fun NodePanelDetails(
}
item {
Text(
- text = "Workflow Details",
- style = MaterialTheme.typography.h6,
- modifier = Modifier.padding(top = 8.dp, bottom = 8.dp)
+ text = "${node.current.parent} (ID: ${node.current.parentId})",
+ style = MaterialTheme.typography.subtitle2,
+ color = Color.Gray,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ Text(
+ text = "↳",
+ style = MaterialTheme.typography.subtitle1,
+ color = Color.Gray,
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ Text(
+ text = "${node.current.name} (ID: ${node.current.id})",
+ style = MaterialTheme.typography.h5,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(8.dp),
+ textAlign = TextAlign.Center
)
}
- val fields = Node::class.memberProperties
+ val fields = Node.getNodeFields()
for (field in fields) {
- val currVal = field.get(node.current).toString()
- val pastVal = if (node.previous != null) field.get(node.previous).toString() else null
+ val currVal = Node.getNodeData(node.current, field)
+ val pastVal = if (node.past != null) Node.getNodeData(node.past, field) else null
item {
DetailCard(
- label = field.name,
+ label = field,
currValue = currVal,
pastValue = pastVal
)
@@ -149,19 +169,39 @@ private fun DetailCard(
text = label,
style = MaterialTheme.typography.h6,
color = Color.Black,
- fontWeight = FontWeight.Medium
+ fontWeight = FontWeight.Bold,
)
if (!open) {
return@Card
}
- Spacer(modifier = Modifier.height(4.dp))
if (pastValue != null) {
Column {
Text(
- text = "Before:",
- style = TextStyle(fontStyle = FontStyle.Italic),
- color = Color.Black,
+ text = "Changes",
+ style = MaterialTheme.typography.subtitle1,
+ color = Color.Gray,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ text = computeAnnotatedDiff(pastValue, currValue),
+ style = MaterialTheme.typography.body2,
+ modifier = Modifier
+ .padding(top = 8.dp)
+ .align(Alignment.CenterHorizontally)
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
+ maxLines = 1,
+ overflow = TextOverflow.Clip
+ )
+
+ Text(
+ text = "Before",
+ style = MaterialTheme.typography.subtitle1,
+ color = Color.Gray,
fontWeight = FontWeight.Medium
)
Text(
@@ -169,11 +209,13 @@ private fun DetailCard(
style = MaterialTheme.typography.body2,
color = Color.Black
)
- Spacer(modifier = Modifier.height(8.dp))
+
+ Spacer(modifier = Modifier.height(16.dp))
+
Text(
- text = "After:",
- style = TextStyle(fontStyle = FontStyle.Italic),
- color = Color.Black,
+ text = "After",
+ style = MaterialTheme.typography.subtitle1,
+ color = Color.Gray,
fontWeight = FontWeight.Medium
)
Text(
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 d7bf164fd..645a3bf54 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
@@ -2,25 +2,39 @@ package com.squareup.workflow1.traceviewer.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.hoverable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventType
-import androidx.compose.ui.input.pointer.isPrimaryPressed
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.onPointerEvent
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import com.squareup.workflow1.traceviewer.model.Node
+import com.squareup.workflow1.traceviewer.model.NodeState
+import com.squareup.workflow1.traceviewer.model.NodeUpdate
/**
* Since the workflow nodes present a tree structure, we utilize a recursive function to draw the tree
@@ -32,17 +46,20 @@ import com.squareup.workflow1.traceviewer.model.Node
@Composable
internal fun DrawTree(
node: Node,
- previousNode: Node?,
+ previousFrameNode: Node?,
affectedNodes: Set,
expandedNodes: MutableMap,
- onNodeSelect: (Node, Node?) -> Unit,
+ onNodeSelect: (NodeUpdate) -> Unit,
+ storeNodeLocation: (Node, Offset) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier
- .padding(5.dp)
- .border(1.dp, Color.Black)
- .fillMaxSize(),
+ .padding(6.dp)
+ .fillMaxSize()
+ .then(
+ if (node.children.isNotEmpty()) Modifier.border(3.dp, Color.Black) else Modifier
+ ),
horizontalAlignment = Alignment.CenterHorizontally,
) {
val isAffected = affectedNodes.contains(node)
@@ -53,33 +70,208 @@ internal fun DrawTree(
val isExpanded = expandedNodes[node.id] == true
DrawNode(
- node,
- previousNode,
- isAffected,
- isExpanded,
- onNodeSelect,
- onExpandToggle = { expandedNodes[node.id] = !expandedNodes[node.id]!! }
+ node = node,
+ nodePast = previousFrameNode,
+ isAffected = isAffected,
+ isExpanded = isExpanded,
+ onNodeSelect = onNodeSelect,
+ onExpandToggle = { expandedNodes[node.id] = !expandedNodes[node.id]!! },
+ storeNodeLocation = storeNodeLocation
)
- // Draws the node's children recursively.
if (isExpanded) {
+ val (affectedChildren, unaffectedChildren) = node.children.values
+ .partition { affectedNodes.contains(it) }
+
+ UnaffectedChildrenGroup(
+ node = node,
+ children = unaffectedChildren,
+ previousFrameNode = previousFrameNode,
+ affectedNodes = affectedNodes,
+ expandedNodes = expandedNodes,
+ onNodeSelect = onNodeSelect,
+ storeNodeLocation = storeNodeLocation
+ )
+
+ AffectedChildrenGroup(
+ children = affectedChildren,
+ previousFrameNode = previousFrameNode,
+ affectedNodes = affectedNodes,
+ expandedNodes = expandedNodes,
+ onNodeSelect = onNodeSelect,
+ storeNodeLocation = storeNodeLocation
+ )
+ }
+ }
+}
+
+/**
+ * Draws the group of unaffected children, which can be open and closed to expand/collapse them.
+ *
+ * If an unaffected children also has other children, it cannot be opened since the this group
+ * treats all nodes as one entity. The right click for the whole group overrides the right click for
+ * the individual nodes.
+ */
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+private fun UnaffectedChildrenGroup(
+ node: Node,
+ children: List,
+ previousFrameNode: Node?,
+ affectedNodes: Set,
+ expandedNodes: MutableMap,
+ onNodeSelect: (NodeUpdate) -> Unit,
+ storeNodeLocation: (Node, Offset) -> Unit
+) {
+ if (children.isEmpty()) return
+
+ val groupKey = "${node.id}_unaffected_group"
+ LaunchedEffect(Unit) {
+ expandedNodes[groupKey] = false
+ }
+ val isGroupExpanded = expandedNodes[groupKey] == true
+
+ Box(
+ modifier = Modifier
+ .onPointerEvent(PointerEventType.Press) {
+ if (it.buttons.isSecondaryPressed) {
+ expandedNodes[groupKey] = !isGroupExpanded
+ }
+ }
+ ) {
+ if (!isGroupExpanded) {
+ Column(
+ modifier = Modifier
+ .background(Color.LightGray.copy(alpha = 0.3f), shape = RoundedCornerShape(4.dp))
+ .border(1.dp, Color.Gray)
+ .padding(8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(text = "${node.name}'s", color = Color.DarkGray)
+ Text(
+ text = "${children.size} unaffected children",
+ color = Color.DarkGray,
+ fontSize = 12.sp
+ )
+ }
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(6.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ DrawChildrenInGroups(
+ children = children,
+ previousFrameNode = previousFrameNode,
+ affectedNodes = affectedNodes,
+ expandedNodes = expandedNodes,
+ unaffected = true,
+ onNodeSelect = onNodeSelect,
+ storeNodeLocation = storeNodeLocation
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Draws the group of affected children
+ */
+@Composable
+private fun AffectedChildrenGroup(
+ children: List,
+ previousFrameNode: Node?,
+ affectedNodes: Set,
+ expandedNodes: MutableMap,
+ onNodeSelect: (NodeUpdate) -> Unit,
+ storeNodeLocation: (Node, Offset) -> Unit
+) {
+ if (children.isEmpty()) return
+
+ DrawChildrenInGroups(
+ children = children,
+ previousFrameNode = previousFrameNode,
+ affectedNodes = affectedNodes,
+ expandedNodes = expandedNodes,
+ onNodeSelect = onNodeSelect,
+ storeNodeLocation = storeNodeLocation
+ )
+}
+
+/**
+ * Draws the children in a grid manner, to avoid horizontal clutter and make better use of space.
+ *
+ * Unaffected children group would call this with `unaffected = true`, which means that simple/nested
+ * nodes don't matter since we can't open nested ones, so we just simply group in 5's
+ */
+@Composable
+private fun DrawChildrenInGroups(
+ children: List,
+ previousFrameNode: Node?,
+ affectedNodes: Set,
+ expandedNodes: MutableMap,
+ onNodeSelect: (NodeUpdate) -> Unit,
+ storeNodeLocation: (Node, Offset) -> Unit,
+ unaffected: Boolean = false,
+) {
+ // Split children into those with children (nested) and those without
+ var (nestedChildren, simpleChildren) = children.partition { it.children.isNotEmpty() }
+
+ // Just reset the lists so we chunk everything in the unaffected group
+ if (unaffected) {
+ nestedChildren = emptyList()
+ simpleChildren = children
+ }
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Draw simple children in a grid at the top
+ if (simpleChildren.isNotEmpty()) {
+ val groupedSimpleChildren = simpleChildren.chunked(5)
+
+ groupedSimpleChildren.forEach { group ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp)
+ .align(Alignment.CenterHorizontally),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.Top
+ ) {
+ group.forEach { childNode ->
+ DrawTree(
+ node = childNode,
+ previousFrameNode = previousFrameNode?.children?.get(childNode.id),
+ affectedNodes = affectedNodes,
+ expandedNodes = expandedNodes,
+ onNodeSelect = onNodeSelect,
+ storeNodeLocation = storeNodeLocation
+ )
+ }
+ }
+ }
+ }
+
+ // Draw nested children in a single row at the bottom
+ if (nestedChildren.isNotEmpty()) {
Row(
- horizontalArrangement = Arrangement.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp)
+ .align(Alignment.CenterHorizontally),
+ horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.Top
) {
- /*
- We pair up the current node's children with previous frame's children.
- In the edge case that the current frame has additional children compared to the previous
- frame, we replace with null and will check before next recursive call.
- */
- node.children.forEach { (index, childNode) ->
- val prevChildNode = previousNode?.children?.get(index)
+ nestedChildren.forEach { childNode ->
DrawTree(
node = childNode,
- previousNode = prevChildNode,
+ previousFrameNode = previousFrameNode?.children?.get(childNode.id),
affectedNodes = affectedNodes,
expandedNodes = expandedNodes,
- onNodeSelect = onNodeSelect
+ onNodeSelect = onNodeSelect,
+ storeNodeLocation = storeNodeLocation
)
}
}
@@ -94,35 +286,88 @@ internal fun DrawTree(
@Composable
private fun DrawNode(
node: Node,
- previousNode: Node?,
+ nodePast: Node?,
isAffected: Boolean,
isExpanded: Boolean,
- onNodeSelect: (Node, Node?) -> Unit,
+ onNodeSelect: (NodeUpdate) -> Unit,
onExpandToggle: (Node) -> Unit,
+ storeNodeLocation: (Node, Offset) -> Unit
) {
- Box(
- modifier = Modifier
- .background(if (isAffected) Color.Green else Color.Transparent)
- .onPointerEvent(PointerEventType.Press) {
- if (it.buttons.isPrimaryPressed) {
- onNodeSelect(node, previousNode)
- } else if (it.buttons.isSecondaryPressed) {
- onExpandToggle(node)
+ val interactionSource = remember { MutableInteractionSource() }
+ val isHovered by interactionSource.collectIsHoveredAsState()
+
+ val nodeUpdate = NodeUpdate.create(
+ current = node,
+ past = nodePast,
+ isAffected = isAffected
+ )
+
+ Box {
+ Box(
+ modifier = Modifier
+ .hoverable(interactionSource)
+ .background(nodeUpdate.state.color)
+ .clickable {
+ onNodeSelect(nodeUpdate)
}
- }
- .padding(10.dp)
- ) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(4.dp)
- ) {
- if (node.children.isNotEmpty()) {
- Text(text = if (isExpanded) "▼" else "▶")
+ .onPointerEvent(PointerEventType.Press) {
+ if (it.buttons.isSecondaryPressed) {
+ onExpandToggle(node)
+ }
}
- Text(text = node.name)
+ .padding(16.dp)
+ .onGloballyPositioned { coords ->
+ val offsetToTopLeft = coords.positionInRoot()
+ val offsetToCenter = Offset(
+ x = offsetToTopLeft.x + coords.size.width / 2,
+ y = offsetToTopLeft.y + coords.size.height / 2
+ )
+ storeNodeLocation(node, offsetToCenter)
+ }
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ if (node.children.isNotEmpty()) {
+ Text(text = if (isExpanded) "▼" else "▶")
+ }
+ Text(text = node.name)
+ }
+ Text(text = "ID: ${node.id}")
}
- Text(text = "ID: ${node.id}")
}
+
+ if (isHovered) {
+ NodeTooltip(
+ nodeState = nodeUpdate.state,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .background(nodeUpdate.state.color)
+ )
+ }
+ }
+}
+
+/**
+ * A tooltip that appears on hover showing the node state type
+ */
+@Composable
+private fun NodeTooltip(
+ nodeState: NodeState,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .clip(RoundedCornerShape(4.dp))
+ .background(Color.Black.copy(alpha = 0.3f))
+ .padding(horizontal = 8.dp, vertical = 4.dp)
+ ) {
+ Text(
+ text = nodeState.name,
+ color = Color.White,
+ fontSize = 12.sp
+ )
}
}
diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/DisplayDevices.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/DisplayDevices.kt
new file mode 100644
index 000000000..f487a8d55
--- /dev/null
+++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/DisplayDevices.kt
@@ -0,0 +1,61 @@
+package com.squareup.workflow1.traceviewer.ui.control
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Card
+import androidx.compose.material.ExperimentalMaterialApi
+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.unit.dp
+
+@OptIn(ExperimentalMaterialApi::class)
+@Composable
+internal fun DisplayDevices(
+ onDeviceSelect: (String) -> Unit,
+ devices: List,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ if (devices.isEmpty()) {
+ Text(
+ text = "No device available. Boot up a new device and restart the visualizer",
+ modifier = Modifier.align(Alignment.Center)
+ )
+ return@Box
+ }
+
+ val emulatorRegex = Regex("""\bemulator-\d+\b""")
+ Column {
+ devices.forEach { device ->
+ Card(
+ onClick = {
+ // Only give back the specific emulator device, i.e. "emulator-5554"
+ emulatorRegex.find(device)?.value?.let { emulator ->
+ onDeviceSelect(emulator)
+ }
+ },
+ shape = RoundedCornerShape(16.dp),
+ border = BorderStroke(1.dp, Color.Gray),
+ modifier = Modifier.padding(4.dp),
+ elevation = 2.dp
+ ) {
+ Text(
+ text = device,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/FileDump.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/FileDump.kt
new file mode 100644
index 000000000..73c30a174
--- /dev/null
+++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/FileDump.kt
@@ -0,0 +1,61 @@
+package com.squareup.workflow1.traceviewer.ui.control
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.Button
+import androidx.compose.material.ButtonDefaults.buttonColors
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import okio.FileSystem
+import okio.Path.Companion.toPath
+import okio.buffer
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+@Composable
+internal fun FileDump(
+ trace: String,
+ modifier: Modifier = Modifier
+) {
+ var clicked by remember { mutableStateOf(false) }
+ Button(
+ modifier = modifier.padding(16.dp),
+ shape = CircleShape,
+ colors = buttonColors(Color.Black),
+ onClick = {
+ clicked = true
+ writeToFile(trace)
+ }
+ ) {
+ val text = if (clicked) {
+ "Trace saved to Downloads"
+ } else {
+ "Save trace to file"
+ }
+ Text(
+ text = text,
+ color = Color.White
+ )
+ }
+}
+
+private fun writeToFile(trace: String) {
+ val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"))
+ val home = System.getProperty("user.home")
+ val path = "$home/Downloads/workflow-trace_$timestamp.json".toPath()
+
+ FileSystem.SYSTEM.sink(path).use { sink ->
+ sink.buffer().use { bufferedSink ->
+ bufferedSink.writeUtf8("[")
+ bufferedSink.writeUtf8(trace.dropLast(1)) // Fenceposting final comma
+ bufferedSink.writeUtf8("]")
+ }
+ }
+}
diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/FrameNavigator.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/FrameNavigator.kt
new file mode 100644
index 000000000..305fa9ff9
--- /dev/null
+++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/FrameNavigator.kt
@@ -0,0 +1,141 @@
+package com.squareup.workflow1.traceviewer.ui.control
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.DropdownMenu
+import androidx.compose.material.DropdownMenuItem
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+/**
+ * A frame navigator that shows the current frame number with dropdown selection
+ * and left/right navigation arrows.
+ */
+@Composable
+internal fun FrameNavigator(
+ totalFrames: Int,
+ currentIndex: Int,
+ onIndexChange: (Int) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ var dropdownExpanded by remember { mutableStateOf(false) }
+
+ Surface(
+ modifier = modifier,
+ color = Color.White,
+ elevation = 2.dp,
+ shape = RoundedCornerShape(8.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ // Previous frame button
+ IconButton(
+ onClick = {
+ if (currentIndex > 0) {
+ onIndexChange(currentIndex - 1)
+ }
+ },
+ enabled = currentIndex > 0
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft,
+ contentDescription = "Previous frame",
+ tint = if (currentIndex > 0) Color.Black else Color.LightGray
+ )
+ }
+
+ Box {
+ Row(
+ modifier = Modifier
+ .clip(RoundedCornerShape(4.dp))
+ .clickable { dropdownExpanded = true }
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = "Frame ${currentIndex + 1}",
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ color = Color.Black
+ )
+ Icon(
+ imageVector = Icons.Default.ArrowDropDown,
+ contentDescription = "Select frame",
+ tint = Color.Black
+ )
+ }
+
+ DropdownMenu(
+ expanded = dropdownExpanded,
+ onDismissRequest = { dropdownExpanded = false },
+ modifier = Modifier
+ .background(Color.White)
+ .width(150.dp)
+ .heightIn(max = 350.dp)
+ ) {
+ (0 until totalFrames).forEach { index ->
+ DropdownMenuItem(
+ onClick = {
+ onIndexChange(index)
+ dropdownExpanded = false
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = "Frame ${index + 1}",
+ color = if (index == currentIndex) Color.Black else Color.LightGray,
+ fontWeight = if (index == currentIndex) FontWeight.Bold else FontWeight.Normal
+ )
+ }
+ }
+ }
+ }
+
+ IconButton(
+ onClick = {
+ if (currentIndex < totalFrames - 1) {
+ onIndexChange(currentIndex + 1)
+ }
+ },
+ enabled = currentIndex < totalFrames - 1
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = "Next frame",
+ tint = if (currentIndex < totalFrames - 1) Color.Black else Color.LightGray
+ )
+ }
+ }
+ }
+}
diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/SearchBox.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/SearchBox.kt
new file mode 100644
index 000000000..8709e2138
--- /dev/null
+++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/SearchBox.kt
@@ -0,0 +1,83 @@
+package com.squareup.workflow1.traceviewer.ui.control
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.DockedSearchBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.SearchBarColors
+import androidx.compose.material3.SearchBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import com.squareup.workflow1.traceviewer.model.Node
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun SearchBox(
+ nodes: List,
+ onSearch: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ var searchText by remember { mutableStateOf("") }
+ var expanded by remember { mutableStateOf(false) }
+
+ DockedSearchBar(
+ modifier = modifier,
+ inputField = {
+ SearchBarDefaults.InputField(
+ query = searchText,
+ onQueryChange = { searchText = it },
+ onSearch = {
+ expanded = false
+ },
+ expanded = expanded,
+ onExpandedChange = { expanded = it },
+ placeholder = { Text("search for a node...") },
+ trailingIcon = {
+ IconButton(
+ onClick = {
+ expanded = false
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Clear search"
+ )
+ }
+ }
+ )
+ },
+ colors = SearchBarColors(Color.White, Color.Black),
+ expanded = expanded,
+ onExpandedChange = { expanded = it },
+ ) {
+ val relevantNodes = nodes.filter { it.name.contains(searchText, ignoreCase = true) }
+ Column {
+ relevantNodes.take(5).forEach { node ->
+ ListItem(
+ headlineContent = { Text(node.name) },
+ modifier = Modifier
+ .clickable {
+ onSearch(node.name)
+ expanded = false
+ },
+ colors = ListItemDefaults.colors(
+ containerColor = Color.White,
+ headlineColor = Color.Black
+ )
+ )
+ }
+ }
+ }
+}
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/control/TraceModeToggleSwitch.kt
similarity index 95%
rename from workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/TraceModeToggleSwitch.kt
rename to workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/TraceModeToggleSwitch.kt
index 0c88df899..e2e101294 100644
--- 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/control/TraceModeToggleSwitch.kt
@@ -1,4 +1,4 @@
-package com.squareup.workflow1.traceviewer.ui
+package com.squareup.workflow1.traceviewer.ui.control
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/UploadFile.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/UploadFile.kt
similarity index 96%
rename from workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/UploadFile.kt
rename to workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/UploadFile.kt
index 6daae3691..7f08c33bf 100644
--- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/UploadFile.kt
+++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/control/UploadFile.kt
@@ -1,4 +1,4 @@
-package com.squareup.workflow1.traceviewer.util
+package com.squareup.workflow1.traceviewer.ui.control
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SandboxBackground.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SandboxBackground.kt
index a0534a342..adc5d38b7 100644
--- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SandboxBackground.kt
+++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SandboxBackground.kt
@@ -8,9 +8,11 @@ import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.unit.IntSize
import com.squareup.workflow1.traceviewer.SandboxState
/**
@@ -22,6 +24,7 @@ import com.squareup.workflow1.traceviewer.SandboxState
*/
@Composable
internal fun SandboxBackground(
+ appWindowSize: IntSize,
sandboxState: SandboxState,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
@@ -41,14 +44,29 @@ internal fun SandboxBackground(
awaitEachGesture {
val event = awaitPointerEvent()
if (event.type == PointerEventType.Scroll) {
- val scrollDelta = event.changes.first().scrollDelta.y
+ val pointerInput = event.changes.first()
+ val pointerOffsetToCenter = Offset(
+ // For some reason using 1.5 made zooming more natural than 2
+ x = pointerInput.position.x - appWindowSize.width / (3 / 2),
+ y = pointerInput.position.y - appWindowSize.height / 2
+ )
+ val scrollDelta = pointerInput.scrollDelta.y
// Applies zoom factor based on the actual delta change rather than just the act of scrolling
// This helps to normalize mouse scrolling and touchpad scrolling, since touchpad will
// fire a lot more scroll events.
val factor = 1f + (-scrollDelta * 0.1f)
val minWindowSize = 0.1f
- val maxWindowSize = 10f
- sandboxState.scale = (sandboxState.scale * factor).coerceIn(minWindowSize, maxWindowSize)
+ val maxWindowSize = 2f
+ val oldScale = sandboxState.scale
+ val newScale = (oldScale * factor).coerceIn(minWindowSize, maxWindowSize)
+ val scaleRatio = newScale / oldScale
+
+ val newOrigin = sandboxState.offset - pointerOffsetToCenter
+ val scaledView = newOrigin * scaleRatio
+ val resetViewOffset = scaledView + pointerOffsetToCenter
+ sandboxState.offset = resetViewOffset
+ sandboxState.scale = newScale
+
event.changes.forEach { it.consume() }
}
}
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
index 61fbd74d9..a5755764a 100644
--- 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
@@ -11,12 +11,12 @@ import kotlinx.coroutines.withContext
import okio.IOException
import java.net.Socket
-internal suspend fun streamRenderPassesFromDevice(parseOnNewRenderPass: (String) -> Unit) {
+internal suspend fun streamRenderPassesFromDevice(device: String, parseOnNewRenderPass: (String) -> Unit) {
val renderPassChannel: Channel = Channel(Channel.BUFFERED)
coroutineScope {
launch {
try {
- pollSocket(onNewRenderPass = renderPassChannel::send)
+ pollSocket(device = device, onNewRenderPass = renderPassChannel::send)
} finally {
renderPassChannel.close()
}
@@ -39,10 +39,10 @@ internal suspend fun streamRenderPassesFromDevice(parseOnNewRenderPass: (String)
* @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) {
+private suspend fun pollSocket(device: String, onNewRenderPass: suspend (String) -> Unit) {
withContext(Dispatchers.IO) {
try {
- runForwardingPortThroughAdb { port ->
+ runForwardingPortThroughAdb(device) { port ->
Socket("localhost", port).useWithCancellation { socket ->
val reader = socket.getInputStream().bufferedReader()
do {
@@ -88,9 +88,9 @@ private suspend fun Socket.useWithCancellation(block: suspend (Socket) -> Unit)
* 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) {
+private suspend inline fun runForwardingPortThroughAdb(device: String, block: (port: Int) -> Unit) {
val process = ProcessBuilder(
- "adb", "forward", "tcp:0", "localabstract:workflow-trace"
+ "adb", "-s", device, "forward", "tcp:0", "localabstract:workflow-trace"
).start()
// The adb forward command will output the port number it picks to connect.
diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/parser/DiffUtils.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/parser/DiffUtils.kt
new file mode 100644
index 000000000..2cf07df51
--- /dev/null
+++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/parser/DiffUtils.kt
@@ -0,0 +1,225 @@
+package com.squareup.workflow1.traceviewer.util.parser
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import com.github.difflib.text.DiffRow.Tag
+import com.github.difflib.text.DiffRowGenerator
+import com.squareup.workflow1.traceviewer.util.parser.DiffStyles.buildStringWithStyle
+
+/**
+ * Generates a field-level word-diff for each node's states.
+ *
+ */
+fun computeAnnotatedDiff(
+ past: String,
+ current: String
+): AnnotatedString {
+ val diffGenerator = DiffRowGenerator.create()
+ .showInlineDiffs(true)
+ .inlineDiffByWord(true)
+ .mergeOriginalRevised(true)
+ .oldTag { f -> "--" }
+ .newTag { f -> "++" }
+ .build()
+
+ val pastName = extractTypeName(past)
+ val currentName = extractTypeName(current)
+ val pastFields = getFieldsAsList(past)
+ val currentFields = getFieldsAsList(current)
+ val diffRows = diffGenerator.generateDiffRows(pastFields, currentFields)
+
+ var existsDiff = false
+ return buildAnnotatedString {
+ // A full change in the type means all internal data will be changed, so it's easier to just
+ // generalize and show the diff in the type's name
+ if (pastName != currentName) {
+ buildStringWithStyle(
+ style = DiffStyles.DELETE,
+ text = "$pastName(...)",
+ builder = this
+ )
+ // pushStyle(DiffStyles.DELETE)
+ // append("$pastName(...)")
+ // pop()
+ append(" → ")
+ buildStringWithStyle(
+ style = DiffStyles.INSERT,
+ text = "$currentName(...)",
+ builder = this
+ )
+ return@buildAnnotatedString
+ }
+
+ diffRows.forEach { row ->
+ val tag = row.tag!!
+ // The 'mergeOriginalRevised' flag changes the semantics of the data, but the API still returns
+ // the same components
+ val fullDiff = row.oldLine
+
+ /*
+ Tag.INSERT and Tag.DELETE only happens when there is a difference in number of rows, i.e.:
+ Tag(["a"],["a","b"]) == INSERT
+ and
+ Tag(["a","b"],["a"]) == DELETE
+ but
+ Tag([""],["a"]) == CHANGE
+ */
+ when (tag) {
+ Tag.CHANGE -> {
+ existsDiff = true
+ parseChangedDiff(fullDiff).forEach { (style, text) ->
+ buildStringWithStyle(
+ style = style,
+ text = text,
+ builder = this
+ )
+ }
+ append("\n\n")
+ }
+
+ Tag.INSERT -> {
+ existsDiff = true
+ buildStringWithStyle(
+ text = fullDiff.replace("++", ""),
+ style = DiffStyles.INSERT,
+ builder = this
+ )
+ append("\n\n")
+ }
+
+ Tag.DELETE -> {
+ existsDiff = true
+ buildStringWithStyle(
+ text = fullDiff.replace("--", ""),
+ style = DiffStyles.DELETE,
+ builder = this
+ )
+ append("\n\n")
+ }
+
+ Tag.EQUAL -> {
+ // NoOp
+ }
+ }
+ }
+
+ if (!existsDiff) {
+ buildStringWithStyle(
+ style = DiffStyles.NO_CHANGE,
+ text = "No Diff",
+ builder = this
+ )
+ }
+ }
+}
+
+/**
+ * Parses the full diff within Tag.CHANGED to give back a list of operations to perform
+ */
+private fun parseChangedDiff(fullDiff: String): List> {
+ val operations: MutableList> = mutableListOf()
+ var i = 0
+ while (i < fullDiff.length) {
+ when {
+ fullDiff.startsWith("--", i) -> {
+ val end = fullDiff.indexOf("--", i + 2)
+ if (end != -1) {
+ val removed = fullDiff.substring(i + 2, end)
+ operations.add(DiffStyles.DELETE to removed)
+ i = end + 2
+ }
+ }
+
+ fullDiff.startsWith("++", i) -> {
+ val end = fullDiff.indexOf("++", i + 2)
+ if (end != -1) {
+ val added = fullDiff.substring(i + 2, end)
+ operations.add(DiffStyles.INSERT to added)
+ i = end + 2
+ }
+ }
+
+ else -> {
+ val nextTagStart = listOf(
+ fullDiff.indexOf("--", i),
+ fullDiff.indexOf("++", i)
+ ).filter { it >= 0 }.minOrNull() ?: fullDiff.length
+ operations.add(DiffStyles.UNCHANGED to fullDiff.substring(i, nextTagStart))
+ i = nextTagStart
+ }
+ }
+ }
+
+ return operations
+}
+
+object DiffStyles {
+ val DELETE = SpanStyle(background = Color.Red.copy(alpha = 0.3f))
+ val INSERT = SpanStyle(background = Color.Green.copy(alpha = 0.3f))
+ val NO_CHANGE = SpanStyle(background = Color.LightGray)
+ val UNCHANGED = SpanStyle()
+
+ fun buildStringWithStyle(
+ style: SpanStyle,
+ text: String,
+ builder: AnnotatedString.Builder
+ ) {
+ builder.pushStyle(style)
+ builder.append(text)
+ builder.pop()
+ }
+}
+
+/**
+ * Pull out each "key=value" pair within the field data by looking for a comma. Since plenty of data
+ * include nesting, doing .split or simple regex won't suffice.
+ *
+ * Manually iterates through the fields and changes the depth of the current comma accordingly
+ */
+private fun getFieldsAsList(field: String): List {
+ val fields = mutableListOf()
+ val currentField = StringBuilder()
+ var depth = 0
+ // We skip past the field's Type's name
+ var i = field.indexOf('(') + 1
+
+ while (i < field.length) {
+ val char = field[i]
+ when (char) {
+ '(', '[', '{' -> {
+ depth++
+ currentField.append(char)
+ }
+
+ ')', ']', '}' -> {
+ depth--
+ currentField.append(char)
+ }
+
+ ',' -> {
+ if (depth == 0) { // end of key=value pair
+ fields += currentField.toString().trim()
+ currentField.clear()
+ i++ // skip space, e.g. "key=value, key2=value2, etc..."
+ } else { // nested list
+ currentField.append(char)
+ }
+ }
+
+ else -> currentField.append(char)
+ }
+ i++
+ }
+
+ // Just append whatever is left, since there are no trailing commas
+ if (currentField.isNotBlank()) fields += currentField.toString().trim()
+ return fields
+}
+
+private fun extractTypeName(field: String): String {
+ val stateRegex = Regex("""^(\w+)\(""")
+ // If regex doesn't match, that means it's likely "kotlin.Unit" or "0"
+ return stateRegex.find(field)?.groupValues?.get(1) ?: field
+}
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/parser/JsonParser.kt
similarity index 91%
rename from workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt
rename to workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/parser/JsonParser.kt
index abe6acaf0..3403c404f 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/parser/JsonParser.kt
@@ -1,4 +1,4 @@
-package com.squareup.workflow1.traceviewer.util
+package com.squareup.workflow1.traceviewer.util.parser
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
@@ -7,6 +7,8 @@ import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.squareup.workflow1.traceviewer.model.Node
import com.squareup.workflow1.traceviewer.model.addChild
import com.squareup.workflow1.traceviewer.model.replaceChild
+import com.squareup.workflow1.traceviewer.util.parser.ParseResult.Failure
+import com.squareup.workflow1.traceviewer.util.parser.ParseResult.Success
import io.github.vinceglb.filekit.PlatformFile
import io.github.vinceglb.filekit.readString
import kotlin.reflect.jvm.javaType
@@ -31,11 +33,11 @@ internal suspend fun parseFileTrace(
val jsonString = file.readString()
val workflowAdapter = createMoshiAdapter>()
val parsedRenderPasses = try {
- workflowAdapter.fromJson(jsonString) ?: return ParseResult.Failure(
+ workflowAdapter.fromJson(jsonString) ?: return Failure(
IllegalArgumentException("Provided trace file is empty or malformed.")
)
} catch (e: Exception) {
- return ParseResult.Failure(e)
+ return Failure(e)
}
val parsedFrames = parsedRenderPasses.map { renderPass -> getFrameFromRenderPass(renderPass) }
@@ -45,7 +47,7 @@ internal suspend fun parseFileTrace(
frameTrees.add(mergedTree)
mergedTree
}
- return ParseResult.Success(
+ return Success(
trace = parsedFrames,
trees = frameTrees,
affectedNodes = parsedRenderPasses
@@ -64,11 +66,11 @@ internal fun parseLiveTrace(
currentTree: Node? = null
): ParseResult {
val parsedRenderPass = try {
- adapter.fromJson(renderPass) ?: return ParseResult.Failure(
+ adapter.fromJson(renderPass) ?: return Failure(
IllegalArgumentException("Provided trace data is empty or malformed.")
)
} catch (e: Exception) {
- return ParseResult.Failure(e)
+ return Failure(e)
}
val parsedFrame = getFrameFromRenderPass(parsedRenderPass)
@@ -81,7 +83,7 @@ internal fun parseLiveTrace(
}
// Since live tracing handles one frame at a time, we generalize and return listOf for each.
- return ParseResult.Success(
+ return Success(
trace = listOf(parsedFrame),
trees = listOf(mergedTree),
affectedNodes = listOf(parsedRenderPass)
@@ -153,7 +155,8 @@ 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/TraceParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/parser/TraceParser.kt
similarity index 56%
rename from workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/TraceParser.kt
rename to workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/parser/TraceParser.kt
index 55d5d00c5..4095df0c9 100644
--- 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/parser/TraceParser.kt
@@ -1,5 +1,7 @@
-package com.squareup.workflow1.traceviewer.util
+package com.squareup.workflow1.traceviewer.util.parser
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.padding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -10,10 +12,16 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import com.squareup.moshi.JsonAdapter
import com.squareup.workflow1.traceviewer.TraceMode
import com.squareup.workflow1.traceviewer.model.Node
+import com.squareup.workflow1.traceviewer.model.NodeUpdate
import com.squareup.workflow1.traceviewer.ui.DrawTree
+import com.squareup.workflow1.traceviewer.util.streamRenderPassesFromDevice
/**
* Handles parsing the trace's after JsonParser has turned all render passes into frames. Also calls
@@ -25,30 +33,32 @@ import com.squareup.workflow1.traceviewer.ui.DrawTree
internal fun RenderTrace(
traceSource: TraceMode,
frameInd: Int,
- onFileParse: (List) -> Unit,
- onNodeSelect: (Node, Node?) -> Unit,
+ onFileParse: (Int) -> Unit,
+ onNodeSelect: (NodeUpdate) -> Unit,
onNewFrame: () -> Unit,
+ onNewData: (String) -> Unit,
+ storeNodeLocation: (Node, Offset) -> 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>() }
+ val frames = remember(traceSource) { mutableStateListOf() }
+ val fullTree = remember(traceSource) { mutableStateListOf() }
+ val affectedNodes = remember(traceSource) { 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)
+ onFileParse(frame.size)
}
// 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,
+ rawRenderPass: String? = null,
onNewFrame: (() -> Unit)? = null
) {
when (parseResult) {
@@ -62,9 +72,12 @@ internal fun RenderTrace(
tree = parseResult.trees,
affected = parseResult.affectedNodes
)
+ // Only increment the frame index and add the raw data during Live tracing mode
onNewFrame?.invoke()
+ rawRenderPass?.let { onNewData(it) }
}
}
+ isLoading = false
}
LaunchedEffect(traceSource) {
@@ -78,19 +91,32 @@ internal fun RenderTrace(
}
is TraceMode.Live -> {
+ checkNotNull(traceSource.device) {
+ "TraceMode.Live requires a selected device"
+ }
val adapter: JsonAdapter> = createMoshiAdapter()
- streamRenderPassesFromDevice { renderPass ->
+ streamRenderPassesFromDevice(traceSource.device) { renderPass ->
val currentTree = fullTree.lastOrNull()
val parseResult = parseLiveTrace(renderPass, adapter, currentTree)
- handleParseResult(parseResult, onNewFrame)
+ handleParseResult(parseResult, renderPass, onNewFrame)
}
error = "Socket has already been closed or is not available."
}
}
}
- if (error != null) {
- Text("Error parsing: $error")
+ // This will only happen in the initial switch to Live Mode, where a socket error bubbled up and
+ // the lambda call to parse the data was immediately cancelled, meaning handleParseResult was never
+ // called to set isLoading to false
+ if (isLoading && error != null) {
+ Text("Socket Error: $error")
+ return
+ }
+
+ // This meant that there was an exception, but it was stored in ParseResult and read in
+ // handleParseResult. Since there is no parsed data, this likely means there was a moshi parsing error
+ if (error != null && frames.isEmpty()) {
+ Text("Malformed File: $error")
return
}
@@ -98,10 +124,21 @@ internal fun RenderTrace(
val previousFrame = if (frameInd > 0) fullTree[frameInd - 1] else null
DrawTree(
node = fullTree[frameInd],
- previousNode = previousFrame,
+ previousFrameNode = previousFrame,
affectedNodes = affectedNodes[frameInd],
expandedNodes = remember(frameInd) { mutableStateMapOf() },
onNodeSelect = onNodeSelect,
+ storeNodeLocation = storeNodeLocation
)
+
+ // This error happens when there has already been previous data parsed, but some exception bubbled
+ // up again, meaning it has to be a socket closure in Live mode.
+ error?.let {
+ Text(
+ text = "Socket closed: $error",
+ fontSize = 20.sp,
+ modifier = modifier.background(Color.White).padding(20.dp)
+ )
+ }
}
}
diff --git a/workflow-trace-viewer/src/jvmTest/kotlin/com/squareup/workflow1/traceviewer/util/JsonParserTest.kt b/workflow-trace-viewer/src/jvmTest/kotlin/com/squareup/workflow1/traceviewer/util/JsonParserTest.kt
index 434aac6ba..0d7c890a5 100644
--- a/workflow-trace-viewer/src/jvmTest/kotlin/com/squareup/workflow1/traceviewer/util/JsonParserTest.kt
+++ b/workflow-trace-viewer/src/jvmTest/kotlin/com/squareup/workflow1/traceviewer/util/JsonParserTest.kt
@@ -1,6 +1,7 @@
package com.squareup.workflow1.traceviewer.util
import com.squareup.workflow1.traceviewer.model.Node
+import com.squareup.workflow1.traceviewer.util.parser.mergeFrameIntoMainTree
import java.util.LinkedHashMap
import kotlin.test.Test
import kotlin.test.assertEquals