feat: [NETWORK] Share markdown (#462)
Some checks failed
docs / deploy (push) Has been cancelled

Co-authored-by: Florent Champigny <florent@bere.al>
This commit is contained in:
Florent CHAMPIGNY 2025-12-16 21:29:00 +01:00 committed by GitHub
parent 0a20ed44f6
commit d4163217d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 156 additions and 2 deletions

View file

@ -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<DispatcherProvider>()
}
singleOf(::JsonFormatterImpl) {
bind<JsonFormatter>()
}
single {
val dispatcherProvider = get<DispatcherProvider>()
CoroutineScope(dispatcherProvider.data + SupervisorJob()) // the application scope

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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")
}
}
}
}

View file

@ -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)
}
}

View file

@ -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(),

View file

@ -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")
}
}
}

View file

@ -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

View file

@ -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()

View file

@ -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) }

View file

@ -0,0 +1,5 @@
package io.github.openflocon.domain.common
interface JsonFormatter {
fun toPrettyJson(text: String) : String
}

View file

@ -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)

View file

@ -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("```")
}
}
}
}
}