feat: [NETWORK] deep search (#448)

Co-authored-by: Florent Champigny <florent@bere.al>
This commit is contained in:
Florent CHAMPIGNY 2025-12-10 21:59:53 +01:00 committed by GitHub
parent 5e47202096
commit e702440d52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 955 additions and 16 deletions

View file

@ -1,10 +1,10 @@
package io.github.openflocon.flocondesktop.features.crashreporter package io.github.openflocon.flocondesktop.features.crashreporter
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import androidx.paging.map import androidx.paging.map
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import flocondesktop.composeapp.generated.resources.Res import flocondesktop.composeapp.generated.resources.Res
import flocondesktop.composeapp.generated.resources.copied_to_clipboard import flocondesktop.composeapp.generated.resources.copied_to_clipboard
import io.github.openflocon.domain.common.DispatcherProvider import io.github.openflocon.domain.common.DispatcherProvider

View file

@ -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.NetworkMocksViewModel
import io.github.openflocon.flocondesktop.features.network.mock.processor.ExportMocksProcessor 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.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.features.network.websocket.NetworkWebsocketMockViewModel
import io.github.openflocon.flocondesktop.messages.ui.MessagesServerDelegate import io.github.openflocon.flocondesktop.messages.ui.MessagesServerDelegate
import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.factoryOf
@ -32,4 +33,5 @@ internal val networkModule = module {
viewModelOf(::BadQualityNetworkViewModel) viewModelOf(::BadQualityNetworkViewModel)
viewModelOf(::NetworkWebsocketMockViewModel) viewModelOf(::NetworkWebsocketMockViewModel)
viewModelOf(::NetworkSearchViewModel)
} }

View file

@ -2,6 +2,8 @@
package io.github.openflocon.flocondesktop.features.network 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.runtime.EntryProviderScope
import androidx.navigation3.scene.DialogSceneStrategy import androidx.navigation3.scene.DialogSceneStrategy
import io.github.openflocon.domain.settings.repository.SettingsRepository 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.detail.view.NetworkDetailScreen
import io.github.openflocon.flocondesktop.features.network.list.view.NetworkScreen 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.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.FloconRoute
import io.github.openflocon.navigation.PanelRoute 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.PanelSceneStrategy
import io.github.openflocon.navigation.scene.WindowSceneStrategy import io.github.openflocon.navigation.scene.WindowSceneStrategy
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -39,6 +43,11 @@ internal sealed interface NetworkRoutes : FloconRoute {
val windowKey: String, val windowKey: String,
) : NetworkRoutes ) : NetworkRoutes
@Serializable
data object DeepSearch : NetworkRoutes, WindowRoute {
override val singleTopKey = "deepsearch"
}
@Serializable @Serializable
data class JsonDetail( data class JsonDetail(
val json: String, val json: String,
@ -59,7 +68,8 @@ fun EntryProviderScope<FloconRoute>.networkRoutes() {
onPin = { onPin = {
val repository = KoinPlatform.getKoin().get<SettingsRepository>() val repository = KoinPlatform.getKoin().get<SettingsRepository>()
repository.networkSettings = repository.networkSettings.copy(pinnedDetails = true) repository.networkSettings =
repository.networkSettings.copy(pinnedDetails = true)
} }
) )
) { ) {
@ -84,4 +94,14 @@ fun EntryProviderScope<FloconRoute>.networkRoutes() {
body = NetworkBodyDetailUi(text = it.json) body = NetworkBodyDetailUi(text = it.json)
) )
} }
entry<NetworkRoutes.DeepSearch>(
metadata = WindowSceneStrategy.window(
size = DpSize(
width = 1200.0.dp,
height = 800.0.dp
)
)
) {
NetworkSearchScreen()
}
} }

View file

