Skip to content

Commit 1286291

Browse files
authored
Merge pull request #1382 from square/wenli/improve-visualizer
Improve visualizer usability
2 parents bf6909f + 989766d commit 1286291

26 files changed

+1370
-5119
lines changed

gradle/libs.versions.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ turbine = "1.0.0"
9898
vanniktech-publish = "0.32.0"
9999

100100
[plugins]
101-
102101
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
103102

104103
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

workflow-trace-viewer/README.md

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Workflow Trace Viewer
22

3-
A Compose for Desktop app that can be used to view Workflow traces.
3+
A Compose for Desktop application that visualizes and debugs Workflow execution traces. This tool
4+
helps developers understand the hierarchical structure and execution flow of their Workflow
5+
applications by providing both file-based and live streaming trace visualization.
46

57
## Running
68

@@ -10,20 +12,75 @@ It can be run via Gradle using:
1012
./gradlew :workflow-trace-viewer:run
1113
```
1214

13-
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.
15+
## Usage Guide
1416

15-
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.
17+
By default, the app will be in file parsing mode, where you are able to select a previously recorded
18+
workflow trace file for it to visualize the data. Once the workflow tree is rendered
19+
in [File](#file-mode) or [Live](#live-mode) mode, you can switch frames (
20+
see [Terminology](#Terminology)) to see different events that fired. All nodes are color coded based
21+
on what had happened during this frame, and a text diff will show the specific changes. You can open
22+
the right node panel and left click a box get a more detailed view of the specific node, or right
23+
click to expand/collapse a specific node's children.
1624

17-
It is ***important*** to run the emulator first before toggling to live mode.
25+
<img src="https://github.com/square/workflow-kotlin/raw/wenli/improve-visualizer/workflow-trace-viewer/docs/demo.gif" width="320" alt="Demo" />
26+
27+
### File Mode
28+
29+
Once a file of the live data is saved, it can easily be uploaded to retrace the steps taken during
30+
the live session. Currently, text/json files that are saved from recordings only contain raw data,
31+
meaning it is simply a list of lists of node renderings.
32+
33+
<img src="https://github.com/square/workflow-kotlin/raw/wenli/improve-visualizer/workflow-trace-viewer/docs/file_mode.gif" width="320" alt="File Mode" />
34+
35+
### Live Mode
36+
37+
By hitting the bottom switch, you are able to toggle to live stream mode, where data is directly
38+
pulled from the emulator into the visualizer. To do so:
39+
40+
- Start the app (on any device)
41+
- Start the app, and toggle the switch to enter Live mode
42+
- Select the desired device
43+
44+
Once in Live mode, frames will appear as you interact with the app. You may also save the current
45+
data into a file saved in `~/Downloads` to be used later (this action will take some time, so it may
46+
not appear immediately)
47+
48+
Render pass data is passively stored in a buffer before being sent to the visualizer, so you do not
49+
need to immediately open/run the app to "catch" everything. However, since the the buffer has
50+
limited size, it's strongly recommended to avoid interacting with the app — beyond starting it —
51+
before Live mode has been triggered; this helps to avoid losing data.
52+
53+
<img src="https://github.com/square/workflow-kotlin/raw/wenli/improve-visualizer/workflow-trace-viewer/docs/live_mode.gif" width="320" alt="Live Mode" />
54+
55+
### Note
56+
57+
A connection can only happen once. There is currently no support for a recording of the trace data
58+
due to the fact that an open socket will consume all render pass data when a connection begins. To
59+
restart the recording:
60+
61+
- (optional) Save the current trace
62+
- Switch out of Live mode
63+
- Restart the app
64+
- Switch back to Live mode, and the
1865

1966
### Terminology
2067

21-
**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.
68+
`Trace`: A trace is a file — made up of frames — that contains the execution history of a Workflow.
69+
It includes information about render passes, how states have changed within workflows, and the
70+
specific props being passed through.
2271

23-
**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.
72+
`Frame`: Essentially a "snapshot" of the current "state" of the whole Workflow tree. It contains
73+
relevant information about the changes in workflow states and how props are passed throughout.
2474

25-
- Note that "snapshot" and "state" are different from `snapshotState` and `State`, which are idiomatic to the Workflow library.
75+
- Note that "snapshot" and "state" are different from `snapshotState` and `State`, which are
76+
idiomatic to the Workflow library.
2677

2778
### External Libraries
2879

29-
[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.
80+
[FileKit](https://github.com/vinceglb/FileKit) is an external library made to apply file operations
81+
on Kotlin and KMP projects. This simplified the development process of allowing file selection
82+
83+
## Future
84+
85+
This app can be integrated into the process of anyone working with Workflow, so it's highly
86+
encouraged for anyone to make improvements that makes their life a little easier using this app.

workflow-trace-viewer/api/workflow-trace-viewer.api

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,19 @@ public final class com/squareup/workflow1/traceviewer/MainKt {
1212
public final class com/squareup/workflow1/traceviewer/ui/ComposableSingletons$WorkflowInfoPanelKt {
1313
public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/ui/ComposableSingletons$WorkflowInfoPanelKt;
1414
public fun <init> ()V
15-
public final fun getLambda$-1066151803$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3;
1615
public final fun getLambda$-1653175968$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3;
1716
}
1817

19-
public final class com/squareup/workflow1/traceviewer/util/ComposableSingletons$UploadFileKt {
20-
public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/util/ComposableSingletons$UploadFileKt;
18+
public final class com/squareup/workflow1/traceviewer/ui/control/ComposableSingletons$SearchBoxKt {
19+
public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/ui/control/ComposableSingletons$SearchBoxKt;
2120
public fun <init> ()V
22-
public final fun getLambda$-1046372460$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3;
21+
public final fun getLambda$-1622160509$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function2;
22+
public final fun getLambda$673874721$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function2;
2323
}
2424

25-
public final class com/squareup/workflow1/traceviewer/util/JsonParserKt {
26-
public static final field ROOT_ID Ljava/lang/String;
27-
}
28-
29-
public final class com/squareup/workflow1/traceviewer/util/UploadFileKt {
30-
public static final fun UploadFile (Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V
25+
public final class com/squareup/workflow1/traceviewer/ui/control/ComposableSingletons$UploadFileKt {
26+
public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/ui/control/ComposableSingletons$UploadFileKt;
27+
public fun <init> ()V
28+
public final fun getLambda$-1248702605$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3;
3129
}
3230

workflow-trace-viewer/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ kotlin {
1515
implementation(compose.runtime)
1616
implementation(compose.foundation)
1717
implementation(compose.material)
18+
implementation(compose.material3)
1819
implementation(compose.ui)
1920
implementation(compose.components.resources)
2021
implementation(compose.components.uiToolingPreview)
@@ -25,6 +26,7 @@ kotlin {
2526
implementation(compose.materialIconsExtended)
2627
implementation(libs.squareup.moshi.kotlin)
2728
implementation(libs.filekit.dialogs.compose)
29+
implementation(libs.java.diff.utils)
2830
}
2931
}
3032
jvmTest {
Lines changed: 120 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,38 @@
11
package com.squareup.workflow1.traceviewer
22

3+
import androidx.compose.foundation.layout.Arrangement
34
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.padding
47
import androidx.compose.runtime.Composable
58
import androidx.compose.runtime.LaunchedEffect
69
import androidx.compose.runtime.getValue
710
import androidx.compose.runtime.mutableFloatStateOf
811
import androidx.compose.runtime.mutableIntStateOf
912
import androidx.compose.runtime.mutableStateListOf
13+
import androidx.compose.runtime.mutableStateMapOf
1014
import androidx.compose.runtime.mutableStateOf
1115
import androidx.compose.runtime.remember
1216
import androidx.compose.runtime.setValue
1317
import androidx.compose.runtime.snapshotFlow
18+
import androidx.compose.runtime.snapshots.SnapshotStateMap
1419
import androidx.compose.ui.Alignment
1520
import androidx.compose.ui.Modifier
1621
import androidx.compose.ui.geometry.Offset
22+
import androidx.compose.ui.layout.onSizeChanged
23+
import androidx.compose.ui.unit.IntSize
24+
import androidx.compose.ui.unit.dp
1725
import com.squareup.workflow1.traceviewer.model.Node
1826
import com.squareup.workflow1.traceviewer.model.NodeUpdate
19-
import com.squareup.workflow1.traceviewer.ui.FrameSelectTab
2027
import com.squareup.workflow1.traceviewer.ui.RightInfoPanel
21-
import com.squareup.workflow1.traceviewer.ui.TraceModeToggleSwitch
22-
import com.squareup.workflow1.traceviewer.util.RenderTrace
28+
import com.squareup.workflow1.traceviewer.ui.control.DisplayDevices
29+
import com.squareup.workflow1.traceviewer.ui.control.FileDump
30+
import com.squareup.workflow1.traceviewer.ui.control.FrameNavigator
31+
import com.squareup.workflow1.traceviewer.ui.control.SearchBox
32+
import com.squareup.workflow1.traceviewer.ui.control.TraceModeToggleSwitch
33+
import com.squareup.workflow1.traceviewer.ui.control.UploadFile
2334
import com.squareup.workflow1.traceviewer.util.SandboxBackground
24-
import com.squareup.workflow1.traceviewer.util.UploadFile
35+
import com.squareup.workflow1.traceviewer.util.parser.RenderTrace
2536
import io.github.vinceglb.filekit.PlatformFile
2637

2738
/**
@@ -31,14 +42,20 @@ import io.github.vinceglb.filekit.PlatformFile
3142
internal fun App(
3243
modifier: Modifier = Modifier
3344
) {
45+
var appWindowSize by remember { mutableStateOf(IntSize(0, 0)) }
3446
var selectedNode by remember { mutableStateOf<NodeUpdate?>(null) }
35-
val workflowFrames = remember { mutableStateListOf<Node>() }
47+
var frameSize by remember { mutableIntStateOf(0) }
48+
var rawRenderPass by remember { mutableStateOf("") }
3649
var frameIndex by remember { mutableIntStateOf(0) }
3750
val sandboxState = remember { SandboxState() }
51+
val nodeLocations = remember { mutableStateListOf<SnapshotStateMap<Node, Offset>>() }
3852

3953
// Default to File mode, and can be toggled to be in Live mode.
54+
var active by remember { mutableStateOf(false) }
4055
var traceMode by remember { mutableStateOf<TraceMode>(TraceMode.File(null)) }
4156
var selectedTraceFile by remember { mutableStateOf<PlatformFile?>(null) }
57+
// frameIndex is set to -1 when app is in Live Mode, so we increment it by one to avoid off-by-one errors
58+
val frameInd = if (traceMode is TraceMode.Live) frameIndex + 1 else frameIndex
4259

4360
LaunchedEffect(sandboxState) {
4461
snapshotFlow { frameIndex }.collect {
@@ -47,47 +64,83 @@ internal fun App(
4764
}
4865

4966
Box(
50-
modifier = modifier
67+
modifier = modifier.onSizeChanged {
68+
appWindowSize = it
69+
}
5170
) {
5271
fun resetStates() {
5372
selectedTraceFile = null
5473
selectedNode = null
5574
frameIndex = 0
56-
workflowFrames.clear()
75+
frameSize = 0
76+
rawRenderPass = ""
77+
active = false
78+
nodeLocations.clear()
5779
}
5880

5981
// Main content
6082
SandboxBackground(
83+
appWindowSize = appWindowSize,
6184
sandboxState = sandboxState,
6285
) {
6386
// if there is not a file selected and trace mode is live, then don't render anything.
64-
val readyForFileTrace = traceMode is TraceMode.File && selectedTraceFile != null
65-
val readyForLiveTrace = traceMode is TraceMode.Live
87+
val readyForFileTrace = TraceMode.validateFileMode(traceMode)
88+
val readyForLiveTrace = TraceMode.validateLiveMode(traceMode)
89+
6690
if (readyForFileTrace || readyForLiveTrace) {
91+
active = true
6792
RenderTrace(
6893
traceSource = traceMode,
6994
frameInd = frameIndex,
70-
onFileParse = { workflowFrames.addAll(it) },
71-
onNodeSelect = { node, prevNode ->
72-
selectedNode = NodeUpdate(node, prevNode)
73-
},
74-
onNewFrame = { frameIndex += 1 }
95+
onFileParse = { frameSize += it },
96+
onNodeSelect = { selectedNode = it },
97+
onNewFrame = { frameIndex += 1 },
98+
onNewData = { rawRenderPass += "$it," },
99+
storeNodeLocation = { node, loc -> nodeLocations[frameInd] += (node to loc) }
75100
)
76101
}
77102
}
78103

79-
FrameSelectTab(
80-
frames = workflowFrames,
81-
currentIndex = frameIndex,
82-
onIndexChange = { frameIndex = it },
83-
modifier = Modifier.align(Alignment.TopCenter)
84-
)
85-
86-
RightInfoPanel(
87-
selectedNode = selectedNode,
104+
Column(
88105
modifier = Modifier
89-
.align(Alignment.TopEnd)
90-
)
106+
.align(Alignment.TopCenter)
107+
.padding(top = 8.dp),
108+
verticalArrangement = Arrangement.spacedBy(8.dp),
109+
horizontalAlignment = Alignment.CenterHorizontally
110+
) {
111+
if (active) {
112+
// Frames that appear in composition may not happen sequentially, so when the current frame
113+
// locations is null, that means we've skipped frames and need to fill all the intermediate
114+
// ones. e.g. Frame 1 to Frame 10
115+
if (nodeLocations.getOrNull(frameInd) == null) {
116+
// frameSize has not been updated yet, so on the first frame, frameSize = nodeLocations.size = 0,
117+
// and it will append a new map
118+
while (nodeLocations.size <= frameSize) {
119+
nodeLocations += mutableStateMapOf()
120+
}
121+
}
122+
123+
val frameNodeLocations = nodeLocations[frameInd]
124+
SearchBox(
125+
nodes = frameNodeLocations.keys.toList(),
126+
onSearch = { name ->
127+
sandboxState.scale = 1f
128+
val node = frameNodeLocations.keys.first { it.name == name }
129+
val newX = (sandboxState.offset.x - frameNodeLocations.getValue(node).x
130+
+ appWindowSize.width / 2)
131+
val newY = (sandboxState.offset.y - frameNodeLocations.getValue(node).y
132+
+ appWindowSize.height / 2)
133+
sandboxState.offset = Offset(x = newX, y = newY)
134+
},
135+
)
136+
137+
FrameNavigator(
138+
totalFrames = frameSize,
139+
currentIndex = frameIndex,
140+
onIndexChange = { frameIndex = it },
141+
)
142+
}
143+
}
91144

92145
TraceModeToggleSwitch(
93146
onToggle = {
@@ -96,13 +149,12 @@ internal fun App(
96149
frameIndex = 0
97150
TraceMode.File(null)
98151
} else {
99-
// TODO: TraceRecorder needs to be able to take in multiple clients if this is the case
100152
/*
101153
We set the frame to -1 here since we always increment it during Live mode as the list of
102154
frames get populated, so we avoid off by one when indexing into the frames.
103155
*/
104156
frameIndex = -1
105-
TraceMode.Live
157+
TraceMode.Live()
106158
}
107159
},
108160
traceMode = traceMode,
@@ -120,6 +172,26 @@ internal fun App(
120172
modifier = Modifier.align(Alignment.BottomStart)
121173
)
122174
}
175+
176+
if (traceMode is TraceMode.Live && (traceMode as TraceMode.Live).device == null) {
177+
DisplayDevices(
178+
onDeviceSelect = { selectedDevice ->
179+
traceMode = TraceMode.Live(selectedDevice)
180+
},
181+
devices = listDevices(),
182+
modifier = Modifier.align(Alignment.Center)
183+
)
184+
185+
FileDump(
186+
trace = rawRenderPass,
187+
modifier = Modifier.align(Alignment.BottomStart)
188+
)
189+
}
190+
191+
RightInfoPanel(
192+
selectedNode = selectedNode,
193+
modifier = Modifier.align(Alignment.TopEnd)
194+
)
123195
}
124196
}
125197

@@ -134,5 +206,25 @@ internal class SandboxState {
134206

135207
internal sealed interface TraceMode {
136208
data class File(val file: PlatformFile?) : TraceMode
137-
data object Live : TraceMode
209+
data class Live(val device: String? = null) : TraceMode
210+
211+
companion object {
212+
fun validateLiveMode(traceMode: TraceMode): Boolean {
213+
return traceMode is Live && traceMode.device != null
214+
}
215+
216+
fun validateFileMode(traceMode: TraceMode): Boolean {
217+
return traceMode is File && traceMode.file != null
218+
}
219+
}
220+
}
221+
222+
/**
223+
* Allows users to select from multiple devices that are currently running.
224+
*/
225+
private fun listDevices(): List<String> {
226+
val process = ProcessBuilder("adb", "devices", "-l").start()
227+
process.waitFor()
228+
// We drop the header "List of devices attached"
229+
return process.inputStream.bufferedReader().readLines().drop(1).dropLast(1)
138230
}

0 commit comments

Comments
 (0)