Skip to content

Workflow visualizer prototype #1335

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Jun 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,6 @@ local.properties
*.iml
.idea/
captures/

# Kotlin Metadata
.kotlin/
5 changes: 5 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ androidx-viewbinding = "8.1.2"
detekt = "1.19.0"
dokka = "2.0.0"
dependencyGuard = "0.5.0"
# Any version above 0.10.0-beta03 requires Compose 1.8.0 or higher, beta03 is 1.7.3 or higher.
filekit-dialogs-compose = "0.10.0-beta03"

google-accompanist = "0.18.0"
google-dagger = "2.40.5"
Expand Down Expand Up @@ -78,6 +80,7 @@ squareup-curtains = "1.2.5"
squareup-cycler = "0.1.9"
squareup-leakcanary = "3.0-alpha-8"
squareup-moshi = "1.15.0"
squareup-moshi-kotlin = "1.15.2"
squareup-okhttp = "4.9.1"
squareup-okio = "3.3.0"
squareup-radiography = "2.4.1"
Expand Down Expand Up @@ -185,6 +188,7 @@ dokka-gradle-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", vers

dropbox-dependencyGuard = { module = "com.dropbox.dependency-guard:dependency-guard", version.ref = "dependencyGuard" }

filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekit-dialogs-compose" }
google-android-material = { module = "com.google.android.material:material", version.ref = "material" }
google-ksp = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "google-ksp" }

Expand Down Expand Up @@ -250,6 +254,7 @@ squareup-leakcanary-objectwatcher-android = { module = "com.squareup.leakcanary:
squareup-moshi = { module = "com.squareup.moshi:moshi", version.ref = "squareup-moshi" }
squareup-moshi-adapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "squareup-moshi" }
squareup-moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "squareup-moshi" }
squareup-moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "squareup-moshi-kotlin" }

squareup-okio = { module = "com.squareup.okio:okio", version.ref = "squareup-okio" }

Expand Down
4 changes: 4 additions & 0 deletions workflow-trace-viewer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ It can be run via Gradle using:
```shell
./gradlew :workflow-trace-viewer:run
```

