From d4163217d5e32cc9d31a1ab4655c2507576aae38 Mon Sep 17 00:00:00 2001 From: Florent CHAMPIGNY Date: Tue, 16 Dec 2025 21:29:00 +0100 Subject: [PATCH] feat: [NETWORK] Share markdown (#462) Co-authored-by: Florent Champigny --- .../flocondesktop/common/di/CommmonModule.kt | 5 ++ .../common/ui/JsonPrettyPrinter.kt | 9 +++ .../network/detail/NetworkDetailAction.kt | 2 + .../network/detail/NetworkDetailDelegate.kt | 12 +++ .../network/detail/NetworkDetailViewModel.kt | 6 +- .../network/detail/view/NetworkDetailView.kt | 14 ++++ .../features/network/list/NetworkViewModel.kt | 12 +++ .../network/list/model/NetworkAction.kt | 2 + .../network/list/view/NetworkItemView.kt | 4 + .../network/list/view/NetworkScreen.kt | 10 +++ .../openflocon/domain/common/JsonFormatter.kt | 5 ++ .../io/github/openflocon/domain/network/DI.kt | 3 + .../GetNetworkCallAsMarkdownUseCase.kt | 74 +++++++++++++++++++ 13 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/common/JsonFormatter.kt create mode 100644 FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/GetNetworkCallAsMarkdownUseCase.kt diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/di/CommmonModule.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/di/CommmonModule.kt index d8214450..333dcfc4 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/di/CommmonModule.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/di/CommmonModule.kt @@ -5,6 +5,8 @@ import io.github.openflocon.flocondesktop.common.coroutines.closeable.CloseableD import io.github.openflocon.flocondesktop.common.coroutines.closeable.CloseableScoped import io.github.openflocon.flocondesktop.common.coroutines.dispatcherprovider.DispatcherProviderImpl import io.github.openflocon.flocondesktop.common.db.roomModule +import io.github.openflocon.flocondesktop.common.ui.JsonFormatterImpl +import io.github.openflocon.domain.common.JsonFormatter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import org.koin.core.module.dsl.bind @@ -18,6 +20,9 @@ val commonModule = singleOf(::DispatcherProviderImpl) { bind() } + singleOf(::JsonFormatterImpl) { + bind() + } single { val dispatcherProvider = get() CoroutineScope(dispatcherProvider.data + SupervisorJob()) // the application scope diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/ui/JsonPrettyPrinter.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/ui/JsonPrettyPrinter.kt index a6a139cf..1723a4c3 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/ui/JsonPrettyPrinter.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/ui/JsonPrettyPrinter.kt @@ -1,6 +1,7 @@ package io.github.openflocon.flocondesktop.common.ui import co.touchlab.kermit.Logger +import io.github.openflocon.domain.common.JsonFormatter import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -37,3 +38,11 @@ object JsonPrettyPrinter { } } } + + +class JsonFormatterImpl : JsonFormatter { + override fun toPrettyJson(text: String) : String { + return JsonPrettyPrinter.prettyPrint(text) + } + +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailAction.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailAction.kt index 3f7e8fc0..a667734e 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailAction.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailAction.kt @@ -14,4 +14,6 @@ sealed interface NetworkDetailAction { data class Request(val item: NetworkDetailViewState) : OpenBodyExternally data class Response(val item: NetworkDetailViewState.Response.Success) : OpenBodyExternally } + + data object ShareAsMarkdown : NetworkDetailAction } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailDelegate.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailDelegate.kt index 533c816f..b980fc5e 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailDelegate.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailDelegate.kt @@ -6,6 +6,7 @@ import io.github.openflocon.domain.common.DispatcherProvider import io.github.openflocon.domain.feedback.FeedbackDisplayer import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel import io.github.openflocon.domain.network.usecase.DecodeJwtTokenUseCase +import io.github.openflocon.domain.network.usecase.GetNetworkCallAsMarkdownUseCase import io.github.openflocon.domain.network.usecase.ObserveNetworkRequestsByIdUseCase import io.github.openflocon.flocondesktop.common.coroutines.closeable.CloseableDelegate import io.github.openflocon.flocondesktop.common.coroutines.closeable.CloseableScoped @@ -42,6 +43,7 @@ class NetworkDetailDelegate( KoinComponent { private val openBodyDelegate: OpenBodyDelegate by inject() + private val getNetworkCallAsMarkdownUseCase: GetNetworkCallAsMarkdownUseCase by inject() private val requestId = MutableStateFlow("") @@ -88,6 +90,7 @@ class NetworkDetailDelegate( is NetworkDetailAction.JsonDetail -> onJsonDetail(action) is NetworkDetailAction.OpenBodyExternally.Request -> openBodyDelegate.openBodyExternally(action.item) is NetworkDetailAction.OpenBodyExternally.Response -> openBodyDelegate.openBodyExternally(action.item) + is NetworkDetailAction.ShareAsMarkdown -> copyAsMarkdown(requestId.value) } } @@ -107,4 +110,13 @@ class NetworkDetailDelegate( onJsonDetail(NetworkDetailAction.JsonDetail(json = it)) } } + + private fun copyAsMarkdown(requestId: String) { + coroutineScope.launch { + getNetworkCallAsMarkdownUseCase(requestId)?.let { + copyToClipboard(it) + feedbackDisplayer.displayMessage("Markdown copied to clipboard") + } + } + } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailViewModel.kt index c0575e45..5ad01b13 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailViewModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/NetworkDetailViewModel.kt @@ -1,13 +1,14 @@ package io.github.openflocon.flocondesktop.features.network.detail import androidx.lifecycle.ViewModel +import io.github.openflocon.domain.feedback.FeedbackDisplayer import org.koin.core.component.KoinComponent +import org.koin.core.component.inject class NetworkDetailViewModel( requestId: String, private val delegate: NetworkDetailDelegate -) : ViewModel(), - KoinComponent { +) : ViewModel(delegate) { val uiState = delegate.uiState @@ -18,4 +19,5 @@ class NetworkDetailViewModel( fun onAction(action: NetworkDetailAction) { delegate.onAction(action) } + } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/view/NetworkDetailView.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/view/NetworkDetailView.kt index e1799637..ccd7f83d 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/view/NetworkDetailView.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/view/NetworkDetailView.kt @@ -2,8 +2,10 @@ package io.github.openflocon.flocondesktop.features.network.detail.view import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -17,6 +19,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.outlined.CopyAll import androidx.compose.material.icons.outlined.OpenInFull +import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -95,6 +98,17 @@ fun NetworkDetailContent( .verticalScroll(scrollState) .padding(vertical = 8.dp, horizontal = 4.dp), ) { + Row( + modifier = Modifier.fillMaxSize() + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.End + ) { + FloconIconButton( + tooltip = "Share as Markdown", + imageVector = Icons.Outlined.Share, + onClick = { onAction(NetworkDetailAction.ShareAsMarkdown) } + ) + } Request( modifier = Modifier .fillMaxWidth(), diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/NetworkViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/NetworkViewModel.kt index d77e40ad..0fa8afc9 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/NetworkViewModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/NetworkViewModel.kt @@ -21,6 +21,7 @@ import io.github.openflocon.domain.network.models.NetworkFilterDomainModel.Filte import io.github.openflocon.domain.network.models.NetworkTextFilterColumns import io.github.openflocon.domain.network.usecase.ExportNetworkCallsToCsvUseCase import io.github.openflocon.domain.network.usecase.GenerateCurlCommandUseCase +import io.github.openflocon.domain.network.usecase.GetNetworkCallAsMarkdownUseCase import io.github.openflocon.domain.network.usecase.GetNetworkRequestsUseCase import io.github.openflocon.domain.network.usecase.ImportNetworkCallsFromCsvUseCase import io.github.openflocon.domain.network.usecase.ObserveNetworkRequestsByIdUseCase @@ -98,6 +99,7 @@ class NetworkViewModel( private val feedbackDisplayer: FeedbackDisplayer by inject() private val exportNetworkCallsToCsv: ExportNetworkCallsToCsvUseCase by inject() private val replayNetworkCallUseCase: ReplayNetworkCallUseCase by inject() + private val getNetworkCallAsMarkdownUseCase: GetNetworkCallAsMarkdownUseCase by inject() private val contentState = MutableStateFlow( ContentUiState( @@ -276,6 +278,16 @@ class NetworkViewModel( NetworkAction.DeleteSelection -> onDeleteSelection() is NetworkAction.DoubleClicked -> onDoubleClicked(action) NetworkAction.OpenDeepSearch -> navigationState.navigate(NetworkRoutes.DeepSearch) + is NetworkAction.ShareAsMarkdown -> onShareAsMarkdown(action) + } + } + + private fun onShareAsMarkdown(action: NetworkAction.ShareAsMarkdown) { + viewModelScope.launch(dispatcherProvider.viewModel) { + getNetworkCallAsMarkdownUseCase(action.item.uuid)?.let { + copyToClipboard(it) + feedbackDisplayer.displayMessage("Markdown copied to clipboard") + } } } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkAction.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkAction.kt index 719b4409..7c10b1ad 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkAction.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/model/NetworkAction.kt @@ -58,6 +58,8 @@ sealed interface NetworkAction { val itemIdToSelect: String, ) : NetworkAction + data class ShareAsMarkdown(val item: NetworkItemViewState) : NetworkAction + data class Down( val itemIdToSelect: String, ) : NetworkAction diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/view/NetworkItemView.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/view/NetworkItemView.kt index b50fb434..f8055dd7 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/view/NetworkItemView.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/view/NetworkItemView.kt @@ -237,6 +237,10 @@ private fun contextualActions( label = "Replay", onClick = { onActionCallback(NetworkAction.Replay(state)) } ) + item( + label = "Share as Markdown", + onClick = { onActionCallback(NetworkAction.ShareAsMarkdown(state)) } + ) } item(label = "Select Item", onClick = { onActionCallback(NetworkAction.SelectLine(state.uuid, selected = true)) }) separator() diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/view/NetworkScreen.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/view/NetworkScreen.kt index 8f78337d..387c0f7b 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/view/NetworkScreen.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/view/NetworkScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.ScreenSearchDesktop import androidx.compose.material.icons.outlined.CleaningServices import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.ImportExport import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.Search @@ -218,6 +219,15 @@ fun NetworkScreen( imageVector = Icons.Outlined.SignalWifiStatusbarConnectedNoInternet4 ) } + FloconIconToggleButton( + value = uiState.filterState.displayOldSessions, + tooltip = "Display old sessions", + onValueChange = { onAction(NetworkAction.UpdateDisplayOldSessions(it)) } + ) { + FloconIcon( + imageVector = Icons.Outlined.History + ) + } FloconIconButton( imageVector = Icons.Outlined.Delete, onClick = { onAction(NetworkAction.Reset) } diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/common/JsonFormatter.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/common/JsonFormatter.kt new file mode 100644 index 00000000..51c8b839 --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/common/JsonFormatter.kt @@ -0,0 +1,5 @@ +package io.github.openflocon.domain.common + +interface JsonFormatter { + fun toPrettyJson(text: String) : String +} \ No newline at end of file diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/DI.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/DI.kt index f36dc673..d4e06570 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/DI.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/DI.kt @@ -3,6 +3,7 @@ package io.github.openflocon.domain.network import io.github.openflocon.domain.network.usecase.DecodeJwtTokenUseCase import io.github.openflocon.domain.network.usecase.ExportNetworkCallsToCsvUseCase import io.github.openflocon.domain.network.usecase.GenerateCurlCommandUseCase +import io.github.openflocon.domain.network.usecase.GetNetworkCallAsMarkdownUseCase import io.github.openflocon.domain.network.usecase.GetNetworkFilterUseCase import io.github.openflocon.domain.network.usecase.GetNetworkRequestsUseCase import io.github.openflocon.domain.network.usecase.ObserveNetworkFilterUseCase @@ -63,6 +64,8 @@ internal val networkModule = module { factoryOf(::ObserveNetworkWebsocketIdsUseCase) factoryOf(::SendNetworkWebsocketMockUseCase) // bad quality + factoryOf(::GetNetworkCallAsMarkdownUseCase) + factoryOf(::ObserveNetworkBadQualityUseCase) factoryOf(::SaveNetworkBadQualityUseCase) factoryOf(::DeleteBadQualityUseCase) diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/GetNetworkCallAsMarkdownUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/GetNetworkCallAsMarkdownUseCase.kt new file mode 100644 index 00000000..793de49a --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/GetNetworkCallAsMarkdownUseCase.kt @@ -0,0 +1,74 @@ +package io.github.openflocon.domain.network.usecase + +import io.github.openflocon.domain.common.JsonFormatter +import io.github.openflocon.domain.device.usecase.GetCurrentDeviceIdAndPackageNameUseCase +import io.github.openflocon.domain.network.models.responseBody +import io.github.openflocon.domain.network.models.responseByteSizeFormatted +import io.github.openflocon.domain.network.models.responseHeaders +import io.github.openflocon.domain.network.repository.NetworkRepository +import kotlinx.coroutines.flow.firstOrNull + +class GetNetworkCallAsMarkdownUseCase( + private val networkRepository: NetworkRepository, + private val jsonFormatter: JsonFormatter, + private val getCurrentDeviceIdAndPackageNameUseCase: GetCurrentDeviceIdAndPackageNameUseCase, +) { + suspend operator fun invoke(callId: String): String? { + val deviceIdAndPackageName = getCurrentDeviceIdAndPackageNameUseCase() ?: return null + val call = networkRepository.observeRequest( + deviceIdAndPackageName = deviceIdAndPackageName, + requestId = callId + ).firstOrNull() ?: return null + + + return buildString { + appendLine("### ${call.request.method} ${call.request.url}") + appendLine() + appendLine("**Status**: ${call.response?.statusFormatted ?: "Pending"}") + appendLine("**Time**: ${call.request.startTimeFormatted}") + appendLine("**Duration**: ${call.response?.durationFormatted ?: "-"}") + appendLine("**Size**: ${call.responseByteSizeFormatted() ?: "-"}") + appendLine() + + appendLine("#### Request Headers") + appendLine("```") + call.request.headers.forEach { (key, value) -> + appendLine("$key: $value") + } + appendLine("```") + appendLine() + + if (!call.request.body.isNullOrBlank()) { + appendLine("#### Request Body") + appendLine("```json") + appendLine(jsonFormatter.toPrettyJson(call.request.body)) + appendLine("```") + appendLine() + } + + if (call.response != null) { + appendLine("#### Response Headers") + appendLine("```") + call.responseHeaders()?.forEach { (key, value) -> + appendLine("$key: $value") + } + appendLine("```") + appendLine() + + if (!call.responseBody().isNullOrBlank()) { + appendLine("#### Response Body") + appendLine("```json") + appendLine(call.responseBody()?.let { + jsonFormatter.toPrettyJson(it) + } ) + appendLine("```") + } else if (call.response is io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel.Response.Failure) { + appendLine("#### Error") + appendLine("```") + appendLine((call.response as io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel.Response.Failure).issue) + appendLine("```") + } + } + } + } +}