From 6c7c932c643a744afe73fe78c5d921f1e14deebd Mon Sep 17 00:00:00 2001 From: Sandesh Date: Tue, 9 Jun 2026 02:04:05 +0530 Subject: [PATCH 1/7] Add search in websocket detail message list --- gradle.properties | 2 +- .../wiretap/helper/util/TextHighlightUtil.kt | 16 +- .../wiretap/ui/common/MessageBubble.kt | 14 +- .../socket/detail/SocketDetailScreen.kt | 262 +++++++++++++++--- .../wiretap/ui/theme/WiretapColors.kt | 1 + 5 files changed, 253 insertions(+), 42 deletions(-) diff --git a/gradle.properties b/gradle.properties index d099e2ce..6bebe2ed 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,7 +18,7 @@ android.r8.strictFullModeForKeepRules=false #Publishing wiretap.group=dev.skymansandy -wiretap.version=1.0.0-RC15 +wiretap.version=1.0.0-RC16 # KSP Fix ksp.incremental=false diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/TextHighlightUtil.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/TextHighlightUtil.kt index f20b6ce1..fd209a48 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/TextHighlightUtil.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/TextHighlightUtil.kt @@ -11,7 +11,11 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import dev.skymansandy.wiretap.ui.theme.WiretapColors -internal fun highlightText(text: String, query: String): AnnotatedString { +internal fun highlightText( + text: String, + query: String, + activeRange: IntRange? = null, +): AnnotatedString { if (query.isBlank()) return AnnotatedString(text) return buildAnnotatedString { @@ -21,7 +25,15 @@ internal fun highlightText(text: String, query: String): AnnotatedString { var match = lowerText.indexOf(lowerQuery, cursor) while (match >= 0) { append(text.substring(cursor, match)) - withStyle(SpanStyle(background = WiretapColors.SearchHighlightBackground, color = Color.Black)) { + val isActive = activeRange != null && + match == activeRange.first && + match + query.length - 1 == activeRange.last + val background = if (isActive) { + WiretapColors.SearchHighlightActiveBackground + } else { + WiretapColors.SearchHighlightBackground + } + withStyle(SpanStyle(background = background, color = Color.Black)) { append(text.substring(match, match + query.length)) } cursor = match + query.length diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/MessageBubble.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/MessageBubble.kt index d6e8aa61..34faf610 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/MessageBubble.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/MessageBubble.kt @@ -26,11 +26,14 @@ import dev.skymansandy.wiretap.domain.model.SocketMessage import dev.skymansandy.wiretap.domain.model.SocketMessageType import dev.skymansandy.wiretap.helper.util.formatBytes import dev.skymansandy.wiretap.helper.util.formatTime +import dev.skymansandy.wiretap.helper.util.highlightText @Composable internal fun MessageBubble( modifier: Modifier = Modifier, message: SocketMessage, + searchQuery: String = "", + activeMatchRange: IntRange? = null, ) { when (message.contentType) { SocketContentType.Ping, @@ -38,7 +41,12 @@ internal fun MessageBubble( SocketContentType.Close, -> ControlFrameLabel(modifier = modifier, message = message) - else -> DataFrameBubble(modifier = modifier, message = message) + else -> DataFrameBubble( + modifier = modifier, + message = message, + searchQuery = searchQuery, + activeMatchRange = activeMatchRange, + ) } } @@ -70,6 +78,8 @@ private fun ControlFrameLabel( private fun DataFrameBubble( modifier: Modifier = Modifier, message: SocketMessage, + searchQuery: String = "", + activeMatchRange: IntRange? = null, ) { val isSent = message.direction == SocketMessageType.Sent val alignment = if (isSent) Alignment.CenterEnd else Alignment.CenterStart @@ -95,7 +105,7 @@ private fun DataFrameBubble( .padding(10.dp), ) { Text( - text = message.content, + text = highlightText(message.content, searchQuery, activeMatchRange), style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = textColor, diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailScreen.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailScreen.kt index a435123c..252cd5da 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailScreen.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailScreen.kt @@ -15,10 +15,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -31,10 +35,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -52,11 +59,14 @@ import dev.skymansandy.wiretap.navigation.compose.LocalWiretapNavigator import dev.skymansandy.wiretap.ui.common.InfoLabel import dev.skymansandy.wiretap.ui.common.MessageBubble import dev.skymansandy.wiretap.ui.common.ScrollToBottomChip +import dev.skymansandy.wiretap.ui.common.SearchField import dev.skymansandy.wiretap.ui.screens.socket.components.StatusChip import dev.skymansandy.wiretap.ui.theme.WiretapColors +import kotlinx.coroutines.delay import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf +@Suppress("CyclomaticComplexMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun SocketDetailScreenView( @@ -76,6 +86,37 @@ internal fun SocketDetailScreenView( val messages by viewModel.messages.collectAsStateWithLifecycle() val listState = rememberLazyListState() + var searchQuery by remember { mutableStateOf("") } + var isSearchActive by remember { mutableStateOf(false) } + val searchFocusRequester = remember { FocusRequester() } + + LaunchedEffect(isSearchActive) { + if (isSearchActive) { + searchFocusRequester.requestFocus() + } + } + + val debouncedQuery by produceState(initialValue = "", key1 = searchQuery) { + if (searchQuery.isEmpty()) { + value = "" + } else { + delay(450) + value = searchQuery + } + } + + val matches = remember(messages, debouncedQuery) { + computeSocketMatches(messages, debouncedQuery) + } + + var currentMatchIndex by remember { mutableStateOf(0) } + LaunchedEffect(matches) { + currentMatchIndex = 0 + } + + val headerOffset = 1 + (if (entry.historyCleared) 1 else 0) + val autoScrollDisabled = isSearchActive && debouncedQuery.isNotEmpty() + // Scroll to bottom on initial load LaunchedEffect(Unit) { if (messages.isNotEmpty()) { @@ -86,7 +127,7 @@ internal fun SocketDetailScreenView( // Auto-scroll to bottom when new messages arrive and already near bottom var prevMessageCount by remember { mutableStateOf(messages.size) } LaunchedEffect(messages.size) { - if (messages.size > prevMessageCount) { + if (!autoScrollDisabled && messages.size > prevMessageCount) { val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 val totalItems = listState.layoutInfo.totalItemsCount if (totalItems - lastVisible <= 3) { @@ -96,6 +137,12 @@ internal fun SocketDetailScreenView( prevMessageCount = messages.size } + // Scroll to the active search match + LaunchedEffect(currentMatchIndex, matches) { + val match = matches.getOrNull(currentMatchIndex) ?: return@LaunchedEffect + listState.animateScrollToItem(match.messageIndex + headerOffset) + } + val urlDisplay = remember(entry.url) { formatUrlDisplay(entry.url) } @@ -105,17 +152,34 @@ internal fun SocketDetailScreenView( topBar = { TopAppBar( title = { - Column { - Text( - text = "WS $urlDisplay", - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + if (isSearchActive) { + SearchField( + modifier = Modifier.focusRequester(searchFocusRequester), + query = searchQuery, + onQueryChange = { searchQuery = it }, ) + } else { + Column { + Text( + text = "WS $urlDisplay", + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } }, navigationIcon = { - IconButton(onClick = { navigator.pop() }) { + IconButton( + onClick = { + if (isSearchActive) { + isSearchActive = false + searchQuery = "" + } else { + navigator.pop() + } + }, + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", @@ -123,49 +187,173 @@ internal fun SocketDetailScreenView( } }, actions = { - StatusChip(status = entry.status) + if (isSearchActive) { + IconButton( + onClick = { + isSearchActive = false + searchQuery = "" + }, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close search", + ) + } + } else { + IconButton(onClick = { isSearchActive = true }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + ) + } + StatusChip(status = entry.status) + } }, ) }, ) { padding -> - ScrollToBottomChip( - listState = listState, - modifier = Modifier.fillMaxSize().padding(padding), - ) { - LazyColumn( - state = listState, + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + if (isSearchActive && debouncedQuery.isNotEmpty()) { + SearchNavigatorBar( + matchCount = matches.size, + currentIndex = currentMatchIndex, + onPrevious = { + if (matches.isNotEmpty()) { + currentMatchIndex = (currentMatchIndex - 1 + matches.size) % matches.size + } + }, + onNext = { + if (matches.isNotEmpty()) { + currentMatchIndex = (currentMatchIndex + 1) % matches.size + } + }, + ) + HorizontalDivider() + } + + ScrollToBottomChip( + listState = listState, modifier = Modifier.fillMaxSize(), ) { - // Connection info header - item(key = "header") { - ConnectionInfoHeader( - modifier = Modifier.fillMaxWidth(), - entry = entry, - ) - } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + ) { + // Connection info header + item(key = "header") { + ConnectionInfoHeader( + modifier = Modifier.fillMaxWidth(), + entry = entry, + ) + } - // History cleared banner - if (entry.historyCleared) { - item(key = "history_cleared") { - HistoryClearedBanner() + // History cleared banner + if (entry.historyCleared) { + item(key = "history_cleared") { + HistoryClearedBanner() + } } - } - // Messages - items(messages, key = { it.id }) { message -> - MessageBubble( - modifier = Modifier.fillMaxWidth(), - message = message, - ) - } + // Messages + itemsIndexed(messages, key = { _, m -> m.id }) { index, message -> + val activeMatch = matches.getOrNull(currentMatchIndex) + val activeRange = if (activeMatch?.messageIndex == index) { + activeMatch.start..activeMatch.endInclusive + } else { + null + } + MessageBubble( + modifier = Modifier.fillMaxWidth(), + message = message, + searchQuery = debouncedQuery, + activeMatchRange = activeRange, + ) + } - // Bottom spacer - item { Spacer(Modifier.height(16.dp)) } + // Bottom spacer + item { Spacer(Modifier.height(16.dp)) } + } } } } } +private data class SocketMatchPosition( + val messageIndex: Int, + val start: Int, + val endInclusive: Int, +) + +private fun computeSocketMatches( + messages: List, + query: String, +): List { + if (query.isBlank()) return emptyList() + val lowerQuery = query.lowercase() + val results = mutableListOf() + messages.forEachIndexed { index, message -> + if (!message.contentType.isSearchable()) return@forEachIndexed + val lowerContent = message.content.lowercase() + var cursor = 0 + while (true) { + val hit = lowerContent.indexOf(lowerQuery, cursor) + if (hit < 0) break + results += SocketMatchPosition( + messageIndex = index, + start = hit, + endInclusive = hit + query.length - 1, + ) + cursor = hit + query.length + } + } + return results +} + +private fun SocketContentType.isSearchable(): Boolean = when (this) { + SocketContentType.Text -> true + SocketContentType.Binary, + SocketContentType.Ping, + SocketContentType.Pong, + SocketContentType.Close, + -> false +} + +@Composable +private fun SearchNavigatorBar( + matchCount: Int, + currentIndex: Int, + onPrevious: () -> Unit, + onNext: () -> Unit, +) { + val display = if (matchCount == 0) 0 else currentIndex + 1 + val enabled = matchCount > 0 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + Text( + text = "$display / $matchCount", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + IconButton(onClick = onPrevious, enabled = enabled) { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = "Previous match", + ) + } + IconButton(onClick = onNext, enabled = enabled) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Next match", + ) + } + } +} + @Composable private fun ConnectionInfoHeader( modifier: Modifier = Modifier, diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/theme/WiretapColors.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/theme/WiretapColors.kt index e1a914e8..92115f01 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/theme/WiretapColors.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/theme/WiretapColors.kt @@ -21,6 +21,7 @@ internal object WiretapColors { val RuleMockAndThrottle = Color(0xFFFF8A65) val SearchHighlightBackground = Color(0xFFFFEB3B) + val SearchHighlightActiveBackground = Color(0xFFFF9800) val HistoryClearedBackground = Color(0xFF3E2723) val HistoryClearedText = Color(0xFFFFAB40) From e9b74119c4ba9f63635977801e6c941110603c18 Mon Sep 17 00:00:00 2001 From: Sandesh Date: Tue, 9 Jun 2026 02:09:57 +0530 Subject: [PATCH 2/7] Add share dropdown to websocket detail and fix search caret - New common shareLogText / shareLogAsFile(content, fileName) actuals in LogShareUtil; HTTP share migrates to them. - New SocketLogUtil.buildSocketShareText emits a full transcript (connection metadata, request headers, per-message timestamped lines). - Share IconButton + DropdownMenu on SocketDetailScreen offers "Share as text" and "Share as file"; snackbar host added for clipboard-style feedback (JVM). - SearchField cursorBrush set to white so the caret is visible against the dark TopAppBar background. --- ...til.android.kt => LogShareUtil.android.kt} | 9 +- .../wiretap/helper/util/HttpLogUtil.kt | 5 - .../wiretap/helper/util/LogShareUtil.kt | 11 + .../wiretap/helper/util/SocketLogUtil.kt | 64 +++++ .../wiretap/ui/common/SearchField.kt | 3 + .../http/detail/HttpLogDetailScreen.kt | 12 +- .../socket/detail/SocketDetailScreen.kt | 266 +++++++++++------- ...HttpLogUtil.ios.kt => LogShareUtil.ios.kt} | 7 +- ...HttpLogUtil.jvm.kt => LogShareUtil.jvm.kt} | 7 +- 9 files changed, 260 insertions(+), 124 deletions(-) rename wiretap-core/src/androidMain/kotlin/dev/skymansandy/wiretap/helper/util/{HttpLogUtil.android.kt => LogShareUtil.android.kt} (86%) create mode 100644 wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/LogShareUtil.kt create mode 100644 wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/SocketLogUtil.kt rename wiretap-core/src/iosMain/kotlin/dev/skymansandy/wiretap/helper/util/{HttpLogUtil.ios.kt => LogShareUtil.ios.kt} (92%) rename wiretap-core/src/jvmMain/kotlin/dev/skymansandy/wiretap/helper/util/{HttpLogUtil.jvm.kt => LogShareUtil.jvm.kt} (82%) diff --git a/wiretap-core/src/androidMain/kotlin/dev/skymansandy/wiretap/helper/util/HttpLogUtil.android.kt b/wiretap-core/src/androidMain/kotlin/dev/skymansandy/wiretap/helper/util/LogShareUtil.android.kt similarity index 86% rename from wiretap-core/src/androidMain/kotlin/dev/skymansandy/wiretap/helper/util/HttpLogUtil.android.kt rename to wiretap-core/src/androidMain/kotlin/dev/skymansandy/wiretap/helper/util/LogShareUtil.android.kt index 39018630..f649fba9 100644 --- a/wiretap-core/src/androidMain/kotlin/dev/skymansandy/wiretap/helper/util/HttpLogUtil.android.kt +++ b/wiretap-core/src/androidMain/kotlin/dev/skymansandy/wiretap/helper/util/LogShareUtil.android.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File -internal actual fun shareHttpLogs(subject: String, text: String): String? { +internal actual fun shareLogText(subject: String, text: String): String? { val context = WiretapContextProvider.context val intent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" @@ -29,16 +29,16 @@ internal actual fun shareHttpLogs(subject: String, text: String): String? { return null } -internal actual fun shareHttpLogAsFile(content: String) { +internal actual fun shareLogAsFile(content: String, fileName: String): String? { CoroutineScope(Dispatchers.IO).launch { try { val context = WiretapContextProvider.context val shareDir = File(context.cacheDir, SHARE_DIR_NAME).apply { mkdirs() } - val file = File(shareDir, HTTP_LOG_FILE_NAME) + val file = File(shareDir, fileName) file.writeText(content, Charsets.UTF_8) val authority = "${context.packageName}.wiretap.fileprovider" - val uri = "content://$authority/$HTTP_LOG_FILE_NAME".toUri() + val uri = "content://$authority/$fileName".toUri() withContext(Dispatchers.Main) { val intent = Intent(Intent.ACTION_SEND).apply { @@ -57,4 +57,5 @@ internal actual fun shareHttpLogAsFile(content: String) { // Silently fail -- never crash the host app } } + return null } diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/HttpLogUtil.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/HttpLogUtil.kt index 9b9a664a..a5a8f2f7 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/HttpLogUtil.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/HttpLogUtil.kt @@ -6,13 +6,8 @@ package dev.skymansandy.wiretap.helper.util import dev.skymansandy.wiretap.domain.model.HttpLog -internal const val SHARE_DIR_NAME = "wiretap_share" internal const val HTTP_LOG_FILE_NAME = "wiretap_http_log.txt" -internal expect fun shareHttpLogs(subject: String, text: String): String? - -internal expect fun shareHttpLogAsFile(content: String) - internal fun buildShareText(entry: HttpLog): String = buildString { appendLine("${entry.method} ${entry.responseCode}") appendLine(entry.url) diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/LogShareUtil.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/LogShareUtil.kt new file mode 100644 index 00000000..d3d96b7e --- /dev/null +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/LogShareUtil.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2026 skymansandy. All rights reserved. + */ + +package dev.skymansandy.wiretap.helper.util + +internal const val SHARE_DIR_NAME = "wiretap_share" + +internal expect fun shareLogText(subject: String, text: String): String? + +internal expect fun shareLogAsFile(content: String, fileName: String): String? diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/SocketLogUtil.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/SocketLogUtil.kt new file mode 100644 index 00000000..0a2afcbe --- /dev/null +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/SocketLogUtil.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 skymansandy. All rights reserved. + */ + +package dev.skymansandy.wiretap.helper.util + +import dev.skymansandy.wiretap.domain.model.SocketConnection +import dev.skymansandy.wiretap.domain.model.SocketContentType +import dev.skymansandy.wiretap.domain.model.SocketMessage +import dev.skymansandy.wiretap.domain.model.SocketMessageType + +internal const val SOCKET_LOG_FILE_NAME = "wiretap_socket_log.txt" + +internal fun buildSocketShareText( + connection: SocketConnection, + messages: List, +): String = buildString { + appendLine("WS ${connection.url}") + appendLine("Status: ${connection.status.name}") + appendLine("Opened: ${formatTime(connection.timestamp)}") + connection.closedAt?.let { appendLine("Closed: ${formatTime(it)}") } + connection.closeCode?.let { appendLine("Close Code: $it") } + connection.closeReason?.let { appendLine("Close Reason: $it") } + connection.failureMessage?.let { appendLine("Error: $it") } + connection.protocol?.let { appendLine("Protocol: $it") } + connection.remoteAddress?.let { appendLine("Remote Address: $it") } + appendLine() + appendLine("--- Request Headers ---") + if (connection.requestHeaders.isEmpty()) { + appendLine("(none)") + } else { + connection.requestHeaders.forEach { (k, v) -> appendLine("$k: $v") } + } + appendLine() + appendLine("--- Messages (${messages.size}) ---") + if (messages.isEmpty()) { + append("(none)") + } else { + messages.forEachIndexed { index, message -> + append(formatMessageLine(message)) + if (index < messages.lastIndex) appendLine() + } + } +} + +private fun formatMessageLine(message: SocketMessage): String { + val time = formatTime(message.timestamp) + val bytes = formatBytes(message.byteCount) + return when (message.contentType) { + SocketContentType.Text, SocketContentType.Binary -> { + val arrow = if (message.direction == SocketMessageType.Sent) ">>" else "<<" + val tag = if (message.direction == SocketMessageType.Sent) "SENT" else "RECV" + "[$time] $arrow $tag [${message.contentType.name}, $bytes] ${message.content}" + } + + SocketContentType.Ping, + SocketContentType.Pong, + SocketContentType.Close, + -> { + val suffix = message.content.takeIf { it.isNotBlank() }?.let { " — $it" } ?: "" + "[$time] -- ${message.contentType.name.uppercase()}$suffix" + } + } +} diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/SearchField.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/SearchField.kt index e81a3a0d..56b09782 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/SearchField.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/SearchField.kt @@ -22,6 +22,8 @@ import androidx.compose.material3.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.graphics.SolidColor import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview @@ -40,6 +42,7 @@ internal fun SearchField( value = query, onValueChange = onQueryChange, textStyle = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current), + cursorBrush = SolidColor(Color.White), singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/http/detail/HttpLogDetailScreen.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/http/detail/HttpLogDetailScreen.kt index 175a9b8b..82505a09 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/http/detail/HttpLogDetailScreen.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/http/detail/HttpLogDetailScreen.kt @@ -46,10 +46,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.skymansandy.wiretap.domain.model.HttpLog import dev.skymansandy.wiretap.domain.model.ResponseSource +import dev.skymansandy.wiretap.helper.util.HTTP_LOG_FILE_NAME import dev.skymansandy.wiretap.helper.util.buildCurlCommand import dev.skymansandy.wiretap.helper.util.buildShareText -import dev.skymansandy.wiretap.helper.util.shareHttpLogAsFile -import dev.skymansandy.wiretap.helper.util.shareHttpLogs +import dev.skymansandy.wiretap.helper.util.shareLogAsFile +import dev.skymansandy.wiretap.helper.util.shareLogText import dev.skymansandy.wiretap.navigation.api.WiretapScreen import dev.skymansandy.wiretap.navigation.compose.LocalWiretapNavigator import dev.skymansandy.wiretap.ui.common.LocalSnackbarHostState @@ -220,7 +221,7 @@ private fun HttpLogDetailScreenContent( text = { Text("Share as text") }, onClick = { showShareMenu = false - val message = shareHttpLogs( + val message = shareLogText( subject = "${entry.method} ${entry.responseCode} - ${entry.url}", text = buildShareText(entry), ) @@ -232,7 +233,7 @@ private fun HttpLogDetailScreenContent( text = { Text("Share as cURL") }, onClick = { showShareMenu = false - val message = shareHttpLogs( + val message = shareLogText( subject = "cURL - ${entry.method} ${entry.url}", text = buildCurlCommand(entry), ) @@ -244,8 +245,9 @@ private fun HttpLogDetailScreenContent( text = { Text("Share as file") }, onClick = { showShareMenu = false - shareHttpLogAsFile( + shareLogAsFile( content = buildShareText(entry), + fileName = HTTP_LOG_FILE_NAME, ) }, ) diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailScreen.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailScreen.kt index 252cd5da..13e830b2 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailScreen.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailScreen.kt @@ -23,20 +23,27 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -53,16 +60,22 @@ import dev.skymansandy.wiretap.domain.model.SocketContentType import dev.skymansandy.wiretap.domain.model.SocketMessage import dev.skymansandy.wiretap.domain.model.SocketMessageType import dev.skymansandy.wiretap.domain.model.SocketStatus +import dev.skymansandy.wiretap.helper.util.SOCKET_LOG_FILE_NAME +import dev.skymansandy.wiretap.helper.util.buildSocketShareText import dev.skymansandy.wiretap.helper.util.formatTime import dev.skymansandy.wiretap.helper.util.formatUrlDisplay +import dev.skymansandy.wiretap.helper.util.shareLogAsFile +import dev.skymansandy.wiretap.helper.util.shareLogText import dev.skymansandy.wiretap.navigation.compose.LocalWiretapNavigator import dev.skymansandy.wiretap.ui.common.InfoLabel +import dev.skymansandy.wiretap.ui.common.LocalSnackbarHostState import dev.skymansandy.wiretap.ui.common.MessageBubble import dev.skymansandy.wiretap.ui.common.ScrollToBottomChip import dev.skymansandy.wiretap.ui.common.SearchField import dev.skymansandy.wiretap.ui.screens.socket.components.StatusChip import dev.skymansandy.wiretap.ui.theme.WiretapColors import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -90,6 +103,10 @@ internal fun SocketDetailScreenView( var isSearchActive by remember { mutableStateOf(false) } val searchFocusRequester = remember { FocusRequester() } + var showShareMenu by remember { mutableStateOf(false) } + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + LaunchedEffect(isSearchActive) { if (isSearchActive) { searchFocusRequester.requestFocus() @@ -147,131 +164,172 @@ internal fun SocketDetailScreenView( formatUrlDisplay(entry.url) } - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = { - if (isSearchActive) { - SearchField( - modifier = Modifier.focusRequester(searchFocusRequester), - query = searchQuery, - onQueryChange = { searchQuery = it }, - ) - } else { - Column { - Text( - text = "WS $urlDisplay", - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + CompositionLocalProvider(LocalSnackbarHostState provides snackbarHostState) { + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + if (isSearchActive) { + SearchField( + modifier = Modifier.focusRequester(searchFocusRequester), + query = searchQuery, + onQueryChange = { searchQuery = it }, ) - } - } - }, - navigationIcon = { - IconButton( - onClick = { - if (isSearchActive) { - isSearchActive = false - searchQuery = "" - } else { - navigator.pop() + } else { + Column { + Text( + text = "WS $urlDisplay", + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } - }, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - ) - } - }, - actions = { - if (isSearchActive) { + } + }, + navigationIcon = { IconButton( onClick = { - isSearchActive = false - searchQuery = "" + if (isSearchActive) { + isSearchActive = false + searchQuery = "" + } else { + navigator.pop() + } }, ) { Icon( - imageVector = Icons.Default.Close, - contentDescription = "Close search", + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", ) } - } else { - IconButton(onClick = { isSearchActive = true }) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search", - ) - } - StatusChip(status = entry.status) - } - }, - ) - }, - ) { padding -> - Column(modifier = Modifier.fillMaxSize().padding(padding)) { - if (isSearchActive && debouncedQuery.isNotEmpty()) { - SearchNavigatorBar( - matchCount = matches.size, - currentIndex = currentMatchIndex, - onPrevious = { - if (matches.isNotEmpty()) { - currentMatchIndex = (currentMatchIndex - 1 + matches.size) % matches.size - } }, - onNext = { - if (matches.isNotEmpty()) { - currentMatchIndex = (currentMatchIndex + 1) % matches.size + actions = { + if (isSearchActive) { + IconButton( + onClick = { + isSearchActive = false + searchQuery = "" + }, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close search", + ) + } + } else { + IconButton(onClick = { isSearchActive = true }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + ) + } + Box { + IconButton(onClick = { showShareMenu = true }) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share", + ) + } + DropdownMenu( + expanded = showShareMenu, + onDismissRequest = { showShareMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Share as text") }, + onClick = { + showShareMenu = false + val message = shareLogText( + subject = "WS ${entry.url}", + text = buildSocketShareText(entry, messages), + ) + message?.let { + coroutineScope.launch { + snackbarHostState.showSnackbar(it) + } + } + }, + ) + DropdownMenuItem( + text = { Text("Share as file") }, + onClick = { + showShareMenu = false + shareLogAsFile( + content = buildSocketShareText(entry, messages), + fileName = SOCKET_LOG_FILE_NAME, + ) + }, + ) + } + } + StatusChip(status = entry.status) } }, ) - HorizontalDivider() - } + }, + ) { padding -> + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + if (isSearchActive && debouncedQuery.isNotEmpty()) { + SearchNavigatorBar( + matchCount = matches.size, + currentIndex = currentMatchIndex, + onPrevious = { + if (matches.isNotEmpty()) { + currentMatchIndex = (currentMatchIndex - 1 + matches.size) % matches.size + } + }, + onNext = { + if (matches.isNotEmpty()) { + currentMatchIndex = (currentMatchIndex + 1) % matches.size + } + }, + ) + HorizontalDivider() + } - ScrollToBottomChip( - listState = listState, - modifier = Modifier.fillMaxSize(), - ) { - LazyColumn( - state = listState, + ScrollToBottomChip( + listState = listState, modifier = Modifier.fillMaxSize(), ) { - // Connection info header - item(key = "header") { - ConnectionInfoHeader( - modifier = Modifier.fillMaxWidth(), - entry = entry, - ) - } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + ) { + // Connection info header + item(key = "header") { + ConnectionInfoHeader( + modifier = Modifier.fillMaxWidth(), + entry = entry, + ) + } - // History cleared banner - if (entry.historyCleared) { - item(key = "history_cleared") { - HistoryClearedBanner() + // History cleared banner + if (entry.historyCleared) { + item(key = "history_cleared") { + HistoryClearedBanner() + } } - } - // Messages - itemsIndexed(messages, key = { _, m -> m.id }) { index, message -> - val activeMatch = matches.getOrNull(currentMatchIndex) - val activeRange = if (activeMatch?.messageIndex == index) { - activeMatch.start..activeMatch.endInclusive - } else { - null + // Messages + itemsIndexed(messages, key = { _, m -> m.id }) { index, message -> + val activeMatch = matches.getOrNull(currentMatchIndex) + val activeRange = if (activeMatch?.messageIndex == index) { + activeMatch.start..activeMatch.endInclusive + } else { + null + } + MessageBubble( + modifier = Modifier.fillMaxWidth(), + message = message, + searchQuery = debouncedQuery, + activeMatchRange = activeRange, + ) } - MessageBubble( - modifier = Modifier.fillMaxWidth(), - message = message, - searchQuery = debouncedQuery, - activeMatchRange = activeRange, - ) - } - // Bottom spacer - item { Spacer(Modifier.height(16.dp)) } + // Bottom spacer + item { Spacer(Modifier.height(16.dp)) } + } } } } diff --git a/wiretap-core/src/iosMain/kotlin/dev/skymansandy/wiretap/helper/util/HttpLogUtil.ios.kt b/wiretap-core/src/iosMain/kotlin/dev/skymansandy/wiretap/helper/util/LogShareUtil.ios.kt similarity index 92% rename from wiretap-core/src/iosMain/kotlin/dev/skymansandy/wiretap/helper/util/HttpLogUtil.ios.kt rename to wiretap-core/src/iosMain/kotlin/dev/skymansandy/wiretap/helper/util/LogShareUtil.ios.kt index b05ccaa3..0a1fd858 100644 --- a/wiretap-core/src/iosMain/kotlin/dev/skymansandy/wiretap/helper/util/HttpLogUtil.ios.kt +++ b/wiretap-core/src/iosMain/kotlin/dev/skymansandy/wiretap/helper/util/LogShareUtil.ios.kt @@ -50,13 +50,13 @@ private fun presentShareSheet(items: List) { } } -internal actual fun shareHttpLogs(subject: String, text: String): String? { +internal actual fun shareLogText(subject: String, text: String): String? { presentShareSheet(listOf(text)) return null } @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) -internal actual fun shareHttpLogAsFile(content: String) { +internal actual fun shareLogAsFile(content: String, fileName: String): String? { dispatch_async(dispatch_get_main_queue()) { try { val shareDir = NSTemporaryDirectory() + "$SHARE_DIR_NAME/" @@ -67,7 +67,7 @@ internal actual fun shareHttpLogAsFile(content: String) { error = null, ) - val filePath = shareDir + HTTP_LOG_FILE_NAME + val filePath = shareDir + fileName val nsContent = NSString.create(string = content) val written = nsContent.writeToFile( filePath, @@ -83,4 +83,5 @@ internal actual fun shareHttpLogAsFile(content: String) { // Silently fail -- never crash the host app } } + return null } diff --git a/wiretap-core/src/jvmMain/kotlin/dev/skymansandy/wiretap/helper/util/HttpLogUtil.jvm.kt b/wiretap-core/src/jvmMain/kotlin/dev/skymansandy/wiretap/helper/util/LogShareUtil.jvm.kt similarity index 82% rename from wiretap-core/src/jvmMain/kotlin/dev/skymansandy/wiretap/helper/util/HttpLogUtil.jvm.kt rename to wiretap-core/src/jvmMain/kotlin/dev/skymansandy/wiretap/helper/util/LogShareUtil.jvm.kt index 8a8a3960..b029e647 100644 --- a/wiretap-core/src/jvmMain/kotlin/dev/skymansandy/wiretap/helper/util/HttpLogUtil.jvm.kt +++ b/wiretap-core/src/jvmMain/kotlin/dev/skymansandy/wiretap/helper/util/LogShareUtil.jvm.kt @@ -12,7 +12,7 @@ import java.awt.Toolkit import java.awt.datatransfer.StringSelection import java.io.File -internal actual fun shareHttpLogs(subject: String, text: String): String? { +internal actual fun shareLogText(subject: String, text: String): String? { return try { val clipboard = Toolkit.getDefaultToolkit().systemClipboard clipboard.setContents(StringSelection(text), null) @@ -22,11 +22,11 @@ internal actual fun shareHttpLogs(subject: String, text: String): String? { } } -internal actual fun shareHttpLogAsFile(content: String) { +internal actual fun shareLogAsFile(content: String, fileName: String): String? { CoroutineScope(Dispatchers.IO).launch { try { val shareDir = File(System.getProperty("java.io.tmpdir"), SHARE_DIR_NAME).apply { mkdirs() } - val file = File(shareDir, HTTP_LOG_FILE_NAME) + val file = File(shareDir, fileName) file.writeText(content, Charsets.UTF_8) if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) { @@ -36,4 +36,5 @@ internal actual fun shareHttpLogAsFile(content: String) { // Silently fail -- never crash the host app } } + return null } From 8edf6af3c2db263c0562126b09ad0b37f554e2a9 Mon Sep 17 00:00:00 2001 From: Sandesh Date: Tue, 9 Jun 2026 02:12:56 +0530 Subject: [PATCH 3/7] Add search in SSE detail event list Mirrors the WS detail search: SearchField-swapped TopAppBar with debounced query, a navigator row showing match index/count with up/down to jump between matches, and highlighted occurrences in each SseEventBubble's data text. Auto-scroll-to-bottom is suppressed while searching so it doesn't fight the navigator. --- .../screens/sse/components/SseEventBubble.kt | 5 +- .../ui/screens/sse/detail/SseDetailScreen.kt | 252 +++++++++++++++--- 2 files changed, 219 insertions(+), 38 deletions(-) diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/components/SseEventBubble.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/components/SseEventBubble.kt index 543d8cf4..6260becd 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/components/SseEventBubble.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/components/SseEventBubble.kt @@ -25,11 +25,14 @@ import androidx.compose.ui.unit.dp import dev.skymansandy.wiretap.domain.model.SseEvent import dev.skymansandy.wiretap.helper.util.formatBytes import dev.skymansandy.wiretap.helper.util.formatTime +import dev.skymansandy.wiretap.helper.util.highlightText @Composable internal fun SseEventBubble( modifier: Modifier = Modifier, event: SseEvent, + searchQuery: String = "", + activeMatchRange: IntRange? = null, ) { val bgColor = MaterialTheme.colorScheme.surfaceVariant val textColor = MaterialTheme.colorScheme.onSurfaceVariant @@ -55,7 +58,7 @@ internal fun SseEventBubble( } Text( - text = event.data, + text = highlightText(event.data, searchQuery, activeMatchRange), style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = textColor, diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailScreen.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailScreen.kt index c73f92f9..83360411 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailScreen.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailScreen.kt @@ -15,10 +15,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -31,24 +35,30 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.skymansandy.wiretap.domain.model.SseConnection +import dev.skymansandy.wiretap.domain.model.SseEvent import dev.skymansandy.wiretap.helper.util.formatTime import dev.skymansandy.wiretap.helper.util.formatUrlDisplay import dev.skymansandy.wiretap.navigation.compose.LocalWiretapNavigator import dev.skymansandy.wiretap.ui.common.InfoLabel import dev.skymansandy.wiretap.ui.common.ScrollToBottomChip +import dev.skymansandy.wiretap.ui.common.SearchField import dev.skymansandy.wiretap.ui.screens.sse.components.SseEventBubble import dev.skymansandy.wiretap.ui.screens.sse.components.SseStatusChip import dev.skymansandy.wiretap.ui.theme.WiretapColors +import kotlinx.coroutines.delay import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -71,6 +81,37 @@ internal fun SseDetailScreenView( val events by viewModel.events.collectAsStateWithLifecycle() val listState = rememberLazyListState() + var searchQuery by remember { mutableStateOf("") } + var isSearchActive by remember { mutableStateOf(false) } + val searchFocusRequester = remember { FocusRequester() } + + LaunchedEffect(isSearchActive) { + if (isSearchActive) { + searchFocusRequester.requestFocus() + } + } + + val debouncedQuery by produceState(initialValue = "", key1 = searchQuery) { + if (searchQuery.isEmpty()) { + value = "" + } else { + delay(450) + value = searchQuery + } + } + + val matches = remember(events, debouncedQuery) { + computeSseMatches(events, debouncedQuery) + } + + var currentMatchIndex by remember { mutableStateOf(0) } + LaunchedEffect(matches) { + currentMatchIndex = 0 + } + + val headerOffset = 1 + (if (entry.historyCleared) 1 else 0) + val autoScrollDisabled = isSearchActive && debouncedQuery.isNotEmpty() + // Scroll to bottom on initial load LaunchedEffect(Unit) { if (events.isNotEmpty()) { @@ -81,7 +122,7 @@ internal fun SseDetailScreenView( // Auto-scroll to bottom when new events arrive and already near bottom var prevEventCount by remember { mutableStateOf(events.size) } LaunchedEffect(events.size) { - if (events.size > prevEventCount) { + if (!autoScrollDisabled && events.size > prevEventCount) { val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 val totalItems = listState.layoutInfo.totalItemsCount if (totalItems - lastVisible <= 3) { @@ -91,6 +132,12 @@ internal fun SseDetailScreenView( prevEventCount = events.size } + // Scroll to the active search match + LaunchedEffect(currentMatchIndex, matches) { + val match = matches.getOrNull(currentMatchIndex) ?: return@LaunchedEffect + listState.animateScrollToItem(match.eventIndex + headerOffset) + } + val urlDisplay = remember(entry.url) { formatUrlDisplay(entry.url) } @@ -100,17 +147,34 @@ internal fun SseDetailScreenView( topBar = { TopAppBar( title = { - Column { - Text( - text = "SSE $urlDisplay", - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + if (isSearchActive) { + SearchField( + modifier = Modifier.focusRequester(searchFocusRequester), + query = searchQuery, + onQueryChange = { searchQuery = it }, ) + } else { + Column { + Text( + text = "SSE $urlDisplay", + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } }, navigationIcon = { - IconButton(onClick = { navigator.pop() }) { + IconButton( + onClick = { + if (isSearchActive) { + isSearchActive = false + searchQuery = "" + } else { + navigator.pop() + } + }, + ) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", @@ -118,49 +182,163 @@ internal fun SseDetailScreenView( } }, actions = { - SseStatusChip(status = entry.status) + if (isSearchActive) { + IconButton( + onClick = { + isSearchActive = false + searchQuery = "" + }, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close search", + ) + } + } else { + IconButton(onClick = { isSearchActive = true }) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + ) + } + SseStatusChip(status = entry.status) + } }, ) }, ) { padding -> - ScrollToBottomChip( - listState = listState, - modifier = Modifier.fillMaxSize().padding(padding), - ) { - LazyColumn( - state = listState, + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + if (isSearchActive && debouncedQuery.isNotEmpty()) { + SseSearchNavigatorBar( + matchCount = matches.size, + currentIndex = currentMatchIndex, + onPrevious = { + if (matches.isNotEmpty()) { + currentMatchIndex = (currentMatchIndex - 1 + matches.size) % matches.size + } + }, + onNext = { + if (matches.isNotEmpty()) { + currentMatchIndex = (currentMatchIndex + 1) % matches.size + } + }, + ) + HorizontalDivider() + } + + ScrollToBottomChip( + listState = listState, modifier = Modifier.fillMaxSize(), ) { - // Connection info header - item(key = "header") { - SseConnectionInfoHeader( - modifier = Modifier.fillMaxWidth(), - entry = entry, - ) - } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + ) { + // Connection info header + item(key = "header") { + SseConnectionInfoHeader( + modifier = Modifier.fillMaxWidth(), + entry = entry, + ) + } - // History cleared banner - if (entry.historyCleared) { - item(key = "history_cleared") { - SseHistoryClearedBanner() + // History cleared banner + if (entry.historyCleared) { + item(key = "history_cleared") { + SseHistoryClearedBanner() + } } - } - // Events - items(events, key = { it.id }) { event -> - SseEventBubble( - modifier = Modifier.fillMaxWidth(), - event = event, - ) - } + // Events + itemsIndexed(events, key = { _, e -> e.id }) { index, event -> + val activeMatch = matches.getOrNull(currentMatchIndex) + val activeRange = if (activeMatch?.eventIndex == index) { + activeMatch.start..activeMatch.endInclusive + } else { + null + } + SseEventBubble( + modifier = Modifier.fillMaxWidth(), + event = event, + searchQuery = debouncedQuery, + activeMatchRange = activeRange, + ) + } - // Bottom spacer - item { Spacer(Modifier.height(16.dp)) } + // Bottom spacer + item { Spacer(Modifier.height(16.dp)) } + } } } } } +private data class SseMatchPosition( + val eventIndex: Int, + val start: Int, + val endInclusive: Int, +) + +private fun computeSseMatches( + events: List, + query: String, +): List { + if (query.isBlank()) return emptyList() + val lowerQuery = query.lowercase() + val results = mutableListOf() + events.forEachIndexed { index, event -> + val lowerData = event.data.lowercase() + var cursor = 0 + while (true) { + val hit = lowerData.indexOf(lowerQuery, cursor) + if (hit < 0) break + results += SseMatchPosition( + eventIndex = index, + start = hit, + endInclusive = hit + query.length - 1, + ) + cursor = hit + query.length + } + } + return results +} + +@Composable +private fun SseSearchNavigatorBar( + matchCount: Int, + currentIndex: Int, + onPrevious: () -> Unit, + onNext: () -> Unit, +) { + val display = if (matchCount == 0) 0 else currentIndex + 1 + val enabled = matchCount > 0 + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + Text( + text = "$display / $matchCount", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + IconButton(onClick = onPrevious, enabled = enabled) { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = "Previous match", + ) + } + IconButton(onClick = onNext, enabled = enabled) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Next match", + ) + } + } +} + @Composable private fun SseConnectionInfoHeader( modifier: Modifier = Modifier, From f4f85ec318321245c58d8657eaa195a62eb2cb98 Mon Sep 17 00:00:00 2001 From: Sandesh Date: Tue, 9 Jun 2026 02:23:07 +0530 Subject: [PATCH 4/7] Add share dropdown to SSE detail and drop CC suppressions - SseLogUtil.buildSseShareText emits a full SSE transcript (connection metadata, request headers, per-event timestamped block). - SseDetailScreen gains Share IconButton + DropdownMenu (text/file) and a snackbar host, mirroring the WS pattern. - Extracted SseDetailTopBar / SocketDetailTopBar (top-bar branching) and SseDetailContent / SocketDetailContent (navigator row + LazyColumn) so the screen functions stay under detekt's CC=15 ceiling without a Suppress annotation. - Hoisted the 450 ms search debounce into a shared rememberDebouncedQuery helper used by both screens. --- .../wiretap/helper/util/SseLogUtil.kt | 53 +++ .../wiretap/ui/common/SearchField.kt | 14 + .../socket/detail/SocketDetailScreen.kt | 356 ++++++++++-------- .../ui/screens/sse/detail/SseDetailScreen.kt | 332 ++++++++++------ 4 files changed, 471 insertions(+), 284 deletions(-) create mode 100644 wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/SseLogUtil.kt diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/SseLogUtil.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/SseLogUtil.kt new file mode 100644 index 00000000..d27a4b07 --- /dev/null +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/SseLogUtil.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 skymansandy. All rights reserved. + */ + +package dev.skymansandy.wiretap.helper.util + +import dev.skymansandy.wiretap.domain.model.SseConnection +import dev.skymansandy.wiretap.domain.model.SseEvent + +internal const val SSE_LOG_FILE_NAME = "wiretap_sse_log.txt" + +internal fun buildSseShareText( + connection: SseConnection, + events: List, +): String = buildString { + appendLine("SSE ${connection.url}") + appendLine("Status: ${connection.status.name}") + appendLine("Opened: ${formatTime(connection.timestamp)}") + connection.closedAt?.let { appendLine("Closed: ${formatTime(it)}") } + connection.failureMessage?.let { appendLine("Error: $it") } + connection.lastEventId?.let { appendLine("Last Event ID: $it") } + connection.retryMs?.let { appendLine("Retry: ${it}ms") } + appendLine() + appendLine("--- Request Headers ---") + if (connection.requestHeaders.isEmpty()) { + appendLine("(none)") + } else { + connection.requestHeaders.forEach { (k, v) -> appendLine("$k: $v") } + } + appendLine() + appendLine("--- Events (${events.size}) ---") + if (events.isEmpty()) { + append("(none)") + } else { + events.forEachIndexed { index, event -> + append(formatEventBlock(event)) + if (index < events.lastIndex) appendLine() + } + } +} + +private fun formatEventBlock(event: SseEvent): String = buildString { + val time = formatTime(event.timestamp) + val bytes = formatBytes(event.byteCount) + val header = buildString { + append("[$time]") + event.eventType?.let { append(" event: $it") } + event.eventId?.let { append(" id: $it") } + append(" ($bytes)") + } + appendLine(header) + append(event.data) +} diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/SearchField.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/SearchField.kt index 56b09782..31394501 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/SearchField.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/SearchField.kt @@ -20,6 +20,8 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -28,6 +30,18 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +@Composable +internal fun rememberDebouncedQuery(query: String, debounceMs: Long = 450L): State = + produceState(initialValue = "", key1 = query) { + if (query.isEmpty()) { + value = "" + } else { + delay(debounceMs) + value = query + } + } @Composable internal fun SearchField( diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailScreen.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailScreen.kt index 13e830b2..0b8733cd 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailScreen.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons @@ -41,7 +42,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -72,14 +72,13 @@ import dev.skymansandy.wiretap.ui.common.LocalSnackbarHostState import dev.skymansandy.wiretap.ui.common.MessageBubble import dev.skymansandy.wiretap.ui.common.ScrollToBottomChip import dev.skymansandy.wiretap.ui.common.SearchField +import dev.skymansandy.wiretap.ui.common.rememberDebouncedQuery import dev.skymansandy.wiretap.ui.screens.socket.components.StatusChip import dev.skymansandy.wiretap.ui.theme.WiretapColors -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf -@Suppress("CyclomaticComplexMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun SocketDetailScreenView( @@ -103,7 +102,6 @@ internal fun SocketDetailScreenView( var isSearchActive by remember { mutableStateOf(false) } val searchFocusRequester = remember { FocusRequester() } - var showShareMenu by remember { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() @@ -113,14 +111,7 @@ internal fun SocketDetailScreenView( } } - val debouncedQuery by produceState(initialValue = "", key1 = searchQuery) { - if (searchQuery.isEmpty()) { - value = "" - } else { - delay(450) - value = searchQuery - } - } + val debouncedQuery by rememberDebouncedQuery(searchQuery) val matches = remember(messages, debouncedQuery) { computeSocketMatches(messages, debouncedQuery) @@ -169,173 +160,212 @@ internal fun SocketDetailScreenView( modifier = modifier, snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { - TopAppBar( - title = { - if (isSearchActive) { - SearchField( - modifier = Modifier.focusRequester(searchFocusRequester), - query = searchQuery, - onQueryChange = { searchQuery = it }, - ) - } else { - Column { - Text( - text = "WS $urlDisplay", - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } + SocketDetailTopBar( + urlDisplay = urlDisplay, + status = entry.status, + isSearchActive = isSearchActive, + searchQuery = searchQuery, + searchFocusRequester = searchFocusRequester, + onSearchQueryChange = { searchQuery = it }, + onActivateSearch = { isSearchActive = true }, + onCloseSearch = { + isSearchActive = false + searchQuery = "" }, - navigationIcon = { - IconButton( - onClick = { - if (isSearchActive) { - isSearchActive = false - searchQuery = "" - } else { - navigator.pop() - } - }, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - ) - } + onBack = { navigator.pop() }, + onShareAsText = { + val message = shareLogText( + subject = "WS ${entry.url}", + text = buildSocketShareText(entry, messages), + ) + message?.let { coroutineScope.launch { snackbarHostState.showSnackbar(it) } } }, - actions = { - if (isSearchActive) { - IconButton( - onClick = { - isSearchActive = false - searchQuery = "" - }, - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Close search", - ) - } - } else { - IconButton(onClick = { isSearchActive = true }) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search", - ) - } - Box { - IconButton(onClick = { showShareMenu = true }) { - Icon( - imageVector = Icons.Default.Share, - contentDescription = "Share", - ) - } - DropdownMenu( - expanded = showShareMenu, - onDismissRequest = { showShareMenu = false }, - ) { - DropdownMenuItem( - text = { Text("Share as text") }, - onClick = { - showShareMenu = false - val message = shareLogText( - subject = "WS ${entry.url}", - text = buildSocketShareText(entry, messages), - ) - message?.let { - coroutineScope.launch { - snackbarHostState.showSnackbar(it) - } - } - }, - ) - DropdownMenuItem( - text = { Text("Share as file") }, - onClick = { - showShareMenu = false - shareLogAsFile( - content = buildSocketShareText(entry, messages), - fileName = SOCKET_LOG_FILE_NAME, - ) - }, - ) - } - } - StatusChip(status = entry.status) - } + onShareAsFile = { + shareLogAsFile( + content = buildSocketShareText(entry, messages), + fileName = SOCKET_LOG_FILE_NAME, + ) }, ) }, ) { padding -> - Column(modifier = Modifier.fillMaxSize().padding(padding)) { - if (isSearchActive && debouncedQuery.isNotEmpty()) { - SearchNavigatorBar( - matchCount = matches.size, - currentIndex = currentMatchIndex, - onPrevious = { - if (matches.isNotEmpty()) { - currentMatchIndex = (currentMatchIndex - 1 + matches.size) % matches.size - } - }, - onNext = { - if (matches.isNotEmpty()) { - currentMatchIndex = (currentMatchIndex + 1) % matches.size - } - }, + SocketDetailContent( + modifier = Modifier.fillMaxSize().padding(padding), + entry = entry, + messages = messages, + listState = listState, + showNavigator = isSearchActive && debouncedQuery.isNotEmpty(), + debouncedQuery = debouncedQuery, + matches = matches, + currentMatchIndex = currentMatchIndex, + onMatchIndexChange = { currentMatchIndex = it }, + ) + } + } +} + +@Composable +private fun SocketDetailContent( + modifier: Modifier = Modifier, + entry: SocketConnection, + messages: List, + listState: LazyListState, + showNavigator: Boolean, + debouncedQuery: String, + matches: List, + currentMatchIndex: Int, + onMatchIndexChange: (Int) -> Unit, +) { + Column(modifier = modifier) { + if (showNavigator) { + SearchNavigatorBar( + matchCount = matches.size, + currentIndex = currentMatchIndex, + onPrevious = { + if (matches.isNotEmpty()) { + onMatchIndexChange((currentMatchIndex - 1 + matches.size) % matches.size) + } + }, + onNext = { + if (matches.isNotEmpty()) { + onMatchIndexChange((currentMatchIndex + 1) % matches.size) + } + }, + ) + HorizontalDivider() + } + + ScrollToBottomChip( + listState = listState, + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + ) { + item(key = "header") { + ConnectionInfoHeader( + modifier = Modifier.fillMaxWidth(), + entry = entry, ) - HorizontalDivider() } - ScrollToBottomChip( - listState = listState, - modifier = Modifier.fillMaxSize(), - ) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - ) { - // Connection info header - item(key = "header") { - ConnectionInfoHeader( - modifier = Modifier.fillMaxWidth(), - entry = entry, - ) - } - - // History cleared banner - if (entry.historyCleared) { - item(key = "history_cleared") { - HistoryClearedBanner() - } - } - - // Messages - itemsIndexed(messages, key = { _, m -> m.id }) { index, message -> - val activeMatch = matches.getOrNull(currentMatchIndex) - val activeRange = if (activeMatch?.messageIndex == index) { - activeMatch.start..activeMatch.endInclusive - } else { - null - } - MessageBubble( - modifier = Modifier.fillMaxWidth(), - message = message, - searchQuery = debouncedQuery, - activeMatchRange = activeRange, - ) - } - - // Bottom spacer - item { Spacer(Modifier.height(16.dp)) } + if (entry.historyCleared) { + item(key = "history_cleared") { + HistoryClearedBanner() + } + } + + itemsIndexed(messages, key = { _, m -> m.id }) { index, message -> + val activeMatch = matches.getOrNull(currentMatchIndex) + val activeRange = if (activeMatch?.messageIndex == index) { + activeMatch.start..activeMatch.endInclusive + } else { + null } + MessageBubble( + modifier = Modifier.fillMaxWidth(), + message = message, + searchQuery = debouncedQuery, + activeMatchRange = activeRange, + ) } + + item { Spacer(Modifier.height(16.dp)) } } } } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SocketDetailTopBar( + urlDisplay: String, + status: SocketStatus, + isSearchActive: Boolean, + searchQuery: String, + searchFocusRequester: FocusRequester, + onSearchQueryChange: (String) -> Unit, + onActivateSearch: () -> Unit, + onCloseSearch: () -> Unit, + onBack: () -> Unit, + onShareAsText: () -> Unit, + onShareAsFile: () -> Unit, +) { + var showShareMenu by remember { mutableStateOf(false) } + TopAppBar( + title = { + if (isSearchActive) { + SearchField( + modifier = Modifier.focusRequester(searchFocusRequester), + query = searchQuery, + onQueryChange = onSearchQueryChange, + ) + } else { + Text( + text = "WS $urlDisplay", + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + navigationIcon = { + IconButton(onClick = if (isSearchActive) onCloseSearch else onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + actions = { + if (isSearchActive) { + IconButton(onClick = onCloseSearch) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close search", + ) + } + } else { + IconButton(onClick = onActivateSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + ) + } + Box { + IconButton(onClick = { showShareMenu = true }) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share", + ) + } + DropdownMenu( + expanded = showShareMenu, + onDismissRequest = { showShareMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Share as text") }, + onClick = { + showShareMenu = false + onShareAsText() + }, + ) + DropdownMenuItem( + text = { Text("Share as file") }, + onClick = { + showShareMenu = false + onShareAsFile() + }, + ) + } + } + StatusChip(status = status) + } + }, + ) +} + private data class SocketMatchPosition( val messageIndex: Int, val start: Int, diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailScreen.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailScreen.kt index 83360411..ab053dc1 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailScreen.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons @@ -23,20 +24,26 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -49,16 +56,23 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.skymansandy.wiretap.domain.model.SseConnection import dev.skymansandy.wiretap.domain.model.SseEvent +import dev.skymansandy.wiretap.domain.model.SseStatus +import dev.skymansandy.wiretap.helper.util.SSE_LOG_FILE_NAME +import dev.skymansandy.wiretap.helper.util.buildSseShareText import dev.skymansandy.wiretap.helper.util.formatTime import dev.skymansandy.wiretap.helper.util.formatUrlDisplay +import dev.skymansandy.wiretap.helper.util.shareLogAsFile +import dev.skymansandy.wiretap.helper.util.shareLogText import dev.skymansandy.wiretap.navigation.compose.LocalWiretapNavigator import dev.skymansandy.wiretap.ui.common.InfoLabel +import dev.skymansandy.wiretap.ui.common.LocalSnackbarHostState import dev.skymansandy.wiretap.ui.common.ScrollToBottomChip import dev.skymansandy.wiretap.ui.common.SearchField +import dev.skymansandy.wiretap.ui.common.rememberDebouncedQuery import dev.skymansandy.wiretap.ui.screens.sse.components.SseEventBubble import dev.skymansandy.wiretap.ui.screens.sse.components.SseStatusChip import dev.skymansandy.wiretap.ui.theme.WiretapColors -import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -85,20 +99,16 @@ internal fun SseDetailScreenView( var isSearchActive by remember { mutableStateOf(false) } val searchFocusRequester = remember { FocusRequester() } + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + LaunchedEffect(isSearchActive) { if (isSearchActive) { searchFocusRequester.requestFocus() } } - val debouncedQuery by produceState(initialValue = "", key1 = searchQuery) { - if (searchQuery.isEmpty()) { - value = "" - } else { - delay(450) - value = searchQuery - } - } + val debouncedQuery by rememberDebouncedQuery(searchQuery) val matches = remember(events, debouncedQuery) { computeSseMatches(events, debouncedQuery) @@ -142,137 +152,217 @@ internal fun SseDetailScreenView( formatUrlDisplay(entry.url) } - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = { - if (isSearchActive) { - SearchField( - modifier = Modifier.focusRequester(searchFocusRequester), - query = searchQuery, - onQueryChange = { searchQuery = it }, + CompositionLocalProvider(LocalSnackbarHostState provides snackbarHostState) { + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + SseDetailTopBar( + urlDisplay = urlDisplay, + status = entry.status, + isSearchActive = isSearchActive, + searchQuery = searchQuery, + searchFocusRequester = searchFocusRequester, + onSearchQueryChange = { searchQuery = it }, + onActivateSearch = { isSearchActive = true }, + onCloseSearch = { + isSearchActive = false + searchQuery = "" + }, + onBack = { navigator.pop() }, + onShareAsText = { + val message = shareLogText( + subject = "SSE ${entry.url}", + text = buildSseShareText(entry, events), ) - } else { - Column { - Text( - text = "SSE $urlDisplay", - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - }, - navigationIcon = { - IconButton( - onClick = { - if (isSearchActive) { - isSearchActive = false - searchQuery = "" - } else { - navigator.pop() - } - }, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + message?.let { coroutineScope.launch { snackbarHostState.showSnackbar(it) } } + }, + onShareAsFile = { + shareLogAsFile( + content = buildSseShareText(entry, events), + fileName = SSE_LOG_FILE_NAME, ) + }, + ) + }, + ) { padding -> + SseDetailContent( + modifier = Modifier.fillMaxSize().padding(padding), + entry = entry, + events = events, + listState = listState, + showNavigator = isSearchActive && debouncedQuery.isNotEmpty(), + debouncedQuery = debouncedQuery, + matches = matches, + currentMatchIndex = currentMatchIndex, + onMatchIndexChange = { currentMatchIndex = it }, + ) + } + } +} + +@Composable +private fun SseDetailContent( + modifier: Modifier = Modifier, + entry: SseConnection, + events: List, + listState: LazyListState, + showNavigator: Boolean, + debouncedQuery: String, + matches: List, + currentMatchIndex: Int, + onMatchIndexChange: (Int) -> Unit, +) { + Column(modifier = modifier) { + if (showNavigator) { + SseSearchNavigatorBar( + matchCount = matches.size, + currentIndex = currentMatchIndex, + onPrevious = { + if (matches.isNotEmpty()) { + onMatchIndexChange((currentMatchIndex - 1 + matches.size) % matches.size) } }, - actions = { - if (isSearchActive) { - IconButton( - onClick = { - isSearchActive = false - searchQuery = "" - }, - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Close search", - ) - } - } else { - IconButton(onClick = { isSearchActive = true }) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search", - ) - } - SseStatusChip(status = entry.status) + onNext = { + if (matches.isNotEmpty()) { + onMatchIndexChange((currentMatchIndex + 1) % matches.size) } }, ) - }, - ) { padding -> - Column(modifier = Modifier.fillMaxSize().padding(padding)) { - if (isSearchActive && debouncedQuery.isNotEmpty()) { - SseSearchNavigatorBar( - matchCount = matches.size, - currentIndex = currentMatchIndex, - onPrevious = { - if (matches.isNotEmpty()) { - currentMatchIndex = (currentMatchIndex - 1 + matches.size) % matches.size - } - }, - onNext = { - if (matches.isNotEmpty()) { - currentMatchIndex = (currentMatchIndex + 1) % matches.size - } - }, - ) - HorizontalDivider() - } + HorizontalDivider() + } - ScrollToBottomChip( - listState = listState, + ScrollToBottomChip( + listState = listState, + modifier = Modifier.fillMaxSize(), + ) { + LazyColumn( + state = listState, modifier = Modifier.fillMaxSize(), ) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - ) { - // Connection info header - item(key = "header") { - SseConnectionInfoHeader( - modifier = Modifier.fillMaxWidth(), - entry = entry, - ) - } + item(key = "header") { + SseConnectionInfoHeader( + modifier = Modifier.fillMaxWidth(), + entry = entry, + ) + } - // History cleared banner - if (entry.historyCleared) { - item(key = "history_cleared") { - SseHistoryClearedBanner() - } + if (entry.historyCleared) { + item(key = "history_cleared") { + SseHistoryClearedBanner() } + } - // Events - itemsIndexed(events, key = { _, e -> e.id }) { index, event -> - val activeMatch = matches.getOrNull(currentMatchIndex) - val activeRange = if (activeMatch?.eventIndex == index) { - activeMatch.start..activeMatch.endInclusive - } else { - null - } - SseEventBubble( - modifier = Modifier.fillMaxWidth(), - event = event, - searchQuery = debouncedQuery, - activeMatchRange = activeRange, - ) + itemsIndexed(events, key = { _, e -> e.id }) { index, event -> + val activeMatch = matches.getOrNull(currentMatchIndex) + val activeRange = if (activeMatch?.eventIndex == index) { + activeMatch.start..activeMatch.endInclusive + } else { + null } - - // Bottom spacer - item { Spacer(Modifier.height(16.dp)) } + SseEventBubble( + modifier = Modifier.fillMaxWidth(), + event = event, + searchQuery = debouncedQuery, + activeMatchRange = activeRange, + ) } + + item { Spacer(Modifier.height(16.dp)) } } } } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SseDetailTopBar( + urlDisplay: String, + status: SseStatus, + isSearchActive: Boolean, + searchQuery: String, + searchFocusRequester: FocusRequester, + onSearchQueryChange: (String) -> Unit, + onActivateSearch: () -> Unit, + onCloseSearch: () -> Unit, + onBack: () -> Unit, + onShareAsText: () -> Unit, + onShareAsFile: () -> Unit, +) { + var showShareMenu by remember { mutableStateOf(false) } + TopAppBar( + title = { + if (isSearchActive) { + SearchField( + modifier = Modifier.focusRequester(searchFocusRequester), + query = searchQuery, + onQueryChange = onSearchQueryChange, + ) + } else { + Text( + text = "SSE $urlDisplay", + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + navigationIcon = { + IconButton(onClick = if (isSearchActive) onCloseSearch else onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + actions = { + if (isSearchActive) { + IconButton(onClick = onCloseSearch) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close search", + ) + } + } else { + IconButton(onClick = onActivateSearch) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + ) + } + Box { + IconButton(onClick = { showShareMenu = true }) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share", + ) + } + DropdownMenu( + expanded = showShareMenu, + onDismissRequest = { showShareMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Share as text") }, + onClick = { + showShareMenu = false + onShareAsText() + }, + ) + DropdownMenuItem( + text = { Text("Share as file") }, + onClick = { + showShareMenu = false + onShareAsFile() + }, + ) + } + } + SseStatusChip(status = status) + } + }, + ) +} + private data class SseMatchPosition( val eventIndex: Int, val start: Int, From a5afb08fd8ad79a01aa2b7e1aa87f713c8ca319d Mon Sep 17 00:00:00 2001 From: Sandesh Date: Tue, 9 Jun 2026 02:27:06 +0530 Subject: [PATCH 5/7] Add tests for log utils --- .../wiretap/helper/util/SocketLogUtilTest.kt | 177 ++++++++++++++++++ .../wiretap/helper/util/SseLogUtilTest.kt | 143 ++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 wiretap-core/src/commonTest/kotlin/dev/skymansandy/wiretap/helper/util/SocketLogUtilTest.kt create mode 100644 wiretap-core/src/commonTest/kotlin/dev/skymansandy/wiretap/helper/util/SseLogUtilTest.kt diff --git a/wiretap-core/src/commonTest/kotlin/dev/skymansandy/wiretap/helper/util/SocketLogUtilTest.kt b/wiretap-core/src/commonTest/kotlin/dev/skymansandy/wiretap/helper/util/SocketLogUtilTest.kt new file mode 100644 index 00000000..f6633273 --- /dev/null +++ b/wiretap-core/src/commonTest/kotlin/dev/skymansandy/wiretap/helper/util/SocketLogUtilTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2026 skymansandy. All rights reserved. + */ + +package dev.skymansandy.wiretap.helper.util + +import dev.skymansandy.wiretap.domain.model.SocketConnection +import dev.skymansandy.wiretap.domain.model.SocketContentType +import dev.skymansandy.wiretap.domain.model.SocketMessage +import dev.skymansandy.wiretap.domain.model.SocketMessageType +import dev.skymansandy.wiretap.domain.model.SocketStatus +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain + +class SocketLogUtilTest : DescribeSpec({ + isolationMode = IsolationMode.InstancePerLeaf + + describe("buildSocketShareText") { + it("starts with WS url and status") { + val text = buildSocketShareText(connection(status = SocketStatus.Open), emptyList()) + + val lines = text.lines() + lines[0] shouldBe "WS wss://example.com/chat" + lines[1] shouldBe "Status: Open" + lines[2].startsWith("Opened: ") shouldBe true + } + + it("omits optional connection metadata when null") { + val text = buildSocketShareText(connection(), emptyList()) + + text shouldNotContain "Closed:" + text shouldNotContain "Close Code:" + text shouldNotContain "Close Reason:" + text shouldNotContain "Error:" + text shouldNotContain "Protocol:" + text shouldNotContain "Remote Address:" + } + + it("emits closure and protocol lines when populated") { + val text = buildSocketShareText( + connection( + closedAt = 1L, + closeCode = 1000, + closeReason = "Normal", + failureMessage = "boom", + protocol = "chat", + remoteAddress = "1.2.3.4:443", + ), + emptyList(), + ) + + text shouldContain "Close Code: 1000" + text shouldContain "Close Reason: Normal" + text shouldContain "Error: boom" + text shouldContain "Protocol: chat" + text shouldContain "Remote Address: 1.2.3.4:443" + } + + it("prints none placeholder for empty request headers and messages") { + val text = buildSocketShareText( + connection(requestHeaders = emptyMap()), + emptyList(), + ) + + text shouldContain "--- Request Headers ---\n(none)" + text shouldContain "--- Messages (0) ---\n(none)" + } + + it("lists request headers as key colon value lines") { + val text = buildSocketShareText( + connection(requestHeaders = mapOf("Sec-WebSocket-Key" to "abc==")), + emptyList(), + ) + + text shouldContain "Sec-WebSocket-Key: abc==" + } + + it("formats text sent and received messages with direction arrows") { + val text = buildSocketShareText( + connection(), + listOf( + message(direction = SocketMessageType.Sent, content = """{"ping":1}""", byteCount = 10), + message(direction = SocketMessageType.Received, content = """{"pong":1}""", byteCount = 10), + ), + ) + + text shouldContain """>> SENT [Text, 10 B] {"ping":1}""" + text shouldContain """<< RECV [Text, 10 B] {"pong":1}""" + } + + it("formats binary messages with the Binary content type tag") { + val text = buildSocketShareText( + connection(), + listOf( + message( + direction = SocketMessageType.Received, + contentType = SocketContentType.Binary, + content = "[Binary: 1.0 KB]", + byteCount = 1024, + ), + ), + ) + + text shouldContain "<< RECV [Binary," + text shouldContain "[Binary: 1.0 KB]" + } + + it("formats control frames as dash dash markers") { + val text = buildSocketShareText( + connection(), + listOf( + message(contentType = SocketContentType.Ping, content = ""), + message(contentType = SocketContentType.Pong, content = ""), + message(contentType = SocketContentType.Close, content = "going away"), + ), + ) + + text shouldContain "-- PING" + text shouldContain "-- PONG" + text shouldContain "-- CLOSE — going away" + } + + it("reports the total message count in the messages section header") { + val text = buildSocketShareText( + connection(), + listOf( + message(), + message(), + message(), + ), + ) + + text shouldContain "--- Messages (3) ---" + } + } +}) + +@Suppress("LongParameterList") +private fun connection( + url: String = "wss://example.com/chat", + requestHeaders: Map = emptyMap(), + status: SocketStatus = SocketStatus.Connecting, + closedAt: Long? = null, + closeCode: Int? = null, + closeReason: String? = null, + failureMessage: String? = null, + protocol: String? = null, + remoteAddress: String? = null, +) = SocketConnection( + url = url, + requestHeaders = requestHeaders, + status = status, + closeCode = closeCode, + closeReason = closeReason, + failureMessage = failureMessage, + timestamp = 0L, + closedAt = closedAt, + protocol = protocol, + remoteAddress = remoteAddress, +) + +private fun message( + direction: SocketMessageType = SocketMessageType.Sent, + contentType: SocketContentType = SocketContentType.Text, + content: String = "{}", + byteCount: Long = 2L, +) = SocketMessage( + socketId = 0L, + direction = direction, + contentType = contentType, + content = content, + byteCount = byteCount, + timestamp = 0L, +) diff --git a/wiretap-core/src/commonTest/kotlin/dev/skymansandy/wiretap/helper/util/SseLogUtilTest.kt b/wiretap-core/src/commonTest/kotlin/dev/skymansandy/wiretap/helper/util/SseLogUtilTest.kt new file mode 100644 index 00000000..c863cb6f --- /dev/null +++ b/wiretap-core/src/commonTest/kotlin/dev/skymansandy/wiretap/helper/util/SseLogUtilTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026 skymansandy. All rights reserved. + */ + +package dev.skymansandy.wiretap.helper.util + +import dev.skymansandy.wiretap.domain.model.SseConnection +import dev.skymansandy.wiretap.domain.model.SseEvent +import dev.skymansandy.wiretap.domain.model.SseStatus +import io.kotest.core.spec.IsolationMode +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain + +class SseLogUtilTest : DescribeSpec({ + isolationMode = IsolationMode.InstancePerLeaf + + describe("buildSseShareText") { + it("starts with SSE url and status") { + val text = buildSseShareText(connection(status = SseStatus.Open), emptyList()) + + val lines = text.lines() + lines[0] shouldBe "SSE https://example.com/stream" + lines[1] shouldBe "Status: Open" + lines[2].startsWith("Opened: ") shouldBe true + } + + it("omits optional connection metadata when null") { + val text = buildSseShareText(connection(), emptyList()) + + text shouldNotContain "Closed:" + text shouldNotContain "Error:" + text shouldNotContain "Last Event ID:" + text shouldNotContain "Retry:" + } + + it("emits closure, error, last-id and retry when populated") { + val text = buildSseShareText( + connection( + closedAt = 1L, + failureMessage = "boom", + lastEventId = "evt-42", + retryMs = 5000L, + ), + emptyList(), + ) + + text shouldContain "Error: boom" + text shouldContain "Last Event ID: evt-42" + text shouldContain "Retry: 5000ms" + } + + it("prints none placeholder for empty request headers and events") { + val text = buildSseShareText(connection(), emptyList()) + + text shouldContain "--- Request Headers ---\n(none)" + text shouldContain "--- Events (0) ---\n(none)" + } + + it("lists request headers as key colon value lines") { + val text = buildSseShareText( + connection(requestHeaders = mapOf("Accept" to "text/event-stream")), + emptyList(), + ) + + text shouldContain "Accept: text/event-stream" + } + + it("renders an event block with event-type id and byte count then data") { + val text = buildSseShareText( + connection(), + listOf( + event( + eventType = "message", + eventId = "1", + data = """{"hello":"world"}""", + byteCount = 17L, + ), + ), + ) + + text shouldContain "event: message" + text shouldContain "id: 1" + text shouldContain "(17 B)" + text shouldContain """{"hello":"world"}""" + } + + it("omits event-type and id when not set") { + val text = buildSseShareText( + connection(), + listOf(event(eventType = null, eventId = null, data = "raw")), + ) + + text shouldNotContain "event:" + text shouldNotContain "id:" + text shouldContain "raw" + } + + it("reports the total event count in the events section header") { + val text = buildSseShareText( + connection(), + listOf(event(), event(), event()), + ) + + text shouldContain "--- Events (3) ---" + } + } +}) + +@Suppress("LongParameterList") +private fun connection( + url: String = "https://example.com/stream", + requestHeaders: Map = emptyMap(), + status: SseStatus = SseStatus.Connecting, + closedAt: Long? = null, + failureMessage: String? = null, + lastEventId: String? = null, + retryMs: Long? = null, +) = SseConnection( + url = url, + requestHeaders = requestHeaders, + status = status, + failureMessage = failureMessage, + timestamp = 0L, + closedAt = closedAt, + lastEventId = lastEventId, + retryMs = retryMs, +) + +private fun event( + eventType: String? = null, + data: String = "{}", + eventId: String? = null, + byteCount: Long = 2L, +) = SseEvent( + connectionId = 0L, + eventType = eventType, + data = data, + eventId = eventId, + byteCount = byteCount, + timestamp = 0L, +) From 7356c02cbfb7d7fae34c5b53bfd0c521dfe163b9 Mon Sep 17 00:00:00 2001 From: Sandesh Date: Tue, 9 Jun 2026 02:38:25 +0530 Subject: [PATCH 6/7] Hoist socket detail search and share state into the ViewModel SocketDetailViewModel now owns searchQuery / isSearchActive, the 450 ms debouncedQuery (same shape as SocketLogListViewModel), the derived matches list (combine of messages + debouncedQuery), and currentMatchIndex with an automatic reset when the match set changes. activateSearch / closeSearch / setSearchQuery / goToPreviousMatch / goToNextMatch drive the state machine, and buildShareText / shareSubject / shareFileName let the composable delegate share-text construction instead of inlining it. The screen drops its local rememberDebouncedQuery / computeSocketMatches / SocketMatchPosition copies in favor of collectAsStateWithLifecycle reads and VM method references; only Compose-bound state (focus requester, snackbar host, dropdown open, scroll LaunchedEffects) stays. Search state now survives configuration changes and the behavior is covered by unit tests on the VM directly. --- .../socket/detail/SocketDetailScreen.kt | 99 ++------ .../socket/detail/SocketDetailViewModel.kt | 118 ++++++++++ .../detail/SocketDetailViewModelTest.kt | 211 ++++++++++++++++++ 3 files changed, 348 insertions(+), 80 deletions(-) diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailScreen.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailScreen.kt index 0b8733cd..000d6f39 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailScreen.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailScreen.kt @@ -60,8 +60,6 @@ import dev.skymansandy.wiretap.domain.model.SocketContentType import dev.skymansandy.wiretap.domain.model.SocketMessage import dev.skymansandy.wiretap.domain.model.SocketMessageType import dev.skymansandy.wiretap.domain.model.SocketStatus -import dev.skymansandy.wiretap.helper.util.SOCKET_LOG_FILE_NAME -import dev.skymansandy.wiretap.helper.util.buildSocketShareText import dev.skymansandy.wiretap.helper.util.formatTime import dev.skymansandy.wiretap.helper.util.formatUrlDisplay import dev.skymansandy.wiretap.helper.util.shareLogAsFile @@ -72,7 +70,6 @@ import dev.skymansandy.wiretap.ui.common.LocalSnackbarHostState import dev.skymansandy.wiretap.ui.common.MessageBubble import dev.skymansandy.wiretap.ui.common.ScrollToBottomChip import dev.skymansandy.wiretap.ui.common.SearchField -import dev.skymansandy.wiretap.ui.common.rememberDebouncedQuery import dev.skymansandy.wiretap.ui.screens.socket.components.StatusChip import dev.skymansandy.wiretap.ui.theme.WiretapColors import kotlinx.coroutines.launch @@ -96,12 +93,14 @@ internal fun SocketDetailScreenView( } val messages by viewModel.messages.collectAsStateWithLifecycle() - val listState = rememberLazyListState() + val isSearchActive by viewModel.isSearchActive.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() + val debouncedQuery by viewModel.debouncedQuery.collectAsStateWithLifecycle() + val matches by viewModel.matches.collectAsStateWithLifecycle() + val currentMatchIndex by viewModel.currentMatchIndex.collectAsStateWithLifecycle() - var searchQuery by remember { mutableStateOf("") } - var isSearchActive by remember { mutableStateOf(false) } + val listState = rememberLazyListState() val searchFocusRequester = remember { FocusRequester() } - val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() @@ -111,17 +110,6 @@ internal fun SocketDetailScreenView( } } - val debouncedQuery by rememberDebouncedQuery(searchQuery) - - val matches = remember(messages, debouncedQuery) { - computeSocketMatches(messages, debouncedQuery) - } - - var currentMatchIndex by remember { mutableStateOf(0) } - LaunchedEffect(matches) { - currentMatchIndex = 0 - } - val headerOffset = 1 + (if (entry.historyCleared) 1 else 0) val autoScrollDisabled = isSearchActive && debouncedQuery.isNotEmpty() @@ -166,24 +154,21 @@ internal fun SocketDetailScreenView( isSearchActive = isSearchActive, searchQuery = searchQuery, searchFocusRequester = searchFocusRequester, - onSearchQueryChange = { searchQuery = it }, - onActivateSearch = { isSearchActive = true }, - onCloseSearch = { - isSearchActive = false - searchQuery = "" - }, + onSearchQueryChange = viewModel::setSearchQuery, + onActivateSearch = viewModel::activateSearch, + onCloseSearch = viewModel::closeSearch, onBack = { navigator.pop() }, onShareAsText = { val message = shareLogText( - subject = "WS ${entry.url}", - text = buildSocketShareText(entry, messages), + subject = viewModel.shareSubject, + text = viewModel.buildShareText(), ) message?.let { coroutineScope.launch { snackbarHostState.showSnackbar(it) } } }, onShareAsFile = { shareLogAsFile( - content = buildSocketShareText(entry, messages), - fileName = SOCKET_LOG_FILE_NAME, + content = viewModel.buildShareText(), + fileName = viewModel.shareFileName, ) }, ) @@ -198,7 +183,8 @@ internal fun SocketDetailScreenView( debouncedQuery = debouncedQuery, matches = matches, currentMatchIndex = currentMatchIndex, - onMatchIndexChange = { currentMatchIndex = it }, + onPreviousMatch = viewModel::goToPreviousMatch, + onNextMatch = viewModel::goToNextMatch, ) } } @@ -214,23 +200,16 @@ private fun SocketDetailContent( debouncedQuery: String, matches: List, currentMatchIndex: Int, - onMatchIndexChange: (Int) -> Unit, + onPreviousMatch: () -> Unit, + onNextMatch: () -> Unit, ) { Column(modifier = modifier) { if (showNavigator) { SearchNavigatorBar( matchCount = matches.size, currentIndex = currentMatchIndex, - onPrevious = { - if (matches.isNotEmpty()) { - onMatchIndexChange((currentMatchIndex - 1 + matches.size) % matches.size) - } - }, - onNext = { - if (matches.isNotEmpty()) { - onMatchIndexChange((currentMatchIndex + 1) % matches.size) - } - }, + onPrevious = onPreviousMatch, + onNext = onNextMatch, ) HorizontalDivider() } @@ -366,46 +345,6 @@ private fun SocketDetailTopBar( ) } -private data class SocketMatchPosition( - val messageIndex: Int, - val start: Int, - val endInclusive: Int, -) - -private fun computeSocketMatches( - messages: List, - query: String, -): List { - if (query.isBlank()) return emptyList() - val lowerQuery = query.lowercase() - val results = mutableListOf() - messages.forEachIndexed { index, message -> - if (!message.contentType.isSearchable()) return@forEachIndexed - val lowerContent = message.content.lowercase() - var cursor = 0 - while (true) { - val hit = lowerContent.indexOf(lowerQuery, cursor) - if (hit < 0) break - results += SocketMatchPosition( - messageIndex = index, - start = hit, - endInclusive = hit + query.length - 1, - ) - cursor = hit + query.length - } - } - return results -} - -private fun SocketContentType.isSearchable(): Boolean = when (this) { - SocketContentType.Text -> true - SocketContentType.Binary, - SocketContentType.Ping, - SocketContentType.Pong, - SocketContentType.Close, - -> false -} - @Composable private fun SearchNavigatorBar( matchCount: Int, diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailViewModel.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailViewModel.kt index afe2b38f..b2109718 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailViewModel.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailViewModel.kt @@ -7,14 +7,25 @@ package dev.skymansandy.wiretap.ui.screens.socket.detail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.skymansandy.wiretap.domain.model.SocketConnection +import dev.skymansandy.wiretap.domain.model.SocketContentType import dev.skymansandy.wiretap.domain.model.SocketMessage import dev.skymansandy.wiretap.domain.orchestrator.SocketLogManager +import dev.skymansandy.wiretap.helper.util.SOCKET_LOG_FILE_NAME +import dev.skymansandy.wiretap.helper.util.buildSocketShareText +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) internal class SocketDetailViewModel( private val socketId: Long, private val socketLogManager: SocketLogManager, @@ -37,9 +48,116 @@ internal class SocketDetailViewModel( initialValue = emptyList(), ) + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _isSearchActive = MutableStateFlow(false) + val isSearchActive: StateFlow = _isSearchActive.asStateFlow() + + val debouncedQuery: StateFlow = _searchQuery + .debounce { if (it.isEmpty()) 0L else SEARCH_DEBOUNCE_MS } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = "", + ) + + val matches: StateFlow> = combine(messages, debouncedQuery) { msgs, q -> + computeSocketMatches(msgs, q) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList(), + ) + + private val _currentMatchIndex = MutableStateFlow(0) + val currentMatchIndex: StateFlow = _currentMatchIndex.asStateFlow() + + val shareFileName: String = SOCKET_LOG_FILE_NAME + + val shareSubject: String + get() = currentEntry()?.let { "WS ${it.url}" } ?: "" + init { viewModelScope.launch { _initialEntry.value = socketLogManager.getSocketById(socketId) } + matches.onEach { _currentMatchIndex.value = 0 }.launchIn(viewModelScope) + } + + fun setSearchQuery(query: String) { + _searchQuery.value = query + } + + fun activateSearch() { + _isSearchActive.value = true + } + + fun closeSearch() { + _isSearchActive.value = false + _searchQuery.value = "" } + + fun goToPreviousMatch() { + val list = matches.value + if (list.isEmpty()) return + _currentMatchIndex.value = (_currentMatchIndex.value - 1 + list.size) % list.size + } + + fun goToNextMatch() { + val list = matches.value + if (list.isEmpty()) return + _currentMatchIndex.value = (_currentMatchIndex.value + 1) % list.size + } + + fun buildShareText(): String { + val entry = currentEntry() ?: return "" + return buildSocketShareText(entry, messages.value) + } + + private fun currentEntry(): SocketConnection? = liveEntry.value ?: _initialEntry.value + + private companion object { + const val SEARCH_DEBOUNCE_MS = 450L + } +} + +internal data class SocketMatchPosition( + val messageIndex: Int, + val start: Int, + val endInclusive: Int, +) + +internal fun computeSocketMatches( + messages: List, + query: String, +): List { + if (query.isBlank()) return emptyList() + val lowerQuery = query.lowercase() + val results = mutableListOf() + messages.forEachIndexed { index, message -> + if (!message.contentType.isSearchable()) return@forEachIndexed + val lowerContent = message.content.lowercase() + var cursor = 0 + while (true) { + val hit = lowerContent.indexOf(lowerQuery, cursor) + if (hit < 0) break + results += SocketMatchPosition( + messageIndex = index, + start = hit, + endInclusive = hit + query.length - 1, + ) + cursor = hit + query.length + } + } + return results +} + +private fun SocketContentType.isSearchable(): Boolean = when (this) { + SocketContentType.Text -> true + SocketContentType.Binary, + SocketContentType.Ping, + SocketContentType.Pong, + SocketContentType.Close, + -> false } diff --git a/wiretap-core/src/commonTest/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailViewModelTest.kt b/wiretap-core/src/commonTest/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailViewModelTest.kt index a8038f57..9b9d39ea 100644 --- a/wiretap-core/src/commonTest/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailViewModelTest.kt +++ b/wiretap-core/src/commonTest/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailViewModelTest.kt @@ -9,6 +9,7 @@ import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.everySuspend +import dev.mokkery.matcher.any import dev.mokkery.mock import dev.skymansandy.wiretap.domain.model.SocketConnection import dev.skymansandy.wiretap.domain.model.SocketContentType @@ -20,8 +21,11 @@ import dev.skymansandy.wiretap.testing.MainDispatcherSupport import io.kotest.core.spec.IsolationMode import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -94,8 +98,215 @@ class SocketDetailViewModelTest : DescribeSpec({ } } } + + describe("search") { + it("activates and closes via actions") { + runTest { + everySuspend { manager.getSocketById(any()) } returns null + every { manager.flowSocketById(any()) } returns flowOf(null) + every { manager.flowSocketMessagesById(any()) } returns flowOf(emptyList()) + + val vm = SocketDetailViewModel(socketId = 1, socketLogManager = manager) + + vm.isSearchActive.value shouldBe false + + vm.activateSearch() + vm.isSearchActive.value shouldBe true + + vm.setSearchQuery("ping") + vm.searchQuery.value shouldBe "ping" + + vm.closeSearch() + vm.isSearchActive.value shouldBe false + vm.searchQuery.value shouldBe "" + } + } + + it("debounces searchQuery for 450ms before publishing to debouncedQuery") { + runTest { + everySuspend { manager.getSocketById(any()) } returns null + every { manager.flowSocketById(any()) } returns flowOf(null) + every { manager.flowSocketMessagesById(any()) } returns flowOf(emptyList()) + + val vm = SocketDetailViewModel(socketId = 1, socketLogManager = manager) + + vm.debouncedQuery.test { + awaitItem() shouldBe "" + vm.setSearchQuery("pi") + advanceTimeBy(SHORT_DEBOUNCE_MS) + expectNoEvents() + advanceTimeBy(LONG_DEBOUNCE_MS) + awaitItem() shouldBe "pi" + cancelAndIgnoreRemainingEvents() + } + } + } + + it("publishes an empty query immediately") { + runTest { + everySuspend { manager.getSocketById(any()) } returns null + every { manager.flowSocketById(any()) } returns flowOf(null) + every { manager.flowSocketMessagesById(any()) } returns flowOf(emptyList()) + + val vm = SocketDetailViewModel(socketId = 1, socketLogManager = manager) + + vm.debouncedQuery.test { + awaitItem() shouldBe "" + vm.setSearchQuery("hi") + advanceTimeBy(FULL_DEBOUNCE_MS) + awaitItem() shouldBe "hi" + vm.setSearchQuery("") + advanceUntilIdle() + awaitItem() shouldBe "" + cancelAndIgnoreRemainingEvents() + } + } + } + } + + describe("matches") { + it("recomputes when messages or debounced query change") { + runTest { + val msgs = MutableStateFlow(emptyList()) + everySuspend { manager.getSocketById(any()) } returns null + every { manager.flowSocketById(any()) } returns flowOf(null) + every { manager.flowSocketMessagesById(any()) } returns msgs + + val vm = SocketDetailViewModel(socketId = 1, socketLogManager = manager) + + vm.matches.test { + awaitItem() shouldBe emptyList() + + msgs.value = listOf(textMessage("hello ping pong"), textMessage("nothing")) + advanceUntilIdle() + + vm.setSearchQuery("ping") + advanceTimeBy(FULL_DEBOUNCE_MS) + + val list = awaitItem() + list.size shouldBe 1 + list[0].messageIndex shouldBe 0 + list[0].start shouldBe 6 + list[0].endInclusive shouldBe 9 + cancelAndIgnoreRemainingEvents() + } + } + } + + it("skips non-text messages so binary/control frames do not match") { + val msg = SocketMessage( + socketId = 0, + direction = SocketMessageType.Received, + contentType = SocketContentType.Binary, + content = "[Binary: 1.0 KB]", + byteCount = 1024, + timestamp = 0, + ) + + val matches = computeSocketMatches(listOf(msg), "Binary") + + matches shouldBe emptyList() + } + } + + describe("match navigation") { + it("wraps prev / next around the match list and resets when matches change") { + runTest { + val msgs = MutableStateFlow( + listOf(textMessage("hit"), textMessage("hit"), textMessage("hit")), + ) + everySuspend { manager.getSocketById(any()) } returns null + every { manager.flowSocketById(any()) } returns flowOf(null) + every { manager.flowSocketMessagesById(any()) } returns msgs + + val vm = SocketDetailViewModel(socketId = 1, socketLogManager = manager) + vm.setSearchQuery("hit") + advanceTimeBy(FULL_DEBOUNCE_MS) + advanceUntilIdle() + + vm.matches.value.size shouldBe 3 + vm.currentMatchIndex.value shouldBe 0 + + vm.goToNextMatch() + vm.currentMatchIndex.value shouldBe 1 + + vm.goToPreviousMatch() + vm.goToPreviousMatch() + vm.currentMatchIndex.value shouldBe 2 + + vm.goToNextMatch() + vm.currentMatchIndex.value shouldBe 0 + + msgs.value = listOf(textMessage("hit"), textMessage("hit")) + advanceUntilIdle() + vm.currentMatchIndex.value shouldBe 0 + } + } + + it("is a no-op when there are no matches") { + runTest { + everySuspend { manager.getSocketById(any()) } returns null + every { manager.flowSocketById(any()) } returns flowOf(null) + every { manager.flowSocketMessagesById(any()) } returns flowOf(emptyList()) + + val vm = SocketDetailViewModel(socketId = 1, socketLogManager = manager) + + vm.goToNextMatch() + vm.goToPreviousMatch() + vm.currentMatchIndex.value shouldBe 0 + } + } + } + + describe("share") { + it("buildShareText delegates to buildSocketShareText with the live entry") { + runTest { + val entry = connection(id = 9).copy(url = "wss://share.example/x") + everySuspend { manager.getSocketById(9) } returns entry + every { manager.flowSocketById(9) } returns flowOf(entry) + every { manager.flowSocketMessagesById(9) } returns flowOf( + listOf(textMessage("UNIQUE_TOKEN")), + ) + + val vm = SocketDetailViewModel(socketId = 9, socketLogManager = manager) + advanceUntilIdle() + + val text = vm.buildShareText() + + text shouldContain "WS wss://share.example/x" + text shouldContain "UNIQUE_TOKEN" + vm.shareSubject shouldBe "WS wss://share.example/x" + } + } + + it("buildShareText returns empty string before the entry resolves") { + runTest { + everySuspend { manager.getSocketById(any()) } returns null + every { manager.flowSocketById(any()) } returns flowOf(null) + every { manager.flowSocketMessagesById(any()) } returns flowOf(emptyList()) + + val vm = SocketDetailViewModel(socketId = 1, socketLogManager = manager) + + vm.buildShareText() shouldBe "" + vm.shareSubject shouldBe "" + } + } + } }) +private const val SHORT_DEBOUNCE_MS = 200L +private const val LONG_DEBOUNCE_MS = 300L +private const val FULL_DEBOUNCE_MS = 500L + +private fun textMessage(content: String) = SocketMessage( + socketId = 0L, + direction = SocketMessageType.Sent, + contentType = SocketContentType.Text, + content = content, + byteCount = content.length.toLong(), + timestamp = 0L, +) + private fun connection(id: Long) = SocketConnection( id = id, url = "wss://x", From 01094ed34632c7d876a1c90f63791fff2034d033 Mon Sep 17 00:00:00 2001 From: Sandesh Date: Tue, 9 Jun 2026 02:42:34 +0530 Subject: [PATCH 7/7] Hoist SSE detail search and share state into the ViewModel Mirrors the WS Phase 1 change for SSE: SseDetailViewModel now owns searchQuery / isSearchActive / the 450 ms debouncedQuery, the derived matches list (combine of events + debouncedQuery), and currentMatchIndex with an automatic reset when the match set changes. Actions (activateSearch / closeSearch / setSearchQuery / goToPreviousMatch / goToNextMatch) and share helpers (buildShareText / shareSubject / shareFileName) drive the screen. SseDetailScreen drops its local rememberDebouncedQuery / computeSseMatches / SseMatchPosition copies in favor of collectAsStateWithLifecycle reads and VM method references. The now-unused rememberDebouncedQuery helper is deleted from SearchField.kt since neither screen needs it anymore. --- .../wiretap/ui/common/SearchField.kt | 14 -- .../ui/screens/sse/detail/SseDetailScreen.kt | 89 ++------ .../screens/sse/detail/SseDetailViewModel.kt | 107 ++++++++++ .../sse/detail/SseDetailViewModelTest.kt | 190 ++++++++++++++++++ 4 files changed, 316 insertions(+), 84 deletions(-) diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/SearchField.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/SearchField.kt index 31394501..56b09782 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/SearchField.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/SearchField.kt @@ -20,8 +20,6 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -30,18 +28,6 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import kotlinx.coroutines.delay - -@Composable -internal fun rememberDebouncedQuery(query: String, debounceMs: Long = 450L): State = - produceState(initialValue = "", key1 = query) { - if (query.isEmpty()) { - value = "" - } else { - delay(debounceMs) - value = query - } - } @Composable internal fun SearchField( diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailScreen.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailScreen.kt index ab053dc1..e956b2b2 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailScreen.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailScreen.kt @@ -57,8 +57,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.skymansandy.wiretap.domain.model.SseConnection import dev.skymansandy.wiretap.domain.model.SseEvent import dev.skymansandy.wiretap.domain.model.SseStatus -import dev.skymansandy.wiretap.helper.util.SSE_LOG_FILE_NAME -import dev.skymansandy.wiretap.helper.util.buildSseShareText import dev.skymansandy.wiretap.helper.util.formatTime import dev.skymansandy.wiretap.helper.util.formatUrlDisplay import dev.skymansandy.wiretap.helper.util.shareLogAsFile @@ -68,7 +66,6 @@ import dev.skymansandy.wiretap.ui.common.InfoLabel import dev.skymansandy.wiretap.ui.common.LocalSnackbarHostState import dev.skymansandy.wiretap.ui.common.ScrollToBottomChip import dev.skymansandy.wiretap.ui.common.SearchField -import dev.skymansandy.wiretap.ui.common.rememberDebouncedQuery import dev.skymansandy.wiretap.ui.screens.sse.components.SseEventBubble import dev.skymansandy.wiretap.ui.screens.sse.components.SseStatusChip import dev.skymansandy.wiretap.ui.theme.WiretapColors @@ -93,12 +90,14 @@ internal fun SseDetailScreenView( } val events by viewModel.events.collectAsStateWithLifecycle() - val listState = rememberLazyListState() + val isSearchActive by viewModel.isSearchActive.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() + val debouncedQuery by viewModel.debouncedQuery.collectAsStateWithLifecycle() + val matches by viewModel.matches.collectAsStateWithLifecycle() + val currentMatchIndex by viewModel.currentMatchIndex.collectAsStateWithLifecycle() - var searchQuery by remember { mutableStateOf("") } - var isSearchActive by remember { mutableStateOf(false) } + val listState = rememberLazyListState() val searchFocusRequester = remember { FocusRequester() } - val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() @@ -108,17 +107,6 @@ internal fun SseDetailScreenView( } } - val debouncedQuery by rememberDebouncedQuery(searchQuery) - - val matches = remember(events, debouncedQuery) { - computeSseMatches(events, debouncedQuery) - } - - var currentMatchIndex by remember { mutableStateOf(0) } - LaunchedEffect(matches) { - currentMatchIndex = 0 - } - val headerOffset = 1 + (if (entry.historyCleared) 1 else 0) val autoScrollDisabled = isSearchActive && debouncedQuery.isNotEmpty() @@ -163,24 +151,21 @@ internal fun SseDetailScreenView( isSearchActive = isSearchActive, searchQuery = searchQuery, searchFocusRequester = searchFocusRequester, - onSearchQueryChange = { searchQuery = it }, - onActivateSearch = { isSearchActive = true }, - onCloseSearch = { - isSearchActive = false - searchQuery = "" - }, + onSearchQueryChange = viewModel::setSearchQuery, + onActivateSearch = viewModel::activateSearch, + onCloseSearch = viewModel::closeSearch, onBack = { navigator.pop() }, onShareAsText = { val message = shareLogText( - subject = "SSE ${entry.url}", - text = buildSseShareText(entry, events), + subject = viewModel.shareSubject, + text = viewModel.buildShareText(), ) message?.let { coroutineScope.launch { snackbarHostState.showSnackbar(it) } } }, onShareAsFile = { shareLogAsFile( - content = buildSseShareText(entry, events), - fileName = SSE_LOG_FILE_NAME, + content = viewModel.buildShareText(), + fileName = viewModel.shareFileName, ) }, ) @@ -195,7 +180,8 @@ internal fun SseDetailScreenView( debouncedQuery = debouncedQuery, matches = matches, currentMatchIndex = currentMatchIndex, - onMatchIndexChange = { currentMatchIndex = it }, + onPreviousMatch = viewModel::goToPreviousMatch, + onNextMatch = viewModel::goToNextMatch, ) } } @@ -211,23 +197,16 @@ private fun SseDetailContent( debouncedQuery: String, matches: List, currentMatchIndex: Int, - onMatchIndexChange: (Int) -> Unit, + onPreviousMatch: () -> Unit, + onNextMatch: () -> Unit, ) { Column(modifier = modifier) { if (showNavigator) { SseSearchNavigatorBar( matchCount = matches.size, currentIndex = currentMatchIndex, - onPrevious = { - if (matches.isNotEmpty()) { - onMatchIndexChange((currentMatchIndex - 1 + matches.size) % matches.size) - } - }, - onNext = { - if (matches.isNotEmpty()) { - onMatchIndexChange((currentMatchIndex + 1) % matches.size) - } - }, + onPrevious = onPreviousMatch, + onNext = onNextMatch, ) HorizontalDivider() } @@ -363,36 +342,6 @@ private fun SseDetailTopBar( ) } -private data class SseMatchPosition( - val eventIndex: Int, - val start: Int, - val endInclusive: Int, -) - -private fun computeSseMatches( - events: List, - query: String, -): List { - if (query.isBlank()) return emptyList() - val lowerQuery = query.lowercase() - val results = mutableListOf() - events.forEachIndexed { index, event -> - val lowerData = event.data.lowercase() - var cursor = 0 - while (true) { - val hit = lowerData.indexOf(lowerQuery, cursor) - if (hit < 0) break - results += SseMatchPosition( - eventIndex = index, - start = hit, - endInclusive = hit + query.length - 1, - ) - cursor = hit + query.length - } - } - return results -} - @Composable private fun SseSearchNavigatorBar( matchCount: Int, diff --git a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailViewModel.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailViewModel.kt index bef20a77..6237c914 100644 --- a/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailViewModel.kt +++ b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailViewModel.kt @@ -9,12 +9,22 @@ import androidx.lifecycle.viewModelScope import dev.skymansandy.wiretap.domain.model.SseConnection import dev.skymansandy.wiretap.domain.model.SseEvent import dev.skymansandy.wiretap.domain.orchestrator.SseLogManager +import dev.skymansandy.wiretap.helper.util.SSE_LOG_FILE_NAME +import dev.skymansandy.wiretap.helper.util.buildSseShareText +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) internal class SseDetailViewModel( private val connectionId: Long, private val sseLogManager: SseLogManager, @@ -37,9 +47,106 @@ internal class SseDetailViewModel( initialValue = emptyList(), ) + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _isSearchActive = MutableStateFlow(false) + val isSearchActive: StateFlow = _isSearchActive.asStateFlow() + + val debouncedQuery: StateFlow = _searchQuery + .debounce { if (it.isEmpty()) 0L else SEARCH_DEBOUNCE_MS } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = "", + ) + + val matches: StateFlow> = combine(events, debouncedQuery) { evts, q -> + computeSseMatches(evts, q) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList(), + ) + + private val _currentMatchIndex = MutableStateFlow(0) + val currentMatchIndex: StateFlow = _currentMatchIndex.asStateFlow() + + val shareFileName: String = SSE_LOG_FILE_NAME + + val shareSubject: String + get() = currentEntry()?.let { "SSE ${it.url}" } ?: "" + init { viewModelScope.launch { _initialEntry.value = sseLogManager.getConnectionById(connectionId) } + matches.onEach { _currentMatchIndex.value = 0 }.launchIn(viewModelScope) + } + + fun setSearchQuery(query: String) { + _searchQuery.value = query + } + + fun activateSearch() { + _isSearchActive.value = true + } + + fun closeSearch() { + _isSearchActive.value = false + _searchQuery.value = "" + } + + fun goToPreviousMatch() { + val list = matches.value + if (list.isEmpty()) return + _currentMatchIndex.value = (_currentMatchIndex.value - 1 + list.size) % list.size + } + + fun goToNextMatch() { + val list = matches.value + if (list.isEmpty()) return + _currentMatchIndex.value = (_currentMatchIndex.value + 1) % list.size + } + + fun buildShareText(): String { + val entry = currentEntry() ?: return "" + return buildSseShareText(entry, events.value) + } + + private fun currentEntry(): SseConnection? = liveEntry.value ?: _initialEntry.value + + private companion object { + const val SEARCH_DEBOUNCE_MS = 450L + } +} + +internal data class SseMatchPosition( + val eventIndex: Int, + val start: Int, + val endInclusive: Int, +) + +internal fun computeSseMatches( + events: List, + query: String, +): List { + if (query.isBlank()) return emptyList() + val lowerQuery = query.lowercase() + val results = mutableListOf() + events.forEachIndexed { index, event -> + val lowerData = event.data.lowercase() + var cursor = 0 + while (true) { + val hit = lowerData.indexOf(lowerQuery, cursor) + if (hit < 0) break + results += SseMatchPosition( + eventIndex = index, + start = hit, + endInclusive = hit + query.length - 1, + ) + cursor = hit + query.length + } } + return results } diff --git a/wiretap-core/src/commonTest/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailViewModelTest.kt b/wiretap-core/src/commonTest/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailViewModelTest.kt index f05f32c3..e369cbad 100644 --- a/wiretap-core/src/commonTest/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailViewModelTest.kt +++ b/wiretap-core/src/commonTest/kotlin/dev/skymansandy/wiretap/ui/screens/sse/detail/SseDetailViewModelTest.kt @@ -9,6 +9,7 @@ import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.everySuspend +import dev.mokkery.matcher.any import dev.mokkery.mock import dev.skymansandy.wiretap.domain.model.SseConnection import dev.skymansandy.wiretap.domain.model.SseEvent @@ -17,8 +18,11 @@ import dev.skymansandy.wiretap.testing.MainDispatcherSupport import io.kotest.core.spec.IsolationMode import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -84,4 +88,190 @@ class SseDetailViewModelTest : DescribeSpec({ } } } + + describe("search") { + it("activates and closes via actions") { + runTest { + everySuspend { manager.getConnectionById(any()) } returns null + every { manager.flowConnectionById(any()) } returns flowOf(null) + every { manager.flowEventsById(any()) } returns flowOf(emptyList()) + + val vm = SseDetailViewModel(connectionId = 1, sseLogManager = manager) + + vm.isSearchActive.value shouldBe false + + vm.activateSearch() + vm.isSearchActive.value shouldBe true + + vm.setSearchQuery("hello") + vm.searchQuery.value shouldBe "hello" + + vm.closeSearch() + vm.isSearchActive.value shouldBe false + vm.searchQuery.value shouldBe "" + } + } + + it("debounces searchQuery for 450ms before publishing to debouncedQuery") { + runTest { + everySuspend { manager.getConnectionById(any()) } returns null + every { manager.flowConnectionById(any()) } returns flowOf(null) + every { manager.flowEventsById(any()) } returns flowOf(emptyList()) + + val vm = SseDetailViewModel(connectionId = 1, sseLogManager = manager) + + vm.debouncedQuery.test { + awaitItem() shouldBe "" + vm.setSearchQuery("he") + advanceTimeBy(SHORT_DEBOUNCE_MS) + expectNoEvents() + advanceTimeBy(LONG_DEBOUNCE_MS) + awaitItem() shouldBe "he" + cancelAndIgnoreRemainingEvents() + } + } + } + + it("publishes an empty query immediately") { + runTest { + everySuspend { manager.getConnectionById(any()) } returns null + every { manager.flowConnectionById(any()) } returns flowOf(null) + every { manager.flowEventsById(any()) } returns flowOf(emptyList()) + + val vm = SseDetailViewModel(connectionId = 1, sseLogManager = manager) + + vm.debouncedQuery.test { + awaitItem() shouldBe "" + vm.setSearchQuery("hi") + advanceTimeBy(FULL_DEBOUNCE_MS) + awaitItem() shouldBe "hi" + vm.setSearchQuery("") + advanceUntilIdle() + awaitItem() shouldBe "" + cancelAndIgnoreRemainingEvents() + } + } + } + } + + describe("matches") { + it("recomputes when events or debounced query change") { + runTest { + val flow = MutableStateFlow(emptyList()) + everySuspend { manager.getConnectionById(any()) } returns null + every { manager.flowConnectionById(any()) } returns flowOf(null) + every { manager.flowEventsById(any()) } returns flow + + val vm = SseDetailViewModel(connectionId = 1, sseLogManager = manager) + + vm.matches.test { + awaitItem() shouldBe emptyList() + + flow.value = listOf(event("hello world"), event("nothing here")) + advanceUntilIdle() + + vm.setSearchQuery("world") + advanceTimeBy(FULL_DEBOUNCE_MS) + + val list = awaitItem() + list.size shouldBe 1 + list[0].eventIndex shouldBe 0 + list[0].start shouldBe 6 + list[0].endInclusive shouldBe 10 + cancelAndIgnoreRemainingEvents() + } + } + } + } + + describe("match navigation") { + it("wraps prev / next around the match list and resets when matches change") { + runTest { + val flow = MutableStateFlow(listOf(event("hit"), event("hit"), event("hit"))) + everySuspend { manager.getConnectionById(any()) } returns null + every { manager.flowConnectionById(any()) } returns flowOf(null) + every { manager.flowEventsById(any()) } returns flow + + val vm = SseDetailViewModel(connectionId = 1, sseLogManager = manager) + vm.setSearchQuery("hit") + advanceTimeBy(FULL_DEBOUNCE_MS) + advanceUntilIdle() + + vm.matches.value.size shouldBe 3 + vm.currentMatchIndex.value shouldBe 0 + + vm.goToNextMatch() + vm.currentMatchIndex.value shouldBe 1 + + vm.goToPreviousMatch() + vm.goToPreviousMatch() + vm.currentMatchIndex.value shouldBe 2 + + vm.goToNextMatch() + vm.currentMatchIndex.value shouldBe 0 + + flow.value = listOf(event("hit"), event("hit")) + advanceUntilIdle() + vm.currentMatchIndex.value shouldBe 0 + } + } + + it("is a no-op when there are no matches") { + runTest { + everySuspend { manager.getConnectionById(any()) } returns null + every { manager.flowConnectionById(any()) } returns flowOf(null) + every { manager.flowEventsById(any()) } returns flowOf(emptyList()) + + val vm = SseDetailViewModel(connectionId = 1, sseLogManager = manager) + + vm.goToNextMatch() + vm.goToPreviousMatch() + vm.currentMatchIndex.value shouldBe 0 + } + } + } + + describe("share") { + it("buildShareText delegates to buildSseShareText with the live entry") { + runTest { + val entry = SseConnection(id = 9, url = "https://share.example/stream", timestamp = 0) + everySuspend { manager.getConnectionById(9) } returns entry + every { manager.flowConnectionById(9) } returns flowOf(entry) + every { manager.flowEventsById(9) } returns flowOf(listOf(event("UNIQUE_TOKEN"))) + + val vm = SseDetailViewModel(connectionId = 9, sseLogManager = manager) + advanceUntilIdle() + + val text = vm.buildShareText() + + text shouldContain "SSE https://share.example/stream" + text shouldContain "UNIQUE_TOKEN" + vm.shareSubject shouldBe "SSE https://share.example/stream" + } + } + + it("buildShareText returns empty string before the entry resolves") { + runTest { + everySuspend { manager.getConnectionById(any()) } returns null + every { manager.flowConnectionById(any()) } returns flowOf(null) + every { manager.flowEventsById(any()) } returns flowOf(emptyList()) + + val vm = SseDetailViewModel(connectionId = 1, sseLogManager = manager) + + vm.buildShareText() shouldBe "" + vm.shareSubject shouldBe "" + } + } + } }) + +private const val SHORT_DEBOUNCE_MS = 200L +private const val LONG_DEBOUNCE_MS = 300L +private const val FULL_DEBOUNCE_MS = 500L + +private fun event(data: String) = SseEvent( + connectionId = 0L, + data = data, + byteCount = data.length.toLong(), + timestamp = 0L, +)