diff --git a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/BodySection.kt b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/BodySection.kt index c8d2213..22b98d3 100644 --- a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/BodySection.kt +++ b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/BodySection.kt @@ -4,11 +4,8 @@ package com.openai.snapo.desktop.ui.inspector import androidx.compose.foundation.ContextMenuArea import androidx.compose.foundation.ContextMenuItem -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -25,7 +22,6 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap import com.openai.snapo.desktop.inspector.NetworkInspectorRequestUiModel import com.openai.snapo.desktop.ui.TriangleIndicator -import com.openai.snapo.desktop.ui.json.JsonOutlineExpansionState import com.openai.snapo.desktop.ui.theme.Spacings import java.awt.FileDialog import java.awt.Frame @@ -38,42 +34,8 @@ import java.io.FileOutputStream import javax.imageio.ImageIO import org.jetbrains.skia.Image as SkiaImage -@OptIn(ExperimentalFoundationApi::class) @Composable -fun BodySection( - title: String, - payload: NetworkInspectorRequestUiModel.BodyPayload, - isExpanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - usePrettyPrinted: Boolean, - onPrettyPrintedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, - jsonOutlineState: JsonOutlineExpansionState? = null, -) { - Column( - verticalArrangement = Arrangement.spacedBy(Spacings.sm), - modifier = modifier, - ) { - BodySectionHeader( - title = title, - payload = payload, - isExpanded = isExpanded, - onExpandedChange = onExpandedChange, - ) - - if (isExpanded) { - BodySectionContent( - payload = payload, - usePrettyPrinted = usePrettyPrinted, - onPrettyPrintedChange = onPrettyPrintedChange, - jsonOutlineState = jsonOutlineState, - ) - } - } -} - -@Composable -private fun BodySectionHeader( +internal fun BodySectionHeader( title: String, payload: NetworkInspectorRequestUiModel.BodyPayload, isExpanded: Boolean, @@ -106,32 +68,7 @@ private fun BodySectionHeader( } @Composable -private fun BodySectionContent( - payload: NetworkInspectorRequestUiModel.BodyPayload, - usePrettyPrinted: Boolean, - onPrettyPrintedChange: (Boolean) -> Unit, - jsonOutlineState: JsonOutlineExpansionState?, -) { - val imageBitmap = remember(payload.data) { payload.data?.let(::decodeImageBitmap) } - val bytes = payload.data - if (imageBitmap != null && bytes != null) { - BodyImagePreview( - payload = payload, - imageBitmap = imageBitmap, - bytes = bytes, - ) - } else { - BodyTextPayload( - payload = payload, - usePrettyPrinted = usePrettyPrinted, - onPrettyPrintedChange = onPrettyPrintedChange, - jsonOutlineState = jsonOutlineState, - ) - } -} - -@Composable -private fun BodyImagePreview( +internal fun BodyImagePreview( payload: NetworkInspectorRequestUiModel.BodyPayload, imageBitmap: ImageBitmap, bytes: ByteArray, @@ -169,26 +106,6 @@ private fun BodyImagePreview( } } -@Composable -private fun BodyTextPayload( - payload: NetworkInspectorRequestUiModel.BodyPayload, - usePrettyPrinted: Boolean, - onPrettyPrintedChange: (Boolean) -> Unit, - jsonOutlineState: JsonOutlineExpansionState?, -) { - InspectorCard { - InspectorPayloadView( - rawText = payload.rawText, - prettyText = payload.prettyPrintedText, - isLikelyJson = payload.isLikelyJson, - usePrettyPrinted = usePrettyPrinted, - onPrettyPrintedChange = onPrettyPrintedChange, - jsonOutlineState = jsonOutlineState, - modifier = Modifier.fillMaxWidth(), - ) - } -} - private fun metadataText(payload: NetworkInspectorRequestUiModel.BodyPayload): String? { val parts = mutableListOf() @@ -226,7 +143,7 @@ private fun formatBytes(byteCount: Long): String { } } -private fun decodeImageBitmap(bytes: ByteArray): ImageBitmap? { +internal fun decodeImageBitmap(bytes: ByteArray): ImageBitmap? { return try { SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap() } catch (_: Throwable) { diff --git a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/HeadersSection.kt b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/HeadersSection.kt index 8436bde..9caa994 100644 --- a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/HeadersSection.kt +++ b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/HeadersSection.kt @@ -3,14 +3,12 @@ package com.openai.snapo.desktop.ui.inspector import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.selection.DisableSelection import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme @@ -54,17 +52,12 @@ import kotlin.math.max import java.awt.datatransfer.Clipboard as AwtClipboard @Composable -fun HeadersSection( +internal fun HeadersSectionHeader( title: String, - headers: List
, isExpanded: Boolean, onExpandedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, ) { - Column( - verticalArrangement = Arrangement.spacedBy(Spacings.sm), - modifier = modifier, - ) { + DisableSelection { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -80,21 +73,20 @@ fun HeadersSection( style = MaterialTheme.typography.titleSmallEmphasized, ) } - - if (isExpanded) { - HeadersSectionBody(headers = headers) - Spacer(modifier = Modifier.size(Spacings.xs)) - } } } @Composable -private fun HeadersSectionBody(headers: List
) { +internal fun HeadersSectionBody( + headers: List
, + modifier: Modifier = Modifier, +) { if (headers.isEmpty()) { Text( text = "None", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier, ) return } @@ -111,7 +103,7 @@ private fun HeadersSectionBody(headers: List
) { SelectionContainer { HeaderGridLayout( rowGap = Spacings.sm, - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), ) { headers.forEach { header -> HeaderNameCell( diff --git a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/InspectorDetailScaffold.kt b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/InspectorDetailScaffold.kt index 75ea8b4..3f5ef90 100644 --- a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/InspectorDetailScaffold.kt +++ b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/InspectorDetailScaffold.kt @@ -2,15 +2,14 @@ package com.openai.snapo.desktop.ui.inspector import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollbarAdapter -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -27,19 +26,28 @@ internal val InspectorDetailContentPadding = PaddingValues( fun InspectorDetailScaffold( modifier: Modifier = Modifier, contentPadding: PaddingValues = InspectorDetailContentPadding, - content: @Composable ColumnScope.() -> Unit, + selectionEnabled: Boolean = true, + content: LazyListScope.() -> Unit, ) { - val scrollState = rememberScrollState() + val listState = rememberLazyListState() Box(modifier = modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(contentPadding), - content = content, - ) + val list = @Composable { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = contentPadding, + content = content, + ) + } + if (selectionEnabled) { + SelectionContainer { + list() + } + } else { + list() + } VerticalScrollbar( - adapter = rememberScrollbarAdapter(scrollState), + adapter = rememberScrollbarAdapter(listState), modifier = Modifier .align(Alignment.CenterEnd) .fillMaxHeight(), diff --git a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/InspectorPayloadView.kt b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/InspectorPayloadView.kt index 73eee65..c9a1e1c 100644 --- a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/InspectorPayloadView.kt +++ b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/InspectorPayloadView.kt @@ -16,6 +16,9 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -32,6 +35,8 @@ 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.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp import com.openai.snapo.desktop.generated.resources.Res import com.openai.snapo.desktop.generated.resources.check_24px @@ -39,7 +44,10 @@ import com.openai.snapo.desktop.generated.resources.content_copy_24px import com.openai.snapo.desktop.inspector.NetworkInspectorCopyExporter import com.openai.snapo.desktop.ui.json.JsonOutlineExpansionState import com.openai.snapo.desktop.ui.json.JsonOutlineNode +import com.openai.snapo.desktop.ui.json.JsonOutlineRowItem +import com.openai.snapo.desktop.ui.json.JsonOutlineRowsState import com.openai.snapo.desktop.ui.json.JsonOutlineView +import com.openai.snapo.desktop.ui.json.rememberJsonOutlineRowsState import com.openai.snapo.desktop.ui.theme.SnapOMono import com.openai.snapo.desktop.ui.theme.Spacings import kotlinx.coroutines.delay @@ -166,6 +174,31 @@ private data class InspectorPayloadDerived( val showJsonParseFailureHint: Boolean, ) +internal data class InspectorPayloadLazyState( + val displayText: String, + val prettyText: String?, + val jsonRoot: JsonOutlineNode?, + val showsJsonOutline: Boolean, + val showInlineActionsInJsonOutline: Boolean, + val hasToggle: Boolean, + val hasCopy: Boolean, + val localPretty: Boolean, + val didCopy: Boolean, + val prettyInitiallyExpanded: Boolean, + val payloadKey: Int, + val onTogglePretty: () -> Unit, + val onCopy: () -> Unit, + val showJsonParseFailureHint: Boolean, + val rootTrailingContent: (@Composable RowScope.(JsonOutlineNode) -> Unit)?, + val jsonOutlineRowsState: JsonOutlineRowsState?, +) + +private data class InspectorPayloadBodyParts( + val bodyState: InspectorPayloadBodyState, + val bodyActions: InspectorPayloadBodyActions, + val didCopy: Boolean, +) + @Composable private fun rememberInspectorPayloadDerived( localPretty: Boolean, @@ -200,6 +233,111 @@ private fun rememberInspectorPayloadDerived( ) } +@Composable +internal fun rememberInspectorPayloadLazyState( + rawText: String, + prettyText: String?, + isLikelyJson: Boolean, + usePrettyPrinted: Boolean, + onPrettyPrintedChange: (Boolean) -> Unit, + showsToggle: Boolean = true, + showsCopyButton: Boolean = true, + prettyInitiallyExpanded: Boolean = true, + jsonOutlineState: JsonOutlineExpansionState? = null, +): InspectorPayloadLazyState { + val prettyState = rememberPrettyToggleState( + usePrettyPrinted = usePrettyPrinted, + onPrettyPrintedChange = onPrettyPrintedChange, + ) + val derived = rememberInspectorPayloadDerived( + localPretty = prettyState.isPretty, + prettyText = prettyText, + rawText = rawText, + showsToggle = showsToggle, + showsCopyButton = showsCopyButton, + isLikelyJson = isLikelyJson, + ) + val bodyParts = rememberInspectorPayloadBodyParts( + derived = derived, + prettyState = prettyState, + prettyText = prettyText, + prettyInitiallyExpanded = prettyInitiallyExpanded, + jsonOutlineState = jsonOutlineState, + ) + val rootTrailingContent = inlineJsonOutlineActions( + enabled = derived.showInlineActionsInJsonOutline, + hasToggle = derived.hasToggle, + hasCopy = derived.hasCopy, + localPretty = prettyState.isPretty, + didCopy = bodyParts.didCopy, + onTogglePretty = bodyParts.bodyActions.onTogglePretty, + onCopy = bodyParts.bodyActions.onCopy, + ) + val jsonOutlineRowsState = + if (bodyParts.bodyState.showsJsonOutline && bodyParts.bodyState.jsonRoot != null) { + rememberJsonOutlineRowsState( + root = bodyParts.bodyState.jsonRoot, + initiallyExpanded = bodyParts.bodyState.prettyInitiallyExpanded, + expansionState = bodyParts.bodyState.jsonOutlineState, + payloadKey = bodyParts.bodyState.payloadKey, + ) + } else { + null + } + return InspectorPayloadLazyState( + displayText = derived.displayText, + prettyText = prettyText, + jsonRoot = derived.jsonRoot, + showsJsonOutline = derived.showsJsonOutline, + showInlineActionsInJsonOutline = derived.showInlineActionsInJsonOutline, + hasToggle = derived.hasToggle, + hasCopy = derived.hasCopy, + localPretty = prettyState.isPretty, + didCopy = bodyParts.didCopy, + prettyInitiallyExpanded = prettyInitiallyExpanded, + payloadKey = derived.payloadKey, + onTogglePretty = bodyParts.bodyActions.onTogglePretty, + onCopy = bodyParts.bodyActions.onCopy, + showJsonParseFailureHint = derived.showJsonParseFailureHint, + rootTrailingContent = rootTrailingContent, + jsonOutlineRowsState = jsonOutlineRowsState, + ) +} + +@Composable +private fun rememberInspectorPayloadBodyParts( + derived: InspectorPayloadDerived, + prettyState: PrettyToggleState, + prettyText: String?, + prettyInitiallyExpanded: Boolean, + jsonOutlineState: JsonOutlineExpansionState?, +): InspectorPayloadBodyParts { + val copyFeedback = rememberCopyFeedback(displayText = derived.displayText, copyKey = derived.payloadKey) + val bodyState = InspectorPayloadBodyState( + displayText = derived.displayText, + prettyText = prettyText, + jsonRoot = derived.jsonRoot, + showsJsonOutline = derived.showsJsonOutline, + showInlineActionsInJsonOutline = derived.showInlineActionsInJsonOutline, + hasToggle = derived.hasToggle, + hasCopy = derived.hasCopy, + localPretty = prettyState.isPretty, + didCopy = copyFeedback.didCopy, + prettyInitiallyExpanded = prettyInitiallyExpanded, + payloadKey = derived.payloadKey, + jsonOutlineState = jsonOutlineState, + ) + val bodyActions = InspectorPayloadBodyActions( + onTogglePretty = prettyState.onTogglePretty, + onCopy = copyFeedback.onCopy, + ) + return InspectorPayloadBodyParts( + bodyState = bodyState, + bodyActions = bodyActions, + didCopy = copyFeedback.didCopy, + ) +} + private fun displayTextFor( localPretty: Boolean, prettyText: String?, @@ -308,6 +446,197 @@ private fun JsonParseFailureHint(show: Boolean) { ) } +internal fun LazyListScope.inspectorPayloadItems( + state: InspectorPayloadLazyState, + keyPrefix: String, + modifier: Modifier = Modifier, +) { + val segments = buildPayloadSegments(state) + val lastIndex = segments.lastIndex + itemsIndexed( + items = segments, + key = { _, segment -> "$keyPrefix:${segment.key}" }, + ) { index, segment -> + InspectorCardSegment( + index = index, + lastIndex = lastIndex, + bottomPadding = segment.bottomPadding, + modifier = modifier, + ) { + segment.content() + } + } +} + +private data class PayloadSegment( + val key: String, + val bottomPadding: androidx.compose.ui.unit.Dp, + val content: @Composable () -> Unit, +) + +private fun buildPayloadSegments(state: InspectorPayloadLazyState): List { + val hasBody = state.showsJsonOutline || state.displayText.isNotEmpty() || state.prettyText != null + return buildList { + addControlsSegment(state, hasBody) + addParseHintSegment(state, hasBody) + addBodySegments(state) + } +} + +private fun MutableList.addControlsSegment( + state: InspectorPayloadLazyState, + hasBody: Boolean, +) { + if (state.showInlineActionsInJsonOutline) return + val bottomPadding = if (state.showJsonParseFailureHint || hasBody) Spacings.sm else 0.dp + add( + PayloadSegment( + key = "controls", + bottomPadding = bottomPadding, + ) { + ControlsRow( + hasToggle = state.hasToggle, + prettyChecked = state.localPretty, + onPrettyToggle = state.onTogglePretty, + showsCopyButton = state.hasCopy, + didCopy = state.didCopy, + onCopy = state.onCopy, + ) + } + ) +} + +private fun MutableList.addParseHintSegment( + state: InspectorPayloadLazyState, + hasBody: Boolean, +) { + if (!state.showJsonParseFailureHint) return + val bottomPadding = if (hasBody) Spacings.sm else 0.dp + add( + PayloadSegment( + key = "parse-hint", + bottomPadding = bottomPadding, + ) { JsonParseFailureHint(show = true) } + ) +} + +private fun MutableList.addBodySegments( + state: InspectorPayloadLazyState, +) { + if (state.showsJsonOutline && state.jsonOutlineRowsState != null) { + addJsonSegments(state) + } else { + addTextSegment(state) + } +} + +private fun MutableList.addJsonSegments( + state: InspectorPayloadLazyState, +) { + val rows = state.jsonOutlineRowsState?.rows.orEmpty() + rows.forEachIndexed { index, row -> + val bottomPadding = if (index == rows.lastIndex) 0.dp else Spacings.xxs + add( + PayloadSegment( + key = "json:${row.key}", + bottomPadding = bottomPadding, + ) { + JsonOutlineRowItem( + row = row, + outlineState = state.jsonOutlineRowsState!!, + rootTrailingContent = state.rootTrailingContent, + ) + } + ) + } +} + +private fun MutableList.addTextSegment( + state: InspectorPayloadLazyState, +) { + add( + PayloadSegment( + key = "text", + bottomPadding = 0.dp, + ) { + Text( + text = payloadDisplayText(state), + style = MaterialTheme.typography.bodySmall, + fontFamily = SnapOMono, + ) + } + ) +} + +private fun payloadDisplayText(state: InspectorPayloadLazyState): String { + return if (state.localPretty && state.prettyText != null) { + state.prettyText + } else { + state.displayText + } +} + +@Composable +private fun InspectorCardSegment( + index: Int, + lastIndex: Int, + bottomPadding: androidx.compose.ui.unit.Dp, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val shape = cardSegmentShape(index, lastIndex) + val topPadding = if (index == 0) Spacings.md else 0.dp + val endPadding = Spacings.mdPlus + val startPadding = Spacings.mdPlus + val finalBottomPadding = bottomPadding + if (index == lastIndex) Spacings.md else 0.dp + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = shape, + ) + .padding( + start = startPadding, + end = endPadding, + top = topPadding, + bottom = finalBottomPadding, + ), + ) { + content() + } +} + +@Composable +private fun cardSegmentShape( + index: Int, + lastIndex: Int, +): Shape { + val baseShape = MaterialTheme.shapes.extraSmall + if (index == 0 && index == lastIndex) return baseShape + val cornerShape = baseShape + val topStart = cornerShape.topStart + val topEnd = cornerShape.topEnd + val bottomStart = cornerShape.bottomStart + val bottomEnd = cornerShape.bottomEnd + + return when { + index == 0 -> RoundedCornerShape( + topStart = topStart, + topEnd = topEnd, + bottomStart = androidx.compose.foundation.shape.CornerSize(0.dp), + bottomEnd = androidx.compose.foundation.shape.CornerSize(0.dp), + ) + index == lastIndex -> RoundedCornerShape( + topStart = androidx.compose.foundation.shape.CornerSize(0.dp), + topEnd = androidx.compose.foundation.shape.CornerSize(0.dp), + bottomStart = bottomStart, + bottomEnd = bottomEnd, + ) + else -> RectangleShape + } +} + private data class InspectorPayloadBodyState( val displayText: String, val prettyText: String?, diff --git a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/NetworkInspectorScreen.kt b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/NetworkInspectorScreen.kt index 3380cc6..c12b0fe 100644 --- a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/NetworkInspectorScreen.kt +++ b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/NetworkInspectorScreen.kt @@ -152,11 +152,7 @@ private fun rememberDerivedState( } } val filteredItems = remember(serverScopedItems, searchText) { - if (searchText.isBlank()) { - serverScopedItems - } else { - serverScopedItems.filter { it.url.contains(searchText, ignoreCase = true) } - } + filterItemsByUrlSearch(serverScopedItems, searchText) } return InspectorDerivedState( selectedServer = selectedServer, diff --git a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/RequestDetailView.kt b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/RequestDetailView.kt index c65d6e2..8c26666 100644 --- a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/RequestDetailView.kt +++ b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/RequestDetailView.kt @@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.text.selection.DisableSelection import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme @@ -18,13 +20,13 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf 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.graphics.ImageBitmap import com.openai.snapo.desktop.inspector.NetworkInspectorCopyExporter import com.openai.snapo.desktop.inspector.NetworkInspectorRequestStatus import com.openai.snapo.desktop.inspector.NetworkInspectorRequestUiModel @@ -115,116 +117,251 @@ private data class RequestDetailActions( val onCopyAllEvents: () -> Unit, ) +private data class RequestDetailPayloads( + val requestBody: NetworkInspectorRequestUiModel.BodyPayload?, + val responseBody: NetworkInspectorRequestUiModel.BodyPayload?, + val requestBodyImage: ImageBitmap?, + val responseBodyImage: ImageBitmap?, + val requestPayloadState: InspectorPayloadLazyState?, + val responsePayloadState: InspectorPayloadLazyState?, +) + @Composable private fun RequestDetailContent( state: RequestDetailState, actions: RequestDetailActions, ) { val request = state.request + val payloads = rememberRequestDetailPayloads(state, actions) + InspectorDetailScaffold { - HeaderSummary( - request = request, - modifier = Modifier.padding(start = Spacings.xs, end = Spacings.xs, bottom = Spacings.md), + requestDetailItems( + state = state, + actions = actions, + payloads = payloads, ) - - RequestHeadersSection(state, actions) - RequestBodySection(state, actions) - PendingResponseSection(request) - ResponseHeadersSection(state, actions) - - if (request.isStreamingResponse) { - StreamEventsSection( - request = request, - streamExpanded = state.streamExpanded, - onStreamExpandedChange = actions.onStreamExpandedChange, - didCopyAllEvents = state.didCopyAllEvents, - onCopyAllEvents = actions.onCopyAllEvents, - streamEventJsonStateProvider = state.streamEventJsonStateProvider, - ) - } - - ResponseBodySection(state, actions) } } @Composable -private fun RequestHeadersSection( +private fun rememberRequestDetailPayloads( state: RequestDetailState, actions: RequestDetailActions, -) { +): RequestDetailPayloads { val request = state.request - if (request.requestHeaders.isEmpty()) return - HeadersSection( - title = "Request Headers", - headers = request.requestHeaders, - isExpanded = state.requestHeadersExpanded, - onExpandedChange = actions.onRequestHeadersExpandedChange, + val requestBody = request.requestBody + val responseBody = request.responseBody + val requestBodyImage = remember(requestBody?.data, state.requestBodyExpanded) { + if (state.requestBodyExpanded) requestBody?.data?.let(::decodeImageBitmap) else null + } + val responseBodyImage = remember(responseBody?.data, state.responseBodyExpanded) { + if (state.responseBodyExpanded) responseBody?.data?.let(::decodeImageBitmap) else null + } + val requestPayloadState = rememberPayloadStateIfNeeded( + payload = requestBody, + imageBitmap = requestBodyImage, + isExpanded = state.requestBodyExpanded, + usePrettyPrinted = state.requestBodyPretty, + onPrettyPrintedChange = actions.onRequestBodyPrettyChange, + jsonOutlineState = state.requestBodyJsonState, + ) + val responsePayloadState = rememberPayloadStateIfNeeded( + payload = responseBody, + imageBitmap = responseBodyImage, + isExpanded = state.responseBodyExpanded, + usePrettyPrinted = state.responseBodyPretty, + onPrettyPrintedChange = actions.onResponseBodyPrettyChange, + jsonOutlineState = state.responseBodyJsonState, + ) + return RequestDetailPayloads( + requestBody = requestBody, + responseBody = responseBody, + requestBodyImage = requestBodyImage, + responseBodyImage = responseBodyImage, + requestPayloadState = requestPayloadState, + responsePayloadState = responsePayloadState, ) } -@Composable -private fun RequestBodySection( +private fun LazyListScope.requestDetailItems( state: RequestDetailState, actions: RequestDetailActions, + payloads: RequestDetailPayloads, ) { val request = state.request - val payload = request.requestBody ?: return - BodySection( + item(key = "request:header") { + HeaderSummary( + request = request, + modifier = Modifier.padding(start = Spacings.xs, end = Spacings.xs, bottom = Spacings.md), + ) + } + + requestHeadersSectionItems( + state = state, + actions = actions, + keyPrefix = "request-headers", + ) + bodySectionItems( title = "Request Body", - payload = payload, + payload = payloads.requestBody, isExpanded = state.requestBodyExpanded, onExpandedChange = actions.onRequestBodyExpandedChange, - usePrettyPrinted = state.requestBodyPretty, - onPrettyPrintedChange = actions.onRequestBodyPrettyChange, - jsonOutlineState = state.requestBodyJsonState, + imageBitmap = payloads.requestBodyImage, + payloadState = payloads.requestPayloadState, + keyPrefix = "request-body", + ) + pendingResponseSectionItems(request) + responseHeadersSectionItems( + state = state, + actions = actions, + keyPrefix = "response-headers", + ) + + if (request.isStreamingResponse) { + streamEventsSectionItems( + request = request, + streamExpanded = state.streamExpanded, + onStreamExpandedChange = actions.onStreamExpandedChange, + didCopyAllEvents = state.didCopyAllEvents, + onCopyAllEvents = actions.onCopyAllEvents, + streamEventJsonStateProvider = state.streamEventJsonStateProvider, + ) + } + + bodySectionItems( + title = "Response Body", + payload = payloads.responseBody, + isExpanded = state.responseBodyExpanded, + onExpandedChange = actions.onResponseBodyExpandedChange, + imageBitmap = payloads.responseBodyImage, + payloadState = payloads.responsePayloadState, + keyPrefix = "response-body", ) } @Composable -private fun PendingResponseSection(request: NetworkInspectorRequestUiModel) { - if (request.status !is NetworkInspectorRequestStatus.Pending) return - Text( - "Waiting for response...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, +private fun rememberPayloadStateIfNeeded( + payload: NetworkInspectorRequestUiModel.BodyPayload?, + imageBitmap: ImageBitmap?, + isExpanded: Boolean, + usePrettyPrinted: Boolean, + onPrettyPrintedChange: (Boolean) -> Unit, + jsonOutlineState: JsonOutlineExpansionState?, +): InspectorPayloadLazyState? { + if (payload == null) return null + if (imageBitmap != null && payload.data != null) return null + if (!isExpanded) return null + return rememberInspectorPayloadLazyState( + rawText = payload.rawText, + prettyText = payload.prettyPrintedText, + isLikelyJson = payload.isLikelyJson, + usePrettyPrinted = usePrettyPrinted, + onPrettyPrintedChange = onPrettyPrintedChange, + jsonOutlineState = jsonOutlineState, ) } -@Composable -private fun ResponseHeadersSection( +private fun LazyListScope.requestHeadersSectionItems( state: RequestDetailState, actions: RequestDetailActions, + keyPrefix: String, ) { val request = state.request - if (request.responseHeaders.isEmpty()) return - HeadersSection( - title = "Response Headers", - headers = request.responseHeaders, - isExpanded = state.responseHeadersExpanded, - onExpandedChange = actions.onResponseHeadersExpandedChange, - ) + if (request.requestHeaders.isEmpty()) return + item(key = "$keyPrefix:header") { + HeadersSectionHeader( + title = "Request Headers", + isExpanded = state.requestHeadersExpanded, + onExpandedChange = actions.onRequestHeadersExpandedChange, + ) + } + if (state.requestHeadersExpanded) { + item(key = "$keyPrefix:body") { + HeadersSectionBody( + headers = request.requestHeaders, + modifier = Modifier.padding(top = Spacings.sm, bottom = Spacings.xs), + ) + } + } } -@Composable -private fun ResponseBodySection( +private fun LazyListScope.responseHeadersSectionItems( state: RequestDetailState, actions: RequestDetailActions, + keyPrefix: String, ) { val request = state.request - val payload = request.responseBody ?: return - BodySection( - title = "Response Body", - payload = payload, - isExpanded = state.responseBodyExpanded, - onExpandedChange = actions.onResponseBodyExpandedChange, - usePrettyPrinted = state.responseBodyPretty, - onPrettyPrintedChange = actions.onResponseBodyPrettyChange, - jsonOutlineState = state.responseBodyJsonState, - ) + if (request.responseHeaders.isEmpty()) return + item(key = "$keyPrefix:header") { + HeadersSectionHeader( + title = "Response Headers", + isExpanded = state.responseHeadersExpanded, + onExpandedChange = actions.onResponseHeadersExpandedChange, + ) + } + if (state.responseHeadersExpanded) { + item(key = "$keyPrefix:body") { + HeadersSectionBody( + headers = request.responseHeaders, + modifier = Modifier.padding(top = Spacings.sm, bottom = Spacings.xs), + ) + } + } } -@Composable -private fun StreamEventsSection( +private fun LazyListScope.bodySectionItems( + title: String, + payload: NetworkInspectorRequestUiModel.BodyPayload?, + isExpanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + imageBitmap: ImageBitmap?, + payloadState: InspectorPayloadLazyState?, + keyPrefix: String, +) { + if (payload == null) return + item(key = "$keyPrefix:header") { + DisableSelection { + BodySectionHeader( + title = title, + payload = payload, + isExpanded = isExpanded, + onExpandedChange = onExpandedChange, + ) + } + } + if (isExpanded) { + item(key = "$keyPrefix:gap") { Spacer(modifier = Modifier.size(Spacings.sm)) } + if (imageBitmap != null && payload.data != null) { + item(key = "$keyPrefix:image") { + BodyImagePreview( + payload = payload, + imageBitmap = imageBitmap, + bytes = payload.data, + ) + } + } else if (payloadState != null) { + inspectorPayloadItems( + state = payloadState, + keyPrefix = "$keyPrefix:payload", + ) + } + } +} + +private fun LazyListScope.pendingResponseSectionItems( + request: NetworkInspectorRequestUiModel, +) { + if (request.status !is NetworkInspectorRequestStatus.Pending) return + item(key = "response:pending") { + Text( + "Waiting for response...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private fun LazyListScope.streamEventsSectionItems( request: NetworkInspectorRequestUiModel, streamExpanded: Boolean, onStreamExpandedChange: (Boolean) -> Unit, @@ -232,24 +369,30 @@ private fun StreamEventsSection( onCopyAllEvents: () -> Unit, streamEventJsonStateProvider: (Long) -> JsonOutlineExpansionState, ) { - StreamEventsHeader( - isExpanded = streamExpanded, - onExpandedChange = onStreamExpandedChange, - hasEvents = request.streamEvents.isNotEmpty(), - didCopyAll = didCopyAllEvents, - onCopyAll = onCopyAllEvents, - ) + item(key = "stream:header") { + DisableSelection { + StreamEventsHeader( + isExpanded = streamExpanded, + onExpandedChange = onStreamExpandedChange, + hasEvents = request.streamEvents.isNotEmpty(), + didCopyAll = didCopyAllEvents, + onCopyAll = onCopyAllEvents, + ) + } + } if (streamExpanded) { if (request.streamEvents.isEmpty()) { - Text( - "Awaiting events...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + item(key = "stream:empty") { + Text( + "Awaiting events...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } else { request.streamEvents.forEach { event -> - key(event.id) { + item(key = "stream:event:${event.id}") { StreamEventCard( event = event, jsonOutlineState = streamEventJsonStateProvider(event.id), @@ -260,7 +403,9 @@ private fun StreamEventsSection( } request.streamClosed?.let { closed -> - StreamClosedInfo(closed = closed) + item(key = "stream:closed") { + StreamClosedInfo(closed = closed) + } } } } diff --git a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/Sidebar.kt b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/Sidebar.kt index 21df7f3..ca46a88 100644 --- a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/Sidebar.kt +++ b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/Sidebar.kt @@ -837,11 +837,7 @@ private fun previewSidebarState( sortOrder: ListSortOrder = ListSortOrder.NewestFirst, ): SidebarState { val serverScopedItems = items.filter { selectedServer == null || it.serverId == selectedServer.id } - val filteredItems = if (searchText.isBlank()) { - serverScopedItems - } else { - serverScopedItems.filter { it.url.contains(searchText, ignoreCase = true) } - } + val filteredItems = filterItemsByUrlSearch(serverScopedItems, searchText) return SidebarState( servers = servers, selectedServer = selectedServer, diff --git a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/UrlFilters.kt b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/UrlFilters.kt new file mode 100644 index 0000000..45be6db --- /dev/null +++ b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/UrlFilters.kt @@ -0,0 +1,168 @@ +package com.openai.snapo.desktop.ui.inspector + +import com.openai.snapo.desktop.inspector.NetworkInspectorListItemUiModel + +internal fun filterItemsByUrlSearch( + items: List, + searchText: String, +): List { + if (searchText.isBlank()) return items + + val tokens = parseUrlFilterTokens(searchText) + if (tokens.includes.isEmpty() && tokens.excludes.isEmpty()) return items + + return items.filter { item -> + val url = item.url + val includesMatch = tokens.includes.all { url.contains(it, ignoreCase = true) } + val excludesMatch = tokens.excludes.any { url.contains(it, ignoreCase = true) } + includesMatch && !excludesMatch + } +} + +private data class UrlFilterTokens( + val includes: List, + val excludes: List, +) + +private fun parseUrlFilterTokens(searchText: String): UrlFilterTokens { + val includes = mutableListOf() + val excludes = mutableListOf() + + tokenizeUrlSearch(searchText).forEach { token -> + if (token.value.isNotBlank()) { + if (token.isExcluded) { + excludes.add(token.value) + } else { + includes.add(token.value) + } + } + } + + return UrlFilterTokens(includes = includes, excludes = excludes) +} + +private data class UrlFilterToken( + val value: String, + val isExcluded: Boolean, +) + +private fun tokenizeUrlSearch(searchText: String): List { + val tokens = mutableListOf() + val cursor = UrlSearchCursor(searchText) + while (true) { + val token = cursor.nextToken() ?: break + tokens.add(token) + } + + return tokens +} + +private class UrlSearchCursor( + private val text: String, +) { + private val length = text.length + private var index = 0 + + fun nextToken(): UrlFilterToken? { + skipWhitespace() + if (index >= length) return null + + val isExcluded = consumeExcludePrefix() + if (index >= length) return null + + val value = if (peek() == '"') { + readQuotedToken() + } else { + readBareToken() + } + + return UrlFilterToken(value = value, isExcluded = isExcluded) + } + + private fun skipWhitespace() { + while (index < length && text[index].isWhitespace()) { + index++ + } + } + + private fun consumeExcludePrefix(): Boolean { + return if (peek() == '-') { + index++ + true + } else { + false + } + } + + private fun readQuotedToken(): String { + index++ + val builder = StringBuilder() + while (index < length) { + val current = text[index] + if (current == '"') { + index++ + break + } + + if (current == '\\') { + val escaped = readQuotedEscape() + if (escaped != null) { + builder.append(escaped) + } else { + builder.append(current) + index++ + } + } else { + builder.append(current) + index++ + } + } + return builder.toString() + } + + private fun readBareToken(): String { + val builder = StringBuilder() + while (index < length && !text[index].isWhitespace()) { + val current = text[index] + if (current == '\\') { + val escaped = readBareEscape() + if (escaped != null) { + builder.append(escaped) + } else { + builder.append(current) + index++ + } + } else { + builder.append(current) + index++ + } + } + return builder.toString() + } + + private fun readQuotedEscape(): Char? { + if (index + 1 >= length) return null + val next = text[index + 1] + return if (next == '"' || next == '\\') { + index += 2 + next + } else { + null + } + } + + private fun readBareEscape(): Char? { + if (index + 1 >= length) return null + val next = text[index + 1] + return if (next == '"' || next == '\\' || next.isWhitespace()) { + index += 2 + next + } else { + null + } + } + + private fun peek(): Char? { + return if (index < length) text[index] else null + } +} diff --git a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/WebSocketDetailView.kt b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/WebSocketDetailView.kt index 38c8b7b..5ab4d77 100644 --- a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/WebSocketDetailView.kt +++ b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/inspector/WebSocketDetailView.kt @@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.text.selection.DisableSelection import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon @@ -18,7 +20,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -35,6 +36,7 @@ import com.openai.snapo.desktop.inspector.NetworkInspectorRequestStatus import com.openai.snapo.desktop.inspector.NetworkInspectorStatusPresentation import com.openai.snapo.desktop.inspector.NetworkInspectorWebSocketUiModel import com.openai.snapo.desktop.inspector.WebSocketMessage +import com.openai.snapo.desktop.protocol.Header import com.openai.snapo.desktop.ui.TriangleIndicator import com.openai.snapo.desktop.ui.json.JsonOutlineExpansionState import com.openai.snapo.desktop.ui.theme.SnapOAccents @@ -60,38 +62,48 @@ fun WebSocketDetailView( val uiState = remember(webSocket.id) { uiStateStore.webSocketState(webSocket.id) } InspectorDetailScaffold(modifier = modifier) { - HeaderSummary(webSocket = webSocket, modifier = Modifier.padding(bottom = Spacings.md)) + item(key = "websocket:header") { + HeaderSummary(webSocket = webSocket, modifier = Modifier.padding(bottom = Spacings.md)) + } - HeadersSection( + headersSectionItems( title = "Request Headers", headers = webSocket.requestHeaders, isExpanded = uiState.requestHeadersExpanded, onExpandedChange = { uiState.requestHeadersExpanded = it }, + keyPrefix = "ws-request-headers", ) - HeadersSection( + headersSectionItems( title = "Response Headers", headers = webSocket.responseHeaders, isExpanded = uiState.responseHeadersExpanded, onExpandedChange = { uiState.responseHeadersExpanded = it }, + keyPrefix = "ws-response-headers", ) - MessagesSectionHeader( - isExpanded = uiState.messagesExpanded, - onExpandedChange = { uiState.messagesExpanded = it }, - ) + item(key = "ws-messages:header") { + DisableSelection { + MessagesSectionHeader( + isExpanded = uiState.messagesExpanded, + onExpandedChange = { uiState.messagesExpanded = it }, + ) + } + } if (uiState.messagesExpanded) { if (webSocket.messages.isEmpty()) { - Text( - "No messages yet", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = Spacings.mdPlus, top = Spacings.md), - ) + item(key = "ws-messages:empty") { + Text( + "No messages yet", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = Spacings.mdPlus, top = Spacings.md), + ) + } } else { webSocket.messages.forEach { message -> - key(message.id) { + item(key = "ws-message:${message.id}") { MessageCard( message = message, jsonOutlineState = uiState.messageJsonState(message.id), @@ -104,6 +116,31 @@ fun WebSocketDetailView( } } +private fun LazyListScope.headersSectionItems( + title: String, + headers: List
, + isExpanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + keyPrefix: String, +) { + if (headers.isEmpty()) return + item(key = "$keyPrefix:header") { + HeadersSectionHeader( + title = title, + isExpanded = isExpanded, + onExpandedChange = onExpandedChange, + ) + } + if (isExpanded) { + item(key = "$keyPrefix:body") { + HeadersSectionBody( + headers = headers, + modifier = Modifier.padding(top = Spacings.sm, bottom = Spacings.xs), + ) + } + } +} + @Composable private fun HeaderSummary( webSocket: NetworkInspectorWebSocketUiModel, diff --git a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/json/JsonOutlineView.kt b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/json/JsonOutlineView.kt index 5c193ed..6bc769f 100644 --- a/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/json/JsonOutlineView.kt +++ b/snapo-desktop-compose/src/main/kotlin/com/openai/snapo/desktop/ui/json/JsonOutlineView.kt @@ -45,6 +45,17 @@ import com.openai.snapo.desktop.ui.theme.SnapOAccents import com.openai.snapo.desktop.ui.theme.SnapOMono import com.openai.snapo.desktop.ui.theme.Spacings +internal data class JsonOutlineRowsState( + val rows: List, + val expandedNodes: Set, + val expandedStrings: Set, + val onToggleExpand: (String) -> Unit, + val onToggleStringExpand: (String) -> Unit, + val onExpandAll: (JsonOutlineNode) -> Unit, + val onCollapseChildren: (JsonOutlineNode) -> Unit, + val rootId: String, +) + @OptIn(ExperimentalFoundationApi::class) @Composable fun JsonOutlineView( @@ -55,19 +66,12 @@ fun JsonOutlineView( expansionState: JsonOutlineExpansionState? = null, payloadKey: Int? = null, ) { - val localState = remember(root.id) { JsonOutlineExpansionState(initiallyExpanded) } - val state = expansionState ?: localState - val effectivePayloadKey = payloadKey ?: root.hashCode() - - LaunchedEffect(effectivePayloadKey, root.id) { - state.sync(payloadKey = effectivePayloadKey, rootId = root.id) - } - - val rows = remember(root, state.expandedNodes) { - buildList { - appendNodeRows(this, node = root, indent = 0, expandedNodes = state.expandedNodes) - } - } + val outlineState = rememberJsonOutlineRowsState( + root = root, + initiallyExpanded = initiallyExpanded, + expansionState = expansionState, + payloadKey = payloadKey, + ) SelectionContainer { Column( @@ -76,47 +80,18 @@ fun JsonOutlineView( .fillMaxWidth() .background(Color.Transparent), ) { - for (row in rows) { - when (row) { - is JsonOutlineRow.Node -> JsonNodeRow( - node = row.node, - indent = row.indent, - expandedNodes = state.expandedNodes, - expandedStrings = state.expandedStrings, - onToggleExpand = { id -> - val current = state.expandedNodes - state.expandedNodes = - if (current.contains(id)) current - id else current + id - }, - onToggleStringExpand = { id -> - val current = state.expandedStrings - state.expandedStrings = - if (current.contains(id)) current - id else current + id - }, - onExpandAll = { node -> - state.expandedNodes = - state.expandedNodes + node.collectExpandableIds(includeSelf = true) - }, - onCollapseChildren = { node -> - state.expandedNodes = - state.expandedNodes - node.collectExpandableIds(includeSelf = false) - state.expandedStrings = - state.expandedStrings - node.collectStringNodeIds(includeSelf = false) - }, - trailingContent = if (row.node.id == root.id) rootTrailingContent else null, - ) - - is JsonOutlineRow.Closing -> JsonClosingRow( - indent = row.indent, - symbol = row.symbol, - ) - } + for (row in outlineState.rows) { + JsonOutlineRowItem( + row = row, + outlineState = outlineState, + rootTrailingContent = rootTrailingContent, + ) } } } } -private sealed interface JsonOutlineRow { +internal sealed interface JsonOutlineRow { val key: String data class Node(val node: JsonOutlineNode, val indent: Int) : JsonOutlineRow { @@ -128,6 +103,82 @@ private sealed interface JsonOutlineRow { } } +@Composable +internal fun rememberJsonOutlineRowsState( + root: JsonOutlineNode, + initiallyExpanded: Boolean, + expansionState: JsonOutlineExpansionState?, + payloadKey: Int?, +): JsonOutlineRowsState { + val localState = remember(root.id) { JsonOutlineExpansionState(initiallyExpanded) } + val state = expansionState ?: localState + val effectivePayloadKey = payloadKey ?: root.hashCode() + + LaunchedEffect(effectivePayloadKey, root.id) { + state.sync(payloadKey = effectivePayloadKey, rootId = root.id) + } + + val rows = remember(root, state.expandedNodes) { + buildList { + appendNodeRows(this, node = root, indent = 0, expandedNodes = state.expandedNodes) + } + } + + return JsonOutlineRowsState( + rows = rows, + expandedNodes = state.expandedNodes, + expandedStrings = state.expandedStrings, + onToggleExpand = { id -> + val current = state.expandedNodes + state.expandedNodes = if (current.contains(id)) current - id else current + id + }, + onToggleStringExpand = { id -> + val current = state.expandedStrings + state.expandedStrings = if (current.contains(id)) current - id else current + id + }, + onExpandAll = { node -> + state.expandedNodes = + state.expandedNodes + node.collectExpandableIds(includeSelf = true) + }, + onCollapseChildren = { node -> + state.expandedNodes = + state.expandedNodes - node.collectExpandableIds(includeSelf = false) + state.expandedStrings = + state.expandedStrings - node.collectStringNodeIds(includeSelf = false) + }, + rootId = root.id, + ) +} + +@Composable +internal fun JsonOutlineRowItem( + row: JsonOutlineRow, + outlineState: JsonOutlineRowsState, + rootTrailingContent: (@Composable RowScope.(JsonOutlineNode) -> Unit)?, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + when (row) { + is JsonOutlineRow.Node -> JsonNodeRow( + node = row.node, + indent = row.indent, + expandedNodes = outlineState.expandedNodes, + expandedStrings = outlineState.expandedStrings, + onToggleExpand = outlineState.onToggleExpand, + onToggleStringExpand = outlineState.onToggleStringExpand, + onExpandAll = outlineState.onExpandAll, + onCollapseChildren = outlineState.onCollapseChildren, + trailingContent = if (row.node.id == outlineState.rootId) rootTrailingContent else null, + ) + + is JsonOutlineRow.Closing -> JsonClosingRow( + indent = row.indent, + symbol = row.symbol, + ) + } + } +} + private fun appendNodeRows( out: MutableList, node: JsonOutlineNode,