mirror of
https://github.com/openflocon/Flocon.git
synced 2026-05-06 03:45:34 +00:00
Feat network open body (#359)
Co-authored-by: Florent Champigny <florent@bere.al>
This commit is contained in:
parent
d50f4012d6
commit
73b67ca646
9 changed files with 236 additions and 58 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue