diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/utils/OpenFile.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/utils/OpenFile.kt index 842f5b44..1f11539a 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/utils/OpenFile.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/common/utils/OpenFile.kt @@ -26,6 +26,11 @@ sealed interface OpenFileError { } object OpenFile { + + fun isSupported(): Boolean { + return Desktop.isDesktopSupported() + } + fun openFileOnDesktop(path: String): Either { val file = File(path) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/NetworkUiModule.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/NetworkUiModule.kt index af8f1a0c..c4ef7de9 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/NetworkUiModule.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/NetworkUiModule.kt @@ -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) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/mapper/NetworkDetailUiMapper.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/mapper/NetworkDetailUiMapper.kt index 0a42fd4e..efee8a00 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/mapper/NetworkDetailUiMapper.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/mapper/NetworkDetailUiMapper.kt @@ -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() +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/model/NetworkDetailViewState.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/model/NetworkDetailViewState.kt index e614ecff..6ff0339a 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/model/NetworkDetailViewState.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/detail/model/NetworkDetailViewState.kt @@ -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?, 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?, ) : Response 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 d4e0786e..5fea0c3f 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 @@ -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 = {}, 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 fa134757..0aac4344 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 @@ -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() { diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/delegate/OpenBodyDelegate.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/delegate/OpenBodyDelegate.kt new file mode 100644 index 00000000..e0ac5e24 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/delegate/OpenBodyDelegate.kt @@ -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 { + return openAndWriteBody( + prefix = "request_body_", + body = request.requestBody + ) + } + + fun openBodyExternally(response: NetworkDetailViewState.Response.Success) : Either { + 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 { + 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(" "xml" + + trimmed.contains(" "html" + else -> "txt" + } + } +} 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 b09631e8..b76b2ece 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 @@ -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, diff --git a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconIconButton.kt b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconIconButton.kt index 315c2dd2..90a1c893 100644 --- a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconIconButton.kt +++ b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconIconButton.kt @@ -121,23 +121,8 @@ fun FloconIconToggleButton( } ) - TooltipArea( - tooltip = { - if (tooltip != null) { - Text( - text = tooltip, - style = FloconTheme.typography.labelSmall, - modifier = Modifier - .clip(FloconTheme.shapes.small) - .background(FloconTheme.colorPalette.primary) - .padding(vertical = 2.dp, horizontal = 4.dp) - ) - } - }, - delayMillis = 100, - tooltipPlacement = TooltipPlacement.ComponentRect( - offset = DpOffset(x = 0.dp, y = 2.dp) - ) + WithTooltip( + tooltip = tooltip, ) { Box( contentAlignment = Alignment.Center, @@ -163,6 +148,36 @@ fun FloconIconToggleButton( } } +@Composable +fun WithTooltip( + tooltip: String?, + tooltipPlacement: TooltipPlacement = TooltipPlacement.ComponentRect( + offset = DpOffset(x = 0.dp, y = 2.dp) + ), + content: @Composable () -> Unit +) { + if(tooltip != null) { + TooltipArea( + tooltip = { + Text( + text = tooltip, + style = FloconTheme.typography.labelSmall, + modifier = Modifier + .clip(FloconTheme.shapes.small) + .background(FloconTheme.colorPalette.primary) + .padding(vertical = 2.dp, horizontal = 4.dp) + ) + }, + delayMillis = 100, + tooltipPlacement = tooltipPlacement, + ) { + content() + } + } else { + content() + } +} + @Composable fun FloconSmallIconButton( onClick: () -> Unit, @@ -188,16 +203,19 @@ fun FloconIconButton( imageVector: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier, + tooltip: String? = null, enabled: Boolean = true ) { - FloconIconButton( - onClick = onClick, - enabled = enabled, - modifier = modifier - ) { - FloconIcon( - imageVector = imageVector - ) + WithTooltip(tooltip) { + FloconIconButton( + onClick = onClick, + enabled = enabled, + modifier = modifier + ) { + FloconIcon( + imageVector = imageVector + ) + } } }