From e702440d52d5380b1aa5ff029de843db4ece64ce Mon Sep 17 00:00:00 2001 From: Florent CHAMPIGNY Date: Wed, 10 Dec 2025 21:59:53 +0100 Subject: [PATCH] feat: [NETWORK] deep search (#448) Co-authored-by: Florent Champigny --- .../crashreporter/CrashReporterViewModel.kt | 4 +- .../flocondesktop/features/network/DI.kt | 2 + .../features/network/Navigation.kt | 22 +- .../features/network/list/NetworkViewModel.kt | 14 +- .../network/list/mapper/NetworkUiMapper.kt | 2 +- .../network/list/model/NetworkAction.kt | 2 + .../network/list/view/NetworkItemView.kt | 2 +- .../network/list/view/NetworkScreen.kt | 19 ++ .../network/search/NetworkSearchViewModel.kt | 230 +++++++++++++++++ .../search/model/NetworkSearchUiState.kt | 12 + .../search/view/NetworkSearchScreen.kt | 240 ++++++++++++++++++ .../components/NetworkSearchPreviewView.kt | 212 ++++++++++++++++ .../search/view/components/ScopeChipsView.kt | 48 ++++ .../models/DashboardConfigDataModel.kt | 1 - .../io/github/openflocon/domain/network/DI.kt | 4 + .../models/FloconNetworkCallDomainModel.kt | 12 + .../domain/network/models/SearchScope.kt | 21 ++ .../search/GetAllNetworkRequestsUseCase.kt | 25 ++ .../search/SearchNetworkCallsUseCase.kt | 54 ++++ .../components/FloconVerticalScrollbar.kt | 5 +- .../FloconVerticalScrollbar.desktop.kt | 6 +- .../navigation/FloconNavigationState.kt | 5 + .../openflocon/navigation/FloconRoute.kt | 4 + .../navigation/scene/WindowScene.kt | 25 +- 24 files changed, 955 insertions(+), 16 deletions(-) create mode 100644 FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/NetworkSearchViewModel.kt create mode 100644 FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/model/NetworkSearchUiState.kt create mode 100644 FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/view/NetworkSearchScreen.kt create mode 100644 FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/view/components/NetworkSearchPreviewView.kt create mode 100644 FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/view/components/ScopeChipsView.kt create mode 100644 FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/models/SearchScope.kt create mode 100644 FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/search/GetAllNetworkRequestsUseCase.kt create mode 100644 FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/search/SearchNetworkCallsUseCase.kt diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/crashreporter/CrashReporterViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/crashreporter/CrashReporterViewModel.kt index e6ef124d..4c4dfbaa 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/crashreporter/CrashReporterViewModel.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/crashreporter/CrashReporterViewModel.kt @@ -1,10 +1,10 @@ package io.github.openflocon.flocondesktop.features.crashreporter +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.map -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import flocondesktop.composeapp.generated.resources.Res import flocondesktop.composeapp.generated.resources.copied_to_clipboard import io.github.openflocon.domain.common.DispatcherProvider diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/DI.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/DI.kt index a11c4400..311f509f 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/DI.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/DI.kt @@ -10,6 +10,7 @@ import io.github.openflocon.flocondesktop.features.network.list.delegate.OpenBod 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 +import io.github.openflocon.flocondesktop.features.network.search.NetworkSearchViewModel import io.github.openflocon.flocondesktop.features.network.websocket.NetworkWebsocketMockViewModel import io.github.openflocon.flocondesktop.messages.ui.MessagesServerDelegate import org.koin.core.module.dsl.factoryOf @@ -32,4 +33,5 @@ internal val networkModule = module { viewModelOf(::BadQualityNetworkViewModel) viewModelOf(::NetworkWebsocketMockViewModel) + viewModelOf(::NetworkSearchViewModel) } diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/Navigation.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/Navigation.kt index 8eea440c..834a5bbf 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/Navigation.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/Navigation.kt @@ -2,6 +2,8 @@ package io.github.openflocon.flocondesktop.features.network +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.scene.DialogSceneStrategy import io.github.openflocon.domain.settings.repository.SettingsRepository @@ -11,8 +13,10 @@ import io.github.openflocon.flocondesktop.features.network.body.model.NetworkBod import io.github.openflocon.flocondesktop.features.network.detail.view.NetworkDetailScreen import io.github.openflocon.flocondesktop.features.network.list.view.NetworkScreen import io.github.openflocon.flocondesktop.features.network.mock.list.view.NetworkMocksWindow +import io.github.openflocon.flocondesktop.features.network.search.view.NetworkSearchScreen import io.github.openflocon.navigation.FloconRoute import io.github.openflocon.navigation.PanelRoute +import io.github.openflocon.navigation.WindowRoute import io.github.openflocon.navigation.scene.PanelSceneStrategy import io.github.openflocon.navigation.scene.WindowSceneStrategy import kotlinx.serialization.Serializable @@ -39,6 +43,11 @@ internal sealed interface NetworkRoutes : FloconRoute { val windowKey: String, ) : NetworkRoutes + @Serializable + data object DeepSearch : NetworkRoutes, WindowRoute { + override val singleTopKey = "deepsearch" + } + @Serializable data class JsonDetail( val json: String, @@ -59,7 +68,8 @@ fun EntryProviderScope.networkRoutes() { onPin = { val repository = KoinPlatform.getKoin().get() - repository.networkSettings = repository.networkSettings.copy(pinnedDetails = true) + repository.networkSettings = + repository.networkSettings.copy(pinnedDetails = true) } ) ) { @@ -84,4 +94,14 @@ fun EntryProviderScope.networkRoutes() { body = NetworkBodyDetailUi(text = it.json) ) } + entry( + metadata = WindowSceneStrategy.window( + size = DpSize( + width = 1200.0.dp, + height = 800.0.dp + ) + ) + ) { + NetworkSearchScreen() + } } 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 c8ec44dd..d77e40ad 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 @@ -275,6 +275,7 @@ class NetworkViewModel( NetworkAction.MultiSelect -> onMultiSelect() NetworkAction.DeleteSelection -> onDeleteSelection() is NetworkAction.DoubleClicked -> onDoubleClicked(action) + NetworkAction.OpenDeepSearch -> navigationState.navigate(NetworkRoutes.DeepSearch) } } @@ -502,15 +503,16 @@ class NetworkViewModel( .also { onClearMultiSelect() } } } + @OptIn(ExperimentalUuidApi::class) private fun onDoubleClicked(action: NetworkAction.DoubleClicked) { - navigationState.navigate(NetworkRoutes.WindowDetail( - requestId = action.item.uuid, - windowKey = Uuid.random().toString(), - )) + navigationState.navigate( + NetworkRoutes.WindowDetail( + requestId = action.item.uuid, + windowKey = Uuid.random().toString(), + ) + ) } - - } private fun Map.toDomain(): List = buildList { diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/mapper/NetworkUiMapper.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/mapper/NetworkUiMapper.kt index 12723843..397bcf77 100644 --- a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/mapper/NetworkUiMapper.kt +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/list/mapper/NetworkUiMapper.kt @@ -6,7 +6,7 @@ import io.github.openflocon.domain.network.models.responseByteSizeFormatted import io.github.openflocon.flocondesktop.features.network.list.model.NetworkItemViewState fun FloconNetworkCallDomainModel.toUi( - deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel? + deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel?, ): NetworkItemViewState = NetworkItemViewState( uuid = callId, dateFormatted = request.startTimeFormatted, 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 d081e2f2..719b4409 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 @@ -62,6 +62,8 @@ sealed interface NetworkAction { val itemIdToSelect: String, ) : NetworkAction + data object OpenDeepSearch : NetworkAction + data class DoubleClicked(val item: NetworkItemViewState) : NetworkAction data object ClearMultiSelect : 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 2ce0cecc..b50fb434 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 @@ -1,8 +1,8 @@ package io.github.openflocon.flocondesktop.features.network.list.view import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable 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 7f04c044..8f78337d 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 @@ -18,12 +18,16 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ManageSearch import androidx.compose.material.icons.automirrored.outlined.List +import androidx.compose.material.icons.filled.ManageSearch +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.ImportExport import androidx.compose.material.icons.outlined.PlayCircle +import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.SignalWifiStatusbarConnectedNoInternet4 import androidx.compose.material.icons.outlined.Upgrade import androidx.compose.material.icons.outlined.Upload @@ -48,6 +52,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isMetaPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type @@ -166,6 +171,15 @@ fun NetworkScreen( true } + Key.F -> { + if (event.isMetaPressed) { + onAction(NetworkAction.OpenDeepSearch) + true + } else { + false + } + } + else -> false } } @@ -181,6 +195,11 @@ fun NetworkScreen( ) }, actions = { + FloconIconButton( + imageVector = Icons.AutoMirrored.Filled.ManageSearch, + onClick = { onAction(NetworkAction.OpenDeepSearch) }, + tooltip = "Deep Search" + ) FloconIconToggleButton( value = uiState.filterState.hasMocks, tooltip = "Mocks", diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/NetworkSearchViewModel.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/NetworkSearchViewModel.kt new file mode 100644 index 00000000..7d2bc49d --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/NetworkSearchViewModel.kt @@ -0,0 +1,230 @@ +package io.github.openflocon.flocondesktop.features.network.search + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.github.openflocon.domain.common.DispatcherProvider +import io.github.openflocon.domain.common.combines +import io.github.openflocon.domain.device.usecase.ObserveCurrentDeviceIdAndPackageNameUseCase +import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel +import io.github.openflocon.domain.network.models.SearchScope +import io.github.openflocon.domain.network.models.responseBody +import io.github.openflocon.domain.network.models.responseHeaders +import io.github.openflocon.domain.network.usecase.ObserveNetworkRequestsByIdUseCase +import io.github.openflocon.domain.network.usecase.search.SearchNetworkCallsUseCase +import io.github.openflocon.flocondesktop.features.network.NetworkRoutes +import io.github.openflocon.flocondesktop.features.network.list.mapper.toUi +import io.github.openflocon.flocondesktop.features.network.search.model.NetworkSearchUiState +import io.github.openflocon.library.designsystem.common.asState +import io.github.openflocon.navigation.MainFloconNavigationState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +class NetworkSearchViewModel( + private val searchNetworkCallsUseCase: SearchNetworkCallsUseCase, + private val observeNetworkRequestsByIdUseCase: ObserveNetworkRequestsByIdUseCase, + private val observeCurrentDeviceIdAndPackageNameUseCase: ObserveCurrentDeviceIdAndPackageNameUseCase, + private val dispatcherProvider: DispatcherProvider, + private val navigationState: MainFloconNavigationState, +) : ViewModel(), + KoinComponent { + + private val _query = mutableStateOf("") + val query = _query.asState() + private val selectedScopes = MutableStateFlow(SearchScope.entries.toSet()) + private val loading = MutableStateFlow(false) + + val uiState: StateFlow = combines( + snapshotFlow { _query.value }, + selectedScopes, + observeCurrentDeviceIdAndPackageNameUseCase(), + loading + ).flatMapLatest { (query, scopes, deviceInfo, loading) -> + flow { + val results = searchNetworkCallsUseCase(query, scopes) + emit( + NetworkSearchUiState( + selectedScopes = scopes, + results = results.map { it.toUi(null /* don't check old instance here*/) }, + loading = false + ) + ) + } + } + .flowOn(dispatcherProvider.viewModel) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = NetworkSearchUiState() + ) + + fun onQueryChanged(query: String) { + _query.value = query + loading.value = true + } + + private val _selectedRequestId = MutableStateFlow(null) + val selectedRequestId = _selectedRequestId.asStateFlow() + + val selectedRequest: StateFlow = _selectedRequestId + .flatMapLatest { id -> + if (id == null) flowOf(null) + else observeNetworkRequestsByIdUseCase(id) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = null + ) + + private val _matches = MutableStateFlow>(emptyList()) + val matches = _matches.asStateFlow() + + private val _currentMatchIndex = MutableStateFlow(0) + val currentMatchIndex = _currentMatchIndex.asStateFlow() + + init { + // Calculate matches when query or selected request changes + viewModelScope.launch { + combine(snapshotFlow { _query.value }, selectedRequest) { query, request -> + if (query.isBlank() || request == null) { + emptyList() + } else { + val regex = query.toRegex(RegexOption.IGNORE_CASE) + val matches = mutableListOf() + + // URL + val url = request.request.url + regex.findAll(url).forEach { + matches.add(Match(it.range.first, it.range.last - it.range.first + 1, MatchLocation.Url, url)) + } + + // Method + val method = request.request.method + regex.findAll(method).forEach { + matches.add(Match(it.range.first, it.range.last - it.range.first + 1, MatchLocation.Method, method)) + } + + // Status + val status = request.response?.statusFormatted + if (status != null) { + regex.findAll(status).forEach { + matches.add(Match(it.range.first, it.range.last - it.range.first + 1, MatchLocation.Status, status)) + } + } + + // Request Headers + val reqHeaders = request.request.headers.entries.joinToString("\n") { "${it.key}: ${it.value}" } + regex.findAll(reqHeaders).forEach { + matches.add(Match(it.range.first, it.range.last - it.range.first + 1, MatchLocation.RequestHeaders, reqHeaders)) + } + + // Request Body + val reqBody = request.request.body + if (reqBody != null) { + regex.findAll(reqBody).forEach { + matches.add(Match(it.range.first, it.range.last - it.range.first + 1, MatchLocation.RequestBody, reqBody)) + } + } + + // Response Headers + val resHeaders = request.responseHeaders()?.entries?.joinToString("\n") { "${it.key}: ${it.value}" } + if (resHeaders != null) { + regex.findAll(resHeaders).forEach { + matches.add(Match(it.range.first, it.range.last - it.range.first + 1, MatchLocation.ResponseHeaders, resHeaders)) + } + } + + // Response Body + val resBody = request.responseBody() + if (resBody != null) { + regex.findAll(resBody).forEach { + matches.add(Match(it.range.first, it.range.last - it.range.first + 1, MatchLocation.ResponseBody, resBody)) + } + } + + matches + } + }.collect { + _matches.value = it + _currentMatchIndex.value = 0 + } + } + } + + fun onSelectRequest(requestId: String) { + _selectedRequestId.value = requestId + } + + fun onNextMatch() { + if (_matches.value.isNotEmpty()) { + _currentMatchIndex.update { (it + 1) % _matches.value.size } + } + } + + fun onPrevMatch() { + if (_matches.value.isNotEmpty()) { + _currentMatchIndex.update { + if (it - 1 < 0) _matches.value.lastIndex else it - 1 + } + } + } + + fun onClosePreview() { + _selectedRequestId.value = null + } + + @OptIn(ExperimentalUuidApi::class) + fun onNavigateToDetail(requestId: String) { + navigationState.navigate( + NetworkRoutes.WindowDetail( + requestId, + Uuid.random().toString() + ) + ) + } + + fun onScopeToggled(scope: SearchScope) { + selectedScopes.update { current -> + if (current.contains(scope)) { + current - scope + } else { + current + scope + } + } + loading.value = true + } +} + +@Immutable +data class Match( + val start: Int, + val length: Int, + val location: MatchLocation, + val content: String +) + +enum class MatchLocation(val label: String) { + Url("URL"), + Method("Method"), + Status("Status"), + RequestHeaders("Request Headers"), + RequestBody("Request Body"), + ResponseHeaders("Response Headers"), + ResponseBody("Response Body") +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/model/NetworkSearchUiState.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/model/NetworkSearchUiState.kt new file mode 100644 index 00000000..01d4a4c0 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/model/NetworkSearchUiState.kt @@ -0,0 +1,12 @@ +package io.github.openflocon.flocondesktop.features.network.search.model + +import androidx.compose.runtime.Immutable +import io.github.openflocon.domain.network.models.SearchScope +import io.github.openflocon.flocondesktop.features.network.list.model.NetworkItemViewState + +@Immutable +data class NetworkSearchUiState( + val selectedScopes: Set = SearchScope.entries.toSet(), + val results: List = emptyList(), + val loading: Boolean = false +) diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/view/NetworkSearchScreen.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/view/NetworkSearchScreen.kt new file mode 100644 index 00000000..30b34ad2 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/view/NetworkSearchScreen.kt @@ -0,0 +1,240 @@ +package io.github.openflocon.flocondesktop.features.network.search.view + +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Search +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel +import io.github.openflocon.domain.network.models.SearchScope +import io.github.openflocon.flocondesktop.features.network.list.model.NetworkAction +import io.github.openflocon.flocondesktop.features.network.list.view.NetworkItemView +import io.github.openflocon.flocondesktop.features.network.search.Match +import io.github.openflocon.flocondesktop.features.network.search.NetworkSearchViewModel +import io.github.openflocon.flocondesktop.features.network.search.model.NetworkSearchUiState +import io.github.openflocon.flocondesktop.features.network.search.view.components.NetworkSearchPreviewView +import io.github.openflocon.flocondesktop.features.network.search.view.components.ScopeChipsView +import io.github.openflocon.library.designsystem.FloconTheme +import io.github.openflocon.library.designsystem.components.FloconIcon +import io.github.openflocon.library.designsystem.components.FloconSurface +import io.github.openflocon.library.designsystem.components.FloconTextFieldWithoutM3 +import io.github.openflocon.library.designsystem.components.FloconVerticalScrollbar +import io.github.openflocon.library.designsystem.components.defaultPlaceHolder +import io.github.openflocon.library.designsystem.components.rememberFloconScrollbarAdapter +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun NetworkSearchScreen() { + val viewModel = koinViewModel() + val query by viewModel.query + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val selectedRequestId by viewModel.selectedRequestId.collectAsStateWithLifecycle() + val selectedRequest by viewModel.selectedRequest.collectAsStateWithLifecycle() + val matches by viewModel.matches.collectAsStateWithLifecycle() + val currentMatchIndex by viewModel.currentMatchIndex.collectAsStateWithLifecycle() + + NetworkSearchScreen( + query = query, + uiState = uiState, + selectedRequestId = selectedRequestId, + selectedRequest = selectedRequest, + matches = matches, + currentMatchIndex = currentMatchIndex, + onQueryChanged = viewModel::onQueryChanged, + onNavigateToDetail = viewModel::onNavigateToDetail, + onScopeToggled = viewModel::onScopeToggled, + onSelectRequest = viewModel::onSelectRequest, + onClosePreview = viewModel::onClosePreview, + onNextMatch = viewModel::onNextMatch, + onPrevMatch = viewModel::onPrevMatch, + ) +} + +@Composable +private fun NetworkSearchScreen( + query: String, + onQueryChanged: (String) -> Unit, + onScopeToggled: (SearchScope) -> Unit, + uiState: NetworkSearchUiState, + selectedRequestId: String?, + selectedRequest: FloconNetworkCallDomainModel?, + matches: List, + currentMatchIndex: Int, + onNextMatch: () -> Unit, + onPrevMatch: () -> Unit, + onSelectRequest: (String) -> Unit, + onClosePreview: () -> Unit, + onNavigateToDetail: (String) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + try { + focusRequester.requestFocus() + } catch (t: Throwable) { + t.printStackTrace() + } + } + + FloconSurface( + modifier = Modifier + .fillMaxSize() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + + when (event.key) { + Key.DirectionUp -> { + val index = uiState.results.indexOfFirst { it.uuid == selectedRequestId } + if (index > 0) { + onSelectRequest(uiState.results[index - 1].uuid) + } + true + } + + Key.DirectionDown -> { + val index = uiState.results.indexOfFirst { it.uuid == selectedRequestId } + if (index != -1 && index < uiState.results.lastIndex) { + onSelectRequest(uiState.results[index + 1].uuid) + } else if (index == -1 && uiState.results.isNotEmpty()) { + onSelectRequest(uiState.results[0].uuid) + } + true + } + + Key.DirectionRight -> { + onNextMatch() + true + } + + Key.DirectionLeft -> { + onPrevMatch() + true + } + + else -> false + } + }, + ) { + Column( + modifier = Modifier + .fillMaxSize() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(FloconTheme.colorPalette.primary) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + FloconTextFieldWithoutM3( + value = query, + onValueChange = { + onQueryChanged(it) + }, + placeholder = defaultPlaceHolder("Search..."), + leadingComponent = { + FloconIcon( + imageVector = Icons.Outlined.Search, + modifier = Modifier.size(16.dp) + ) + }, + containerColor = FloconTheme.colorPalette.secondary, + textStyle = FloconTheme.typography.bodySmall.copy(color = FloconTheme.colorPalette.onSurface), + modifier = Modifier + .focusRequester(focusRequester) + .fillMaxWidth() + ) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(top = 8.dp), + ) { + SearchScope.entries.forEach { scope -> + ScopeChipsView( + uiState = uiState, + scope = scope, + onScopeToggled = onScopeToggled + ) + } + } + } + + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier.weight(1f).fillMaxWidth() + ) { + val listState = rememberLazyListState() + val scrollAdapter = rememberFloconScrollbarAdapter(listState) + + LazyColumn( + state = listState, + modifier = Modifier.matchParentSize(), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(vertical = 12.dp), + ) { + items(uiState.results) { item -> + NetworkItemView( + state = item, + selected = item.uuid == selectedRequestId, + multiSelect = false, + multiSelected = false, + onAction = { action -> + if (action is NetworkAction.SelectRequest) { + onSelectRequest(action.id) + } else if (action is NetworkAction.DoubleClicked) { + onNavigateToDetail(action.item.uuid) + } + } + ) + } + } + FloconVerticalScrollbar( + adapter = scrollAdapter, + modifier = Modifier.fillMaxHeight() + .align(Alignment.TopEnd) + ) + } + + if (selectedRequestId != null && selectedRequest != null) { + NetworkSearchPreviewView( + request = selectedRequest, + matches = matches, + currentMatchIndex = currentMatchIndex, + onNextMatch = onNextMatch, + onPrevMatch = onPrevMatch, + onClose = onClosePreview, + modifier = Modifier.weight(1f).fillMaxWidth() + ) + } + } + } + } +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/view/components/NetworkSearchPreviewView.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/view/components/NetworkSearchPreviewView.kt new file mode 100644 index 00000000..9be2de81 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/view/components/NetworkSearchPreviewView.kt @@ -0,0 +1,212 @@ +package io.github.openflocon.flocondesktop.features.network.search.view.components + +import androidx.compose.foundation.LocalScrollbarStyle +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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 +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.ArrowForward +import androidx.compose.material.icons.outlined.Close +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel +import io.github.openflocon.domain.network.models.responseBody +import io.github.openflocon.flocondesktop.features.network.search.Match +import io.github.openflocon.library.designsystem.FloconTheme +import io.github.openflocon.library.designsystem.components.FloconHorizontalDivider +import io.github.openflocon.library.designsystem.components.FloconIcon +import io.github.openflocon.library.designsystem.components.FloconVerticalScrollbar +import io.github.openflocon.library.designsystem.components.rememberFloconScrollbarAdapter + +@Composable +internal fun NetworkSearchPreviewView( + request: FloconNetworkCallDomainModel, + matches: List, + currentMatchIndex: Int, + onNextMatch: () -> Unit, + onPrevMatch: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + val locationLabel = remember(matches, currentMatchIndex) { + if (matches.isNotEmpty() && matches.indices.contains(currentMatchIndex)) { + matches[currentMatchIndex].location.label + } else { + "Preview" + } + } + Column( + modifier = modifier + .background(FloconTheme.colorPalette.primary) + ) { + // Divider + FloconHorizontalDivider(Modifier.fillMaxWidth()) + + // Toolbar + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${matches.size} matches", + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colorPalette.onPrimary + ) + + Spacer(Modifier.width(16.dp)) + + if (matches.isNotEmpty()) { + Text( + text = "${currentMatchIndex + 1} / ${matches.size}", + style = FloconTheme.typography.bodySmall, + color = FloconTheme.colorPalette.onPrimary + ) + Spacer(Modifier.width(8.dp)) + FloconIcon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, // Left Arrow + // contentDescription = "Previous Match", + modifier = Modifier.clickable { onPrevMatch() } + ) + Spacer(Modifier.width(8.dp)) + FloconIcon( + imageVector = Icons.AutoMirrored.Outlined.ArrowForward, // Right Arrow + // contentDescription = "Next Match", + modifier = Modifier.clickable { onNextMatch() } + ) + } + + Text( + text = locationLabel, + style = FloconTheme.typography.titleSmall, + color = FloconTheme.colorPalette.onPrimary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + + Spacer(Modifier.weight(1f)) + + FloconIcon( + imageVector = Icons.Outlined.Close, + // contentDescription = "Close Preview", + modifier = Modifier.clickable { onClose() } + ) + } + + // Content + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + val content = remember(matches, currentMatchIndex, request) { + if (matches.isNotEmpty() && matches.indices.contains(currentMatchIndex)) { + matches[currentMatchIndex].content + } else { + request.responseBody() ?: "No content" + } + } + + val scrollState = rememberScrollState() + val scrollAdapter = rememberFloconScrollbarAdapter(scrollState) + + val annotatedString = remember(content, matches, currentMatchIndex) { + buildAnnotatedString { + append(content) + // Highlighting + if (matches.isNotEmpty()) { + val currentMatch = matches.getOrNull(currentMatchIndex) + // Only highlight matches that belong to the SAME location/content + matches.filter { it.location == currentMatch?.location }.forEach { match -> + addStyle( + style = SpanStyle( + background = if (match == currentMatch) Color.Red.copy(alpha = 0.5f) else Color.Yellow.copy( + alpha = 0.3f + ), + color = if (match == currentMatch) Color.White else Color.Black + ), + start = match.start, + end = match.start + match.length + ) + } + } + } + } + + var layoutResult by remember { mutableStateOf(null) } + + // Scroll to top when switching content or match + LaunchedEffect(currentMatchIndex) { + if (matches.isNotEmpty()) { + val match = matches.getOrNull(currentMatchIndex) + if (match != null) { + layoutResult?.let { layout -> + // Ensure the offset is valid for the current content + if (match.start < layout.layoutInput.text.length) { + val line = layout.getLineForOffset(match.start) + val y = layout.getLineTop(line) + scrollState.animateScrollTo(y.toInt()) + } + } + } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 12.dp, vertical = 8.dp) + .padding(bottom = 8.dp) + .clip(shape = RoundedCornerShape(8.dp)) + .background(FloconTheme.colorPalette.secondary) + ) { + SelectionContainer { + Text( + text = annotatedString, + style = FloconTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = FloconTheme.colorPalette.onSurface, + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(16.dp), + onTextLayout = { + layoutResult = it + } + ) + } + FloconVerticalScrollbar( + adapter = scrollAdapter, + style = LocalScrollbarStyle.current.copy( + unhoverColor = FloconTheme.colorPalette.onSurface.copy(alpha = 0.2f), + hoverColor = FloconTheme.colorPalette.onSurface.copy(alpha = 0.8f), + ), + modifier = Modifier.fillMaxHeight() + .align(Alignment.TopEnd) + ) + } + } + } +} diff --git a/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/view/components/ScopeChipsView.kt b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/view/components/ScopeChipsView.kt new file mode 100644 index 00000000..1d08f816 --- /dev/null +++ b/FloconDesktop/composeApp/src/commonMain/kotlin/io/github/openflocon/flocondesktop/features/network/search/view/components/ScopeChipsView.kt @@ -0,0 +1,48 @@ +package io.github.openflocon.flocondesktop.features.network.search.view.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import io.github.openflocon.domain.network.models.SearchScope +import io.github.openflocon.domain.network.models.text +import io.github.openflocon.flocondesktop.features.network.search.model.NetworkSearchUiState +import io.github.openflocon.library.designsystem.FloconTheme + +@Composable +internal fun ScopeChipsView( + uiState: NetworkSearchUiState, + scope: SearchScope, + onScopeToggled: (SearchScope) -> Unit, + modifier: Modifier = Modifier, +) { + val isSelected = remember(uiState, scope) { uiState.selectedScopes.contains(scope) } + val color by animateColorAsState(if (isSelected) FloconTheme.colorPalette.tertiary else Color.Transparent) + val textColor by animateColorAsState( + if (isSelected) FloconTheme.colorPalette.onTertiary else FloconTheme.colorPalette.onSurface.copy( + alpha = 0.5f + ) + ) + Box( + modifier = modifier + .clip(FloconTheme.shapes.large) + .clickable { onScopeToggled(scope) } + .background(color = color) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) { + Text( + text = scope.text(), + style = FloconTheme.typography.bodySmall, + color = textColor, + ) + } +} diff --git a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/dashboard/models/DashboardConfigDataModel.kt b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/dashboard/models/DashboardConfigDataModel.kt index c4d11760..ac01f848 100644 --- a/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/dashboard/models/DashboardConfigDataModel.kt +++ b/FloconDesktop/data/remote/src/commonMain/kotlin/com/flocon/data/remote/dashboard/models/DashboardConfigDataModel.kt @@ -52,7 +52,6 @@ data class HtmlConfigDataModel( val value: String, ) - @Serializable data class MarkdownConfigDataModel( val label: String, 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 3593299b..f36dc673 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 @@ -30,6 +30,8 @@ import io.github.openflocon.domain.network.usecase.mocks.SendNetworkWebsocketMoc import io.github.openflocon.domain.network.usecase.mocks.SetupNetworkMocksUseCase import io.github.openflocon.domain.network.usecase.mocks.UpdateNetworkMockIsEnabledUseCase import io.github.openflocon.domain.network.usecase.mocks.UpdateNetworkMocksDeviceUseCase +import io.github.openflocon.domain.network.usecase.search.GetAllNetworkRequestsUseCase +import io.github.openflocon.domain.network.usecase.search.SearchNetworkCallsUseCase import org.koin.core.module.dsl.factoryOf import org.koin.dsl.module @@ -67,4 +69,6 @@ internal val networkModule = module { factoryOf(::SetupNetworkBadQualityUseCase) factoryOf(::SetNetworkBadQualityEnabledConfigUseCase) factoryOf(::ObserveAllNetworkBadQualitiesUseCase) + factoryOf(::SearchNetworkCallsUseCase) + factoryOf(::GetAllNetworkRequestsUseCase) } diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/models/FloconNetworkCallDomainModel.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/models/FloconNetworkCallDomainModel.kt index f93acd6f..4a8163ed 100644 --- a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/models/FloconNetworkCallDomainModel.kt +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/models/FloconNetworkCallDomainModel.kt @@ -108,6 +108,18 @@ fun FloconNetworkCallDomainModel.responseByteSizeFormatted(): String? = when (th null -> null } +fun FloconNetworkCallDomainModel.responseHeaders(): Map? = when (this.response) { + is FloconNetworkCallDomainModel.Response.Failure -> null + is FloconNetworkCallDomainModel.Response.Success -> this.response.headers + null -> null +} + +fun FloconNetworkCallDomainModel.responseBody(): String? = when (this.response) { + is FloconNetworkCallDomainModel.Response.Failure -> null + is FloconNetworkCallDomainModel.Response.Success -> this.response.body + null -> null +} + fun FloconNetworkCallDomainModel.Response.getContentType(): String? = when (this) { is FloconNetworkCallDomainModel.Response.Failure -> null is FloconNetworkCallDomainModel.Response.Success -> this.contentType diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/models/SearchScope.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/models/SearchScope.kt new file mode 100644 index 00000000..661b98df --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/models/SearchScope.kt @@ -0,0 +1,21 @@ +package io.github.openflocon.domain.network.models + +enum class SearchScope { + RequestHeader, + RequestBody, + ResponseHeader, + ResponseBody, + Url, + Method, + Status +} + +fun SearchScope.text(): String = when (this) { + SearchScope.RequestHeader -> "Request Header" + SearchScope.RequestBody -> "Request Body" + SearchScope.ResponseHeader -> "Response Header" + SearchScope.ResponseBody -> "Response Body" + SearchScope.Url -> "Url" + SearchScope.Method -> "Method" + SearchScope.Status -> "Status" +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/search/GetAllNetworkRequestsUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/search/GetAllNetworkRequestsUseCase.kt new file mode 100644 index 00000000..ebd2029a --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/search/GetAllNetworkRequestsUseCase.kt @@ -0,0 +1,25 @@ +package io.github.openflocon.domain.network.usecase.search + +import io.github.openflocon.domain.device.usecase.GetCurrentDeviceIdAndPackageNameUseCase +import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel +import io.github.openflocon.domain.network.models.NetworkFilterDomainModel +import io.github.openflocon.domain.network.repository.NetworkRepository + +class GetAllNetworkRequestsUseCase( + private val networkRepository: NetworkRepository, + private val getCurrentDeviceIdAndPackageNameUseCase: GetCurrentDeviceIdAndPackageNameUseCase, +) { + suspend operator fun invoke(): List { + val current = getCurrentDeviceIdAndPackageNameUseCase() ?: return emptyList() + return networkRepository.getRequests( + deviceIdAndPackageName = current, + sortedBy = null, + filter = NetworkFilterDomainModel( + filterOnAllColumns = null, + textsFilters = null, + methodFilter = null, + displayOldSessions = true // Include everything for global search + ), + ) + } +} diff --git a/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/search/SearchNetworkCallsUseCase.kt b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/search/SearchNetworkCallsUseCase.kt new file mode 100644 index 00000000..2c6f087d --- /dev/null +++ b/FloconDesktop/domain/src/commonMain/kotlin/io/github/openflocon/domain/network/usecase/search/SearchNetworkCallsUseCase.kt @@ -0,0 +1,54 @@ +package io.github.openflocon.domain.network.usecase.search + +import io.github.openflocon.domain.network.models.FloconNetworkCallDomainModel +import io.github.openflocon.domain.network.models.SearchScope +import io.github.openflocon.domain.network.models.responseBody +import io.github.openflocon.domain.network.models.responseHeaders + +class SearchNetworkCallsUseCase( + private val getAllNetworkRequestsUseCase: GetAllNetworkRequestsUseCase +) { + suspend operator fun invoke( + query: String, + scope: Set + ): List { + if (query.isBlank() || scope.isEmpty()) { + return emptyList() + } + + val calls = getAllNetworkRequestsUseCase() + + return calls.filter { call -> + scope.any { s -> + when (s) { + SearchScope.RequestHeader -> call.request.headers.any { + it.key.contains(query, ignoreCase = true) || it.value.contains( + query, + ignoreCase = true + ) + } + + SearchScope.RequestBody -> call.request.body?.contains(query, ignoreCase = true) + ?: false + + SearchScope.ResponseHeader -> call.responseHeaders()?.any { + it.key.contains(query, ignoreCase = true) || it.value.contains( + query, + ignoreCase = true + ) + } == true + + SearchScope.ResponseBody -> call.responseBody() + ?.contains(query, ignoreCase = true) == true + + SearchScope.Url -> call.request.url.contains(query, ignoreCase = true) + SearchScope.Method -> call.request.method.contains(query, ignoreCase = true) + SearchScope.Status -> call.response?.statusFormatted?.contains( + query, + ignoreCase = true + ) == true + } + } + } + } +} diff --git a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconVerticalScrollbar.kt b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconVerticalScrollbar.kt index edc1e27a..3df410ce 100644 --- a/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconVerticalScrollbar.kt +++ b/FloconDesktop/library/designsystem/src/commonMain/kotlin/io/github/openflocon/library/designsystem/components/FloconVerticalScrollbar.kt @@ -1,6 +1,8 @@ package io.github.openflocon.library.designsystem.components +import androidx.compose.foundation.LocalScrollbarStyle import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.ScrollbarStyle import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.Composable @@ -20,5 +22,6 @@ expect fun rememberFloconScrollbarAdapter(scrollState: LazyGridState): FloconScr @Composable expect fun FloconVerticalScrollbar( adapter: FloconScrollAdapter, - modifier: Modifier = Modifier + style: ScrollbarStyle = LocalScrollbarStyle.current, + modifier: Modifier = Modifier, ) diff --git a/FloconDesktop/library/designsystem/src/desktopMain/java/io/github/openflocon/library/designsystem/components/FloconVerticalScrollbar.desktop.kt b/FloconDesktop/library/designsystem/src/desktopMain/java/io/github/openflocon/library/designsystem/components/FloconVerticalScrollbar.desktop.kt index a000fa9f..8e024680 100644 --- a/FloconDesktop/library/designsystem/src/desktopMain/java/io/github/openflocon/library/designsystem/components/FloconVerticalScrollbar.desktop.kt +++ b/FloconDesktop/library/designsystem/src/desktopMain/java/io/github/openflocon/library/designsystem/components/FloconVerticalScrollbar.desktop.kt @@ -1,6 +1,8 @@ package io.github.openflocon.library.designsystem.components +import androidx.compose.foundation.LocalScrollbarStyle import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.ScrollbarStyle import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState @@ -17,11 +19,13 @@ data class FloconScrollAdapterDesktop( @Composable actual fun FloconVerticalScrollbar( adapter: FloconScrollAdapter, + style: ScrollbarStyle, modifier: Modifier ) { VerticalScrollbar( adapter = (adapter as FloconScrollAdapterDesktop).scrollbarAdapter, - modifier = modifier + modifier = modifier, + style = style, ) } diff --git a/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/FloconNavigationState.kt b/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/FloconNavigationState.kt index 953dc113..ba14e0e8 100644 --- a/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/FloconNavigationState.kt +++ b/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/FloconNavigationState.kt @@ -32,6 +32,11 @@ class MainFloconNavigationState(initialScreen: FloconRoute = LoadingRoute) : Flo } else { _stack.add(route) } + } else if (route is WindowRoute && route.singleTopKey != null) { + _stack.find { it is WindowRoute && it.singleTopKey == route.singleTopKey }?.let { + _stack.remove(it) + } + _stack.add(route) } else { _stack.add(route) } diff --git a/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/FloconRoute.kt b/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/FloconRoute.kt index 65053022..28191446 100644 --- a/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/FloconRoute.kt +++ b/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/FloconRoute.kt @@ -5,3 +5,7 @@ import androidx.navigation3.runtime.NavKey interface FloconRoute : NavKey interface PanelRoute : NavKey + +interface WindowRoute : NavKey { + val singleTopKey: String? +} diff --git a/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/scene/WindowScene.kt b/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/scene/WindowScene.kt index edc78504..5b43f8a1 100644 --- a/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/scene/WindowScene.kt +++ b/FloconDesktop/navigation/src/commonMain/kotlin/io/github/openflocon/navigation/scene/WindowScene.kt @@ -4,7 +4,10 @@ package io.github.openflocon.navigation.scene import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window +import androidx.compose.ui.window.rememberWindowState import androidx.navigation3.runtime.NavEntry import androidx.navigation3.scene.OverlayScene import androidx.navigation3.scene.Scene @@ -26,8 +29,18 @@ data class WindowScene( override val entries: List> = listOf(entry) override val content: @Composable (() -> Unit) = { + val width = entry.metadata[WindowSceneStrategy.SIZE_WIDTH] as? Number + val height = entry.metadata[WindowSceneStrategy.SIZE_HEIGHT] as? Number + val size = if (width != null && height != null) { + DpSize(width.toInt().dp, height.toInt().dp) + } else null + + val state = rememberWindowState( + size = size ?: DpSize(800.dp, 600.dp), + ) Window( - onCloseRequest = onBack + onCloseRequest = onBack, + state = state, ) { entry.Content() } @@ -52,7 +65,15 @@ class WindowSceneStrategy : SceneStrategy { companion object { private const val IS_WINDOW = "is_window" + internal const val SIZE_WIDTH = "SIZE_WIDTH" + internal const val SIZE_HEIGHT = "SIZE_HEIGHT" - fun window() = mapOf(IS_WINDOW to true) + fun window(size: DpSize? = null) = buildMap { + put(IS_WINDOW, true) + size?.let { + put(SIZE_WIDTH, it.width.value) + put(SIZE_HEIGHT, it.height.value) + } + } } }