mirror of
https://github.com/openflocon/Flocon.git
synced 2026-04-28 11:19:34 +00:00
feat: [NETWORK] deep search (#448)
Co-authored-by: Florent Champigny <florent@bere.al>
This commit is contained in:
parent
5e47202096
commit
e702440d52
24 changed files with 955 additions and 16 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
requestId = action.item.uuid,
|
NetworkRoutes.WindowDetail(
|
||||||
windowKey = Uuid.random().toString(),
|
requestId = action.item.uuid,
|
||||||
))
|
windowKey = Uuid.random().toString(),
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Map<NetworkTextFilterColumns, TextFilterStateUiModel>.toDomain(): List<Filters> = buildList {
|
private fun Map<NetworkTextFilterColumns, TextFilterStateUiModel>.toDomain(): List<Filters> = buildList {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue