diff --git a/gradle.properties b/gradle.properties index d099e2c..6bebe2e 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/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 3901863..f649fba 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 9b9a664..a5a8f2f 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 0000000..d3d96b7 --- /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 0000000..0a2afcb --- /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/helper/util/SseLogUtil.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/SseLogUtil.kt new file mode 100644 index 0000000..d27a4b0 --- /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/helper/util/TextHighlightUtil.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/helper/util/TextHighlightUtil.kt index f20b6ce..fd209a4 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 d6e8aa6..34faf61 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/common/SearchField.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/common/SearchField.kt index e81a3a0..56b0978 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 175a9b8..82505a0 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 a435123..000d6f3 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,26 +15,40 @@ 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.LazyListState +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.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.remember +import androidx.compose.runtime.rememberCoroutineScope 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 @@ -48,12 +62,17 @@ import dev.skymansandy.wiretap.domain.model.SocketMessageType import dev.skymansandy.wiretap.domain.model.SocketStatus 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.launch import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -74,7 +93,25 @@ internal fun SocketDetailScreenView( } val messages by viewModel.messages.collectAsStateWithLifecycle() + 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() + val listState = rememberLazyListState() + val searchFocusRequester = remember { FocusRequester() } + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(isSearchActive) { + if (isSearchActive) { + searchFocusRequester.requestFocus() + } + } + + val headerOffset = 1 + (if (entry.historyCleared) 1 else 0) + val autoScrollDisabled = isSearchActive && debouncedQuery.isNotEmpty() // Scroll to bottom on initial load LaunchedEffect(Unit) { @@ -86,7 +123,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,47 +133,95 @@ 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) } - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = { - 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 = { + SocketDetailTopBar( + urlDisplay = urlDisplay, + status = entry.status, + isSearchActive = isSearchActive, + searchQuery = searchQuery, + searchFocusRequester = searchFocusRequester, + onSearchQueryChange = viewModel::setSearchQuery, + onActivateSearch = viewModel::activateSearch, + onCloseSearch = viewModel::closeSearch, + onBack = { navigator.pop() }, + onShareAsText = { + val message = shareLogText( + subject = viewModel.shareSubject, + text = viewModel.buildShareText(), ) - } - }, - navigationIcon = { - IconButton(onClick = { navigator.pop() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + message?.let { coroutineScope.launch { snackbarHostState.showSnackbar(it) } } + }, + onShareAsFile = { + shareLogAsFile( + content = viewModel.buildShareText(), + fileName = viewModel.shareFileName, ) - } - }, - actions = { - StatusChip(status = entry.status) - }, + }, + ) + }, + ) { padding -> + SocketDetailContent( + modifier = Modifier.fillMaxSize().padding(padding), + entry = entry, + messages = messages, + listState = listState, + showNavigator = isSearchActive && debouncedQuery.isNotEmpty(), + debouncedQuery = debouncedQuery, + matches = matches, + currentMatchIndex = currentMatchIndex, + onPreviousMatch = viewModel::goToPreviousMatch, + onNextMatch = viewModel::goToNextMatch, ) - }, - ) { padding -> + } + } +} + +@Composable +private fun SocketDetailContent( + modifier: Modifier = Modifier, + entry: SocketConnection, + messages: List, + listState: LazyListState, + showNavigator: Boolean, + debouncedQuery: String, + matches: List, + currentMatchIndex: Int, + onPreviousMatch: () -> Unit, + onNextMatch: () -> Unit, +) { + Column(modifier = modifier) { + if (showNavigator) { + SearchNavigatorBar( + matchCount = matches.size, + currentIndex = currentMatchIndex, + onPrevious = onPreviousMatch, + onNext = onNextMatch, + ) + HorizontalDivider() + } + ScrollToBottomChip( listState = listState, - modifier = Modifier.fillMaxSize().padding(padding), + modifier = Modifier.fillMaxSize(), ) { LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), ) { - // Connection info header item(key = "header") { ConnectionInfoHeader( modifier = Modifier.fillMaxWidth(), @@ -144,28 +229,158 @@ internal fun SocketDetailScreenView( ) } - // History cleared banner if (entry.historyCleared) { item(key = "history_cleared") { HistoryClearedBanner() } } - // Messages - items(messages, key = { it.id }) { message -> + 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)) } } } } } +@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) + } + }, + ) +} + +@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/screens/socket/detail/SocketDetailViewModel.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/screens/socket/detail/SocketDetailViewModel.kt index afe2b38..b210971 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/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 543d8cf..6260bec 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 c73f92f..e956b2b 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,40 +15,61 @@ 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.LazyListState +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.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.remember +import androidx.compose.runtime.rememberCoroutineScope 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.domain.model.SseStatus 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.screens.sse.components.SseEventBubble import dev.skymansandy.wiretap.ui.screens.sse.components.SseStatusChip import dev.skymansandy.wiretap.ui.theme.WiretapColors +import kotlinx.coroutines.launch import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -69,7 +90,25 @@ internal fun SseDetailScreenView( } val events by viewModel.events.collectAsStateWithLifecycle() + 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() + val listState = rememberLazyListState() + val searchFocusRequester = remember { FocusRequester() } + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(isSearchActive) { + if (isSearchActive) { + searchFocusRequester.requestFocus() + } + } + + val headerOffset = 1 + (if (entry.historyCleared) 1 else 0) + val autoScrollDisabled = isSearchActive && debouncedQuery.isNotEmpty() // Scroll to bottom on initial load LaunchedEffect(Unit) { @@ -81,7 +120,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,47 +130,95 @@ 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) } - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = { - Column { - Text( - text = "SSE $urlDisplay", - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + CompositionLocalProvider(LocalSnackbarHostState provides snackbarHostState) { + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + SseDetailTopBar( + urlDisplay = urlDisplay, + status = entry.status, + isSearchActive = isSearchActive, + searchQuery = searchQuery, + searchFocusRequester = searchFocusRequester, + onSearchQueryChange = viewModel::setSearchQuery, + onActivateSearch = viewModel::activateSearch, + onCloseSearch = viewModel::closeSearch, + onBack = { navigator.pop() }, + onShareAsText = { + val message = shareLogText( + subject = viewModel.shareSubject, + text = viewModel.buildShareText(), ) - } - }, - navigationIcon = { - IconButton(onClick = { navigator.pop() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", + message?.let { coroutineScope.launch { snackbarHostState.showSnackbar(it) } } + }, + onShareAsFile = { + shareLogAsFile( + content = viewModel.buildShareText(), + fileName = viewModel.shareFileName, ) - } - }, - actions = { - SseStatusChip(status = entry.status) - }, + }, + ) + }, + ) { padding -> + SseDetailContent( + modifier = Modifier.fillMaxSize().padding(padding), + entry = entry, + events = events, + listState = listState, + showNavigator = isSearchActive && debouncedQuery.isNotEmpty(), + debouncedQuery = debouncedQuery, + matches = matches, + currentMatchIndex = currentMatchIndex, + onPreviousMatch = viewModel::goToPreviousMatch, + onNextMatch = viewModel::goToNextMatch, ) - }, - ) { padding -> + } + } +} + +@Composable +private fun SseDetailContent( + modifier: Modifier = Modifier, + entry: SseConnection, + events: List, + listState: LazyListState, + showNavigator: Boolean, + debouncedQuery: String, + matches: List, + currentMatchIndex: Int, + onPreviousMatch: () -> Unit, + onNextMatch: () -> Unit, +) { + Column(modifier = modifier) { + if (showNavigator) { + SseSearchNavigatorBar( + matchCount = matches.size, + currentIndex = currentMatchIndex, + onPrevious = onPreviousMatch, + onNext = onNextMatch, + ) + HorizontalDivider() + } + ScrollToBottomChip( listState = listState, - modifier = Modifier.fillMaxSize().padding(padding), + modifier = Modifier.fillMaxSize(), ) { LazyColumn( state = listState, modifier = Modifier.fillMaxSize(), ) { - // Connection info header item(key = "header") { SseConnectionInfoHeader( modifier = Modifier.fillMaxWidth(), @@ -139,28 +226,158 @@ internal fun SseDetailScreenView( ) } - // History cleared banner if (entry.historyCleared) { item(key = "history_cleared") { SseHistoryClearedBanner() } } - // Events - items(events, key = { it.id }) { event -> + 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)) } } } } } +@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) + } + }, + ) +} + +@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, 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 bef20a7..6237c91 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/commonMain/kotlin/dev/skymansandy/wiretap/ui/theme/WiretapColors.kt b/wiretap-core/src/commonMain/kotlin/dev/skymansandy/wiretap/ui/theme/WiretapColors.kt index e1a914e..92115f0 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) 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 0000000..f663327 --- /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 0000000..c863cb6 --- /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, +) 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 a8038f5..9b9d39e 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", 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 f05f32c..e369cba 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, +) 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 b05ccaa..0a1fd85 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 8a8a396..b029e64 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 }