Feat network open body (#359)

Co-authored-by: Florent Champigny <florent@bere.al>
This commit is contained in:
Florent CHAMPIGNY 2025-10-17 17:21:25 +02:00 committed by GitHub
parent d50f4012d6
commit 73b67ca646
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 236 additions and 58 deletions

View file

@ -26,6 +26,11 @@ sealed interface OpenFileError {
}
object OpenFile {
fun isSupported(): Boolean {
return Desktop.isDesktopSupported()
}
fun openFileOnDesktop(path: String): Either<OpenFileError, Unit> {
val file = File(path)

View file

@ -3,6 +3,7 @@ package io.github.openflocon.flocondesktop.features.network
import io.github.openflocon.flocondesktop.features.network.badquality.BadQualityNetworkViewModel
import io.github.openflocon.flocondesktop.features.network.list.NetworkViewModel
import io.github.openflocon.flocondesktop.features.network.list.delegate.HeaderDelegate
import io.github.openflocon.flocondesktop.features.network.list.delegate.OpenBodyDelegate
import io.github.openflocon.flocondesktop.features.network.mock.NetworkMocksViewModel
import io.github.openflocon.flocondesktop.features.network.mock.processor.ExportMocksProcessor
import io.github.openflocon.flocondesktop.features.network.mock.processor.ImportMocksProcessor
@ -16,6 +17,7 @@ internal val networkModule = module {
viewModelOf(::NetworkViewModel)
factoryOf(::MessagesServerDelegate)
factoryOf(::HeaderDelegate)
factoryOf(::OpenBodyDelegate)
viewModelOf(::NetworkMocksViewModel)
factoryOf(::ExportMocksProcessor)

View file

@ -2,10 +2,12 @@ package io.github.openflocon.flocondesktop.features.network.detail.mapper
import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel
import io.github.openflocon.flocondesktop.common.ui.JsonPrettyPrinter
import io.github.openflocon.flocondesktop.common.utils.OpenFile
import io.github.openflocon.flocondesktop.features.network.detail.model.NetworkDetailHeaderUi
import io.github.openflocon.flocondesktop.features.network.detail.model.NetworkDetailViewState
import io.github.openflocon.flocondesktop.features.network.detail.model.NetworkDetailViewState.Method.Http
import io.github.openflocon.flocondesktop.features.network.detail.model.NetworkDetailViewState.Method.MethodName
import io.github.openflocon.flocondesktop.features.network.list.delegate.OpenBodyDelegate
import io.github.openflocon.flocondesktop.features.network.list.mapper.getMethodUi
import io.github.openflocon.flocondesktop.features.network.list.mapper.loadingStatus
import io.github.openflocon.flocondesktop.features.network.list.mapper.toGraphQlNetworkStatusUi
@ -16,7 +18,9 @@ import io.github.openflocon.flocondesktop.features.network.list.model.NetworkMet
import io.github.openflocon.flocondesktop.features.network.list.model.NetworkStatusUi
import io.github.openflocon.library.designsystem.common.isImageUrl
fun toDetailUi(request: FloconNetworkCallDomainModel): NetworkDetailViewState =
fun toDetailUi(
request: FloconNetworkCallDomainModel,
): NetworkDetailViewState =
NetworkDetailViewState(
callId = request.callId,
fullUrl = request.request.url,
@ -29,6 +33,9 @@ fun toDetailUi(request: FloconNetworkCallDomainModel): NetworkDetailViewState =
// request
requestBodyTitle = requestBodyTitle(request),
requestBody = httpBodyToUi(request.request.body),
requestBodyIsNotBlank = request.request.body.isNullOrBlank().not(),
canOpenRequestBody = canOpenExternal(request.request.body),
// headers.,
requestHeaders = toNetworkHeadersUi(request.request.headers),
requestSize = request.request.byteSizeFormatted,
// response
@ -41,6 +48,8 @@ fun toDetailUi(request: FloconNetworkCallDomainModel): NetworkDetailViewState =
is FloconNetworkCallDomainModel.Response.Success -> NetworkDetailViewState.Response.Success(
body = httpBodyToUi(it.body),
size = it.byteSizeFormatted,
canOpenResponseBody = canOpenExternal(it.body),
responseBodyIsNotBlank = it.body.isNullOrBlank().not(),
headers = toNetworkHeadersUi(it.headers),
)
@ -157,3 +166,7 @@ fun toDetailMethodUi(request: FloconNetworkCallDomainModel): NetworkDetailViewSt
NetworkMethodUi.WebSocket
)
}
private fun canOpenExternal(body: String?) : Boolean {
return body != null && body.isNotBlank() && OpenFile.isSupported()
}

View file

@ -20,6 +20,9 @@ data class NetworkDetailViewState(
// request
val requestBodyTitle: String,
val requestBody: String,
val requestBodyIsNotBlank: Boolean,
val canOpenRequestBody: Boolean,
val requestSize: String,
val requestHeaders: List<NetworkDetailHeaderUi>?,
val imageUrl: String?, // filled only if it's an image url
@ -31,6 +34,8 @@ data class NetworkDetailViewState(
@Immutable
data class Success(
val body: String,
val responseBodyIsNotBlank: Boolean,
val canOpenResponseBody: Boolean,
val size: String,
val headers: List<NetworkDetailHeaderUi>?,
) : Response

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.material3.Text
@ -152,7 +153,7 @@ private fun Request(
color = FloconTheme.colorPalette.onSecondary,
modifier = Modifier.weight(2f)
.background(
color = FloconTheme.colorPalette.secondary.copy(alpha = 0.8f),
color = FloconTheme.colorPalette.primary.copy(alpha = 0.8f),
shape = RoundedCornerShape(4.dp),
)
.padding(horizontal = 8.dp, vertical = 6.dp),
@ -271,21 +272,40 @@ private fun Request(
title = state.requestBodyTitle,
initialValue = true,
actions = {
FloconIconButton(
imageVector = Icons.Outlined.OpenInFull,
onClick = {
onAction(
NetworkAction.JsonDetail(
state.callId + "request",
state.requestBody,
),
)
}
)
FloconIconButton(
imageVector = Icons.Outlined.CopyAll,
onClick = { onAction(NetworkAction.CopyText(state.requestBody)) }
)
if(state.requestBodyIsNotBlank) {
FloconIconButton(
tooltip = "View in app",
imageVector = Icons.Outlined.OpenInFull,
onClick = {
onAction(
NetworkAction.JsonDetail(
state.callId + "request",
state.requestBody,
),
)
}
)
}
if(state.canOpenRequestBody) {
FloconIconButton(
tooltip = "Open in external editor",
imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
onClick = {
onAction(
NetworkAction.OpenBodyExternally.Request(
state,
)
)
}
)
}
if(state.requestBodyIsNotBlank) {
FloconIconButton(
tooltip = "Copy",
imageVector = Icons.Outlined.CopyAll,
onClick = { onAction(NetworkAction.CopyText(state.requestBody)) }
)
}
},
modifier = Modifier.fillMaxWidth()
) {
@ -415,21 +435,40 @@ private fun Response(
title = "Response - Body",
initialValue = true,
actions = {
FloconIconButton(
imageVector = Icons.Outlined.OpenInFull,
onClick = {
onAction(
NetworkAction.JsonDetail(
state.callId + "response",
response.body,
),
)
}
)
FloconIconButton(
imageVector = Icons.Outlined.CopyAll,
onClick = { onAction(NetworkAction.CopyText(response.body)) }
)
if(response.responseBodyIsNotBlank) {
FloconIconButton(
tooltip = "View body in app",
imageVector = Icons.Outlined.OpenInFull,
onClick = {
onAction(
NetworkAction.JsonDetail(
state.callId + "response",
response.body,
),
)
}
)
}
if(response.canOpenResponseBody) {
FloconIconButton(
tooltip = "Open in external editor",
imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
onClick = {
onAction(
NetworkAction.OpenBodyExternally.Response(
response,
)
)
}
)
}
if(response.responseBodyIsNotBlank) {
FloconIconButton(
tooltip = "Copy",
imageVector = Icons.Outlined.CopyAll,
onClick = { onAction(NetworkAction.CopyText(response.body)) }
)
}
},
modifier = Modifier.fillMaxWidth()
) {
@ -532,6 +571,8 @@ private fun NetworkDetailViewPreview() {
}
""".trimIndent(),
size = "0kb",
canOpenResponseBody = true,
responseBodyIsNotBlank = true,
headers =
listOf(
previewNetworkDetailHeaderUi(),
@ -543,6 +584,8 @@ private fun NetworkDetailViewPreview() {
),
graphQlSection = null,
imageUrl = null,
canOpenRequestBody = true,
requestBodyIsNotBlank = true,
),
modifier = Modifier.padding(16.dp), // Padding pour la preview
onAction = {},

View file

@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import co.touchlab.kermit.Logger
import io.github.openflocon.domain.common.DispatcherProvider
import io.github.openflocon.domain.common.combines
import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceIdAndPackageNameUseCase
@ -33,11 +34,13 @@ import io.github.openflocon.domain.network.usecase.mocks.ObserveNetworkMocksUseC
import io.github.openflocon.domain.network.usecase.mocks.ObserveNetworkWebsocketIdsUseCase
import io.github.openflocon.domain.network.usecase.settings.ObserveNetworkSettingsUseCase
import io.github.openflocon.domain.network.usecase.settings.UpdateNetworkSettingsUseCase
import io.github.openflocon.flocondesktop.common.utils.OpenFile
import io.github.openflocon.flocondesktop.features.network.body.model.ContentUiState
import io.github.openflocon.flocondesktop.features.network.body.model.MockDisplayed
import io.github.openflocon.flocondesktop.features.network.detail.mapper.toDetailUi
import io.github.openflocon.flocondesktop.features.network.detail.model.NetworkDetailViewState
import io.github.openflocon.flocondesktop.features.network.list.delegate.HeaderDelegate
import io.github.openflocon.flocondesktop.features.network.list.delegate.OpenBodyDelegate
import io.github.openflocon.flocondesktop.features.network.list.mapper.toDomain
import io.github.openflocon.flocondesktop.features.network.list.mapper.toUi
import io.github.openflocon.flocondesktop.features.network.list.model.NetworkAction
@ -62,6 +65,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
class NetworkViewModel(
observeNetworkRequestsUseCase: ObserveNetworkRequestsUseCase,
@ -83,6 +87,7 @@ class NetworkViewModel(
private val observeNetworkSettingsUseCase: ObserveNetworkSettingsUseCase,
private val updateNetworkSettingsUseCase: UpdateNetworkSettingsUseCase,
private val observeNetworkWebsocketIdsUseCase: ObserveNetworkWebsocketIdsUseCase,
private val openBodyDelegate: OpenBodyDelegate,
) : ViewModel(headerDelegate) {
private val contentState = MutableStateFlow(
@ -259,7 +264,9 @@ class NetworkViewModel(
is NetworkAction.UpdateDisplayOldSessions -> toggleDisplayOldSessions(action)
NetworkAction.OpenWebsocketMocks -> openWebsocketMocks()
NetworkAction.CloseWebsocketMocks -> contentState.update { it.copy(websocketMocksDisplayed = false) }
}
is NetworkAction.OpenBodyExternally.Request -> openBodyDelegate.openBodyExternally(action.item)
is NetworkAction.OpenBodyExternally.Response -> openBodyDelegate.openBodyExternally(action.item)
}
}
private fun onClearSession() {

View file

@ -0,0 +1,79 @@
package io.github.openflocon.flocondesktop.features.network.list.delegate
import co.touchlab.kermit.Logger
import io.github.openflocon.domain.common.Either
import io.github.openflocon.domain.common.Failure
import io.github.openflocon.domain.common.failure
import io.github.openflocon.flocondesktop.common.utils.OpenFile
import io.github.openflocon.flocondesktop.common.utils.OpenFileError
import io.github.openflocon.flocondesktop.features.network.detail.model.NetworkDetailViewState
import java.io.File
class OpenBodyDelegate {
fun openBodyExternally(request: NetworkDetailViewState) : Either<OpenFileError, Unit> {
return openAndWriteBody(
prefix = "request_body_",
body = request.requestBody
)
}
fun openBodyExternally(response: NetworkDetailViewState.Response.Success) : Either<OpenFileError, Unit> {
return openAndWriteBody(
prefix = "response_body_",
body = response.body
)
}
/**
* Centralizes the logic for writing the body to a temp file and opening it.
*/
private fun openAndWriteBody(prefix: String, body: String): Either<OpenFileError, Unit> {
if (body.isBlank()) {
Logger.w("⚠️ Body is empty, nothing to open.")
return OpenFileError.FileNotFound.failure()
}
val extension = detectBodyExtension(body)
val tempFile = runCatching {
File.createTempFile(prefix, ".$extension")
}.getOrElse { e ->
Logger.e("❌ Failed to create temporary file: ${e.message}")
return OpenFileError.IoError(e).failure()
}
runCatching {
tempFile.writeText(body)
Logger.i("📝 Body written to temporary file: ${tempFile.absolutePath}")
}.onFailure { e ->
Logger.e("💥 Error while writing file: ${e.message}")
return OpenFileError.IoError(e).failure()
}
return OpenFile.openFileOnDesktop(tempFile.absolutePath)
.alsoFailure { error ->
Logger.e("❌ Failed to open file: ${error.message}")
}.alsoSuccess {
Logger.i("✅ File opened successfully: ${tempFile.absolutePath}")
}
}
/**
* Tries to infer file extension based on content.
*/
private fun detectBodyExtension(body: String): String {
val trimmed = body.trimStart()
return when {
trimmed.startsWith("{") || trimmed.startsWith("[") -> "json"
trimmed.startsWith("<") && trimmed.contains("</") && !trimmed.contains(
"<html",
ignoreCase = true
) -> "xml"
trimmed.contains("<html", ignoreCase = true) -> "html"
else -> "txt"
}
}
}

View file

@ -1,5 +1,6 @@
package io.github.openflocon.flocondesktop.features.network.list.model
import io.github.openflocon.flocondesktop.features.network.detail.model.NetworkDetailViewState
import io.github.openflocon.flocondesktop.features.network.list.model.header.OnFilterAction
import io.github.openflocon.flocondesktop.features.network.list.model.header.columns.NetworkColumnsTypeUiModel
@ -57,6 +58,11 @@ sealed interface NetworkAction {
val itemIdToSelect: String,
) : NetworkAction
sealed interface OpenBodyExternally : NetworkAction {
data class Request(val item: NetworkDetailViewState) : OpenBodyExternally
data class Response(val item: NetworkDetailViewState.Response.Success) : OpenBodyExternally
}
sealed interface HeaderAction : NetworkAction {
data class ClickOnSort(
val type: NetworkColumnsTypeUiModel,