@ -275,6 +275,7 @@ class NetworkViewModel(
NetworkAction.MultiSelect -> onMultiSelect() NetworkAction.MultiSelect -> onMultiSelect()
NetworkAction.DeleteSelection -> onDeleteSelection() NetworkAction.DeleteSelection -> onDeleteSelection()
is NetworkAction.DoubleClicked -> onDoubleClicked(action) is NetworkAction.DoubleClicked -> onDoubleClicked(action)
NetworkAction.OpenDeepSearch -> navigationState.navigate(NetworkRoutes.DeepSearch)
} }
} }
@ -502,15 +503,16 @@ class NetworkViewModel(
.also { onClearMultiSelect() } .also { onClearMultiSelect() }
} }
} }
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
private fun onDoubleClicked(action: NetworkAction.DoubleClicked) { private fun onDoubleClicked(action: NetworkAction.DoubleClicked) {
navigationState.navigate(NetworkRoutes.WindowDetail( navigationState.navigate(
NetworkRoutes.WindowDetail(
requestId = action.item.uuid, requestId = action.item.uuid,
windowKey = Uuid.random().toString(), windowKey = Uuid.random().toString(),
)) )
)
} }
} }
private fun Map<NetworkTextFilterColumns, TextFilterStateUiModel>.toDomain(): List<Filters> = buildList { private fun Map<NetworkTextFilterColumns, TextFilterStateUiModel>.toDomain(): List<Filters> = buildList {

View file

@ -6,7 +6,7 @@ import io.github.openflocon.domain.network.models.responseByteSizeFormatted
import io.github.openflocon.flocondesktop.features.network.list.model.NetworkItemViewState import io.github.openflocon.flocondesktop.features.network.list.model.NetworkItemViewState
fun FloconNetworkCallDomainModel.toUi( fun FloconNetworkCallDomainModel.toUi(
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel? deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel?,
): NetworkItemViewState = NetworkItemViewState( ): NetworkItemViewState = NetworkItemViewState(
uuid = callId, uuid = callId,
dateFormatted = request.startTimeFormatted, dateFormatted = request.startTimeFormatted,

View file

@ -62,6 +62,8 @@ sealed interface NetworkAction {
val itemIdToSelect: String, val itemIdToSelect: String,
) : NetworkAction ) : NetworkAction
data object OpenDeepSearch : NetworkAction
data class DoubleClicked(val item: NetworkItemViewState) : NetworkAction data class DoubleClicked(val item: NetworkItemViewState) : NetworkAction
data object ClearMultiSelect : NetworkAction data object ClearMultiSelect : NetworkAction

View file

@ -1,8 +1,8 @@
package io.github.openflocon.flocondesktop.features.network.list.view package io.github.openflocon.flocondesktop.features.network.list.view
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable

View file

@ -18,12 +18,16 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons 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.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.CleaningServices
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.ImportExport import androidx.compose.material.icons.outlined.ImportExport
import androidx.compose.material.icons.outlined.PlayCircle 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.SignalWifiStatusbarConnectedNoInternet4
import androidx.compose.material.icons.outlined.Upgrade import androidx.compose.material.icons.outlined.Upgrade
import androidx.compose.material.icons.outlined.Upload 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.graphics.Color
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType 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.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
@ -166,6 +171,15 @@ fun NetworkScreen(
true true
} }
Key.F -> {
if (event.isMetaPressed) {
onAction(NetworkAction.OpenDeepSearch)
true
} else {
false
}
}
else -> false else -> false
} }
} }
@ -181,6 +195,11 @@ fun NetworkScreen(
) )
}, },
actions = { actions = {
FloconIconButton(
imageVector = Icons.AutoMirrored.Filled.ManageSearch,
onClick = { onAction(NetworkAction.OpenDeepSearch) },
tooltip = "Deep Search"
)
FloconIconToggleButton( FloconIconToggleButton(
value = uiState.filterState.hasMocks, value = uiState.filterState.hasMocks,
tooltip = "Mocks", tooltip = "Mocks",

View file

@ -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<NetworkSearchUiState> = 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<String?>(null)
val selectedRequestId = _selectedRequestId.asStateFlow()
val selectedRequest: StateFlow<FloconNetworkCallDomainModel?> = _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<List<Match>>(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<Match>()
// 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")
}

View file

@ -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> = SearchScope.entries.toSet(),
val results: List<NetworkItemViewState> = emptyList(),
val loading: Boolean = false
)

View file

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

View file

@ -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<Match>,
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<TextLayoutResult?>(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)
)
}
}
}
}

View file

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

View file