### 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.
41 changes: 40 additions & 1 deletion workflow-trace-viewer/api/workflow-trace-viewer.api
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
public final class com/squareup/workflow1/traceviewer/AppKt {
public static final fun App (Landroidx/compose/runtime/Composer;I)V
public static final fun App (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V
}

public final class com/squareup/workflow1/traceviewer/ComposableSingletons$MainKt {
Expand All @@ -9,8 +9,47 @@ public final class com/squareup/workflow1/traceviewer/ComposableSingletons$MainK
public final fun getLambda-1$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3;
}

public final class com/squareup/workflow1/traceviewer/ComposableSingletons$UploadFileKt {
public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/ComposableSingletons$UploadFileKt;
public static field lambda-1 Lkotlin/jvm/functions/Function3;
public fun <init> ()V
public final fun getLambda-1$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3;
}

public final class com/squareup/workflow1/traceviewer/MainKt {
public static final fun main ()V
public static synthetic fun main ([Ljava/lang/String;)V
}

public final class com/squareup/workflow1/traceviewer/SandboxBackgroundKt {
public static final fun SandboxBackground (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V
}

public final class com/squareup/workflow1/traceviewer/UploadFileKt {
public static final fun UploadFile (Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V
}

public final class com/squareup/workflow1/traceviewer/WorkflowJsonParserKt {
public static final fun parseTrace (Ljava/lang/String;)Lcom/squareup/workflow1/traceviewer/WorkflowNode;
}

public final class com/squareup/workflow1/traceviewer/WorkflowNode {
public static final field $stable I
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/lang/String;
public final fun component3 ()Ljava/util/List;
public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)Lcom/squareup/workflow1/traceviewer/WorkflowNode;
public static synthetic fun copy$default (Lcom/squareup/workflow1/traceviewer/WorkflowNode;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lcom/squareup/workflow1/traceviewer/WorkflowNode;
public fun equals (Ljava/lang/Object;)Z
public final fun getChildren ()Ljava/util/List;
public final fun getId ()Ljava/lang/String;
public final fun getName ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class com/squareup/workflow1/traceviewer/WorkflowTreeKt {
public static final fun DrawWorkflowTree (Lcom/squareup/workflow1/traceviewer/WorkflowNode;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V
}

2 changes: 2 additions & 0 deletions workflow-trace-viewer/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ kotlin {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutines.swing)
implementation(compose.materialIconsExtended)
implementation(libs.squareup.moshi.kotlin)
implementation(libs.filekit.dialogs.compose)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,46 @@
package com.squareup.workflow1.traceviewer

import androidx.compose.foundation.layout.Box
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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 io.github.vinceglb.filekit.PlatformFile
import io.github.vinceglb.filekit.readString

/**
* Main composable that provides the different layers of UI.
*/
@Composable
fun App() {
Text("Hello world!")
public fun App(
modifier: Modifier = Modifier
) {
Box {
var selectedFile by remember { mutableStateOf<PlatformFile?>(null) }

if (selectedFile != null) {
SandboxBackground { WorkflowContent(selectedFile!!) }
}

UploadFile(onFileSelect = { selectedFile = it })
}
}

@Composable
private fun WorkflowContent(file: PlatformFile) {
var jsonString by remember { mutableStateOf<String?>(null) }
LaunchedEffect(file) {
jsonString = file.readString()
}
val root = jsonString?.let { parseTrace(it) }

if (root != null) {
DrawWorkflowTree(root)
} else {
Text("Empty data or failed to parse data") // TODO: proper handling of error
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package com.squareup.workflow1.traceviewer

import androidx.compose.ui.window.singleWindowApplication

/**
* Main entry point for the desktop application, see [README.md] for more details.
*/
fun main() {
singleWindowApplication(title = "Workflow Trace Viewer") {
App()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.squareup.workflow1.traceviewer

import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput

/**
* This is the backdrop for the whole app. Since there can be hundreds of modules at a time, there
* is not realistic way to fit everything on the screen at once. Having the liberty to pan across
* the whole tree as well as zoom into specific subtrees means there's a lot more control when
* analyzing the traces.
*
*/
@Composable
public fun SandboxBackground(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }

Box(
modifier
.fillMaxSize()
.pointerInput(Unit) {
// Panning capabilities: watches for drag gestures and applies the translation
detectDragGestures { _, translation->
offset += translation
}
Comment on lines +41 to +43
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

}
.pointerInput(Unit) {
// Zooming capabilities: watches for any scroll events and immediately consumes changes.
// - This is AI generated.
awaitEachGesture {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this from a blog post or ref article or something? If so, it can be valuable to post the link in a comment as then we can follow up during maintenance.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added note that code is AI generated.

val event = awaitPointerEvent()
if (event.type == PointerEventType.Scroll) {
val scrollDelta = event.changes.first().scrollDelta.y
scale *= if (scrollDelta < 0) 1.1f else 0.9f
scale = scale.coerceIn(0.1f, 10f)
event.changes.forEach { it.consume() }
}
}
}
) {
Box(
modifier = Modifier
.wrapContentSize(unbounded = true, align = Alignment.Center)
.graphicsLayer {
translationX = offset.x
translationY = offset.y
scaleX = scale
scaleY = scale
}
) {
content()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.squareup.workflow1.traceviewer

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.github.vinceglb.filekit.PlatformFile
import io.github.vinceglb.filekit.dialogs.FileKitType
import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher

/**
* Provides functionality for user to upload a JSON or .txt file from their local devices, which
* contains information pulled from workflow traces
*/
@Composable
public fun UploadFile(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs kdoc.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

onFileSelect: (PlatformFile?) -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier
.padding(16.dp)
.fillMaxSize()
) {
val launcher = rememberFilePickerLauncher(
type = FileKitType.File(listOf("json", "txt")),
title = "Select Workflow Trace File"
) {
onFileSelect(it)
}
Button(
onClick = { launcher.launch() },
modifier = Modifier
.align(Alignment.BottomEnd),
shape = CircleShape,
colors = buttonColors(Color.Black)
) {
Text(
text = "+",
color = Color.White,
fontSize = 24.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.squareup.workflow1.traceviewer

import com.squareup.moshi.JsonDataException
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import java.io.IOException

/**
* Parses a JSON string into [WorkflowNode] with Moshi adapters.
*
* All the caught exceptions should be handled by the caller, and appropriate UI feedback should be
* provided to user.
*/
public fun parseTrace(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON parsing can be long running. This would be better as a suspend fun that wraps this work in a withContext(Dispatchers.IO) block. Then, instead of having a jsonString being used as state in your WorkflowContent Composable, you'd have a nullable WorkflowNode.

That would make WorkflowContent look something like this:

@Composable
private fun WorkflowContent(file: PlatformFile?) {
  var rootNode by remember { mutableStateOf<WorkflowNode?>(null) }
  LaunchedEffect(file) {
     if(file != null) { 
       rootNode = parseTrace(file?.readString()) // in the future, a try-catch here to switch to an error state!
    }
  }

  if (rootNode != null) {
    DrawWorkflowTree(root)
  } else {
    Text("Empty data or failed to parse data") // TODO: proper handling of error
  }
}

For the future, when you get to the point where you might wanna show an error on parse failures, you'd instead just let this throw and expect it to be caught in the LaunchedEffect you call this from so that the consumer could switch to some error state. But that's looking ahead so don't stress that now!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Working on this in another branch!

json: String
): WorkflowNode? {
return try {
val moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
val workflowAdapter = moshi.adapter(WorkflowNode::class.java)
val root = workflowAdapter.fromJson(json)
root
} catch (e: JsonDataException) {
throw JsonDataException("Failed to parse JSON: ${e.message}", e)
} catch (e: IOException) {
throw IOException("Malformed JSON: ${e.message}", e)
}
}
Loading
Loading