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
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

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.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)
}

View file

@ -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<FloconRoute>.networkRoutes() {
onPin = {
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)
)
}
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.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<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
fun FloconNetworkCallDomainModel.toUi(
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel?
deviceIdAndPackageName: DeviceIdAndPackageNameDomainModel?,
): NetworkItemViewState = NetworkItemViewState(
uuid = callId,
dateFormatted = request.startTimeFormatted,

View file

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

View file

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

View file

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

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,
)
@Serializable
data class MarkdownConfigDataModel(
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.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)
}

View file

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

View file

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

View file

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

View file

@ -5,3 +5,7 @@ import androidx.navigation3.runtime.NavKey
interface FloconRoute : 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.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<NavEntry<FloconRoute>> = 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<FloconRoute> {
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)
}
}
}
}