@ -52,7 +52,6 @@ data class HtmlConfigDataModel(
val value: String, val value: String,
) )
@Serializable @Serializable
data class MarkdownConfigDataModel( data class MarkdownConfigDataModel(
val label: String, val label: String,

View file

@ -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.SetupNetworkMocksUseCase
import io.github.openflocon.domain.network.usecase.mocks.UpdateNetworkMockIsEnabledUseCase 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.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.core.module.dsl.factoryOf
import org.koin.dsl.module import org.koin.dsl.module
@ -67,4 +69,6 @@ internal val networkModule = module {
factoryOf(::SetupNetworkBadQualityUseCase) factoryOf(::SetupNetworkBadQualityUseCase)
factoryOf(::SetNetworkBadQualityEnabledConfigUseCase) factoryOf(::SetNetworkBadQualityEnabledConfigUseCase)
factoryOf(::ObserveAllNetworkBadQualitiesUseCase) factoryOf(::ObserveAllNetworkBadQualitiesUseCase)
factoryOf(::SearchNetworkCallsUseCase)
factoryOf(::GetAllNetworkRequestsUseCase)
} }

View file

@ -108,6 +108,18 @@ fun FloconNetworkCallDomainModel.responseByteSizeFormatted(): String? = when (th
null -> null null -> null
} }
fun FloconNetworkCallDomainModel.responseHeaders(): Map<String, String>? = 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) { fun FloconNetworkCallDomainModel.Response.getContentType(): String? = when (this) {
is FloconNetworkCallDomainModel.Response.Failure -> null is FloconNetworkCallDomainModel.Response.Failure -> null
is FloconNetworkCallDomainModel.Response.Success -> this.contentType is FloconNetworkCallDomainModel.Response.Success -> this.contentType

View file

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

View file

@ -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<FloconNetworkCallDomainModel> {
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
),
)
}
}

View file

@ -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<SearchScope>
): List<FloconNetworkCallDomainModel> {
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
}
}
}
}
}

View file

@ -1,6 +1,8 @@
package io.github.openflocon.library.designsystem.components package io.github.openflocon.library.designsystem.components
import androidx.compose.foundation.LocalScrollbarStyle
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.ScrollbarStyle
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -20,5 +22,6 @@ expect fun rememberFloconScrollbarAdapter(scrollState: LazyGridState): FloconScr
@Composable @Composable
expect fun FloconVerticalScrollbar( expect fun FloconVerticalScrollbar(
adapter: FloconScrollAdapter, adapter: FloconScrollAdapter,
modifier: Modifier = Modifier style: ScrollbarStyle = LocalScrollbarStyle.current,
modifier: Modifier = Modifier,
) )

View file

@ -1,6 +1,8 @@
package io.github.openflocon.library.designsystem.components package io.github.openflocon.library.designsystem.components
import androidx.compose.foundation.LocalScrollbarStyle
import androidx.compose.foundation.ScrollState import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.ScrollbarStyle
import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyGridState
@ -17,11 +19,13 @@ data class FloconScrollAdapterDesktop(
@Composable @Composable
actual fun FloconVerticalScrollbar( actual fun FloconVerticalScrollbar(
adapter: FloconScrollAdapter, adapter: FloconScrollAdapter,
style: ScrollbarStyle,
modifier: Modifier modifier: Modifier
) { ) {
VerticalScrollbar( VerticalScrollbar(
adapter = (adapter as FloconScrollAdapterDesktop).scrollbarAdapter, adapter = (adapter as FloconScrollAdapterDesktop).scrollbarAdapter,
modifier = modifier modifier = modifier,
style = style,
) )
} }

View file

@ -32,6 +32,11 @@ class MainFloconNavigationState(initialScreen: FloconRoute = LoadingRoute) : Flo
} else { } else {
_stack.add(route) _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 { } else {
_stack.add(route) _stack.add(route)
} }

View file

@ -5,3 +5,7 @@ import androidx.navigation3.runtime.NavKey
interface FloconRoute : NavKey interface FloconRoute : NavKey
interface PanelRoute : NavKey interface PanelRoute : NavKey
interface WindowRoute : NavKey {
val singleTopKey: String?
}

View file

@ -4,7 +4,10 @@ package io.github.openflocon.navigation.scene
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable 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.Window
import androidx.compose.ui.window.rememberWindowState
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.scene.OverlayScene import androidx.navigation3.scene.OverlayScene
import androidx.navigation3.scene.Scene import androidx.navigation3.scene.Scene
@ -26,8 +29,18 @@ data class WindowScene(
override val entries: List<NavEntry<FloconRoute>> = listOf(entry) override val entries: List<NavEntry<FloconRoute>> = listOf(entry)
override val content: @Composable (() -> Unit) = { 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( Window(
onCloseRequest = onBack onCloseRequest = onBack,
state = state,
) { ) {
entry.Content() entry.Content()
} }
@ -52,7 +65,15 @@ class WindowSceneStrategy : SceneStrategy<FloconRoute> {
companion object { companion object {
private const val IS_WINDOW = "is_window" 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)
}
}
} }
} }