From 2bb7518d5fdc6923e1484e67b8621ee336b07477 Mon Sep 17 00:00:00 2001 From: Jasmin Le Roux Date: Sun, 28 Jun 2026 19:54:28 +0200 Subject: [PATCH] feat(sessions): search sessions from server --- .../data/session/SessionRepositoryImpl.kt | 43 ++++++ .../ui/screens/sessions/SessionListScreen.kt | 98 +++++++++++--- .../screens/sessions/SessionListViewModel.kt | 127 ++++++++++++++++- app/src/main/res/values/strings.xml | 9 +- .../p4oc/fakes/FakeWorkspaceClient.kt | 16 ++- .../sessions/SessionListViewModelTest.kt | 128 ++++++++++++++++-- 6 files changed, 392 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt index 51acb5ba..c7ea4f22 100644 --- a/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt +++ b/app/src/main/java/dev/blazelight/p4oc/data/session/SessionRepositoryImpl.kt @@ -136,6 +136,48 @@ class SessionRepositoryImpl( _state.value = RepoState.Live(snapshot) } + suspend fun searchSessions(query: String, directory: String? = null): List { + val trimmed = query.trim() + if (trimmed.isEmpty()) return emptyList() + + val projects = if (directory == null) { + runCatching { client.listProjects() }.getOrElse { emptyList() } + } else { + emptyList() + } + val directories = if (directory != null) { + listOf(directory) + } else { + listOf(null) + projects.map { it.worktree } + } + + return coroutineScope { + val results = directories.map { searchDirectory -> + async { + runCatching { + client.listSessions( + directory = searchDirectory, + scope = null, + roots = true, + search = trimmed, + limit = SEARCH_LIMIT, + ).filterNot { dto -> OfishSessionNames.isOfishTitle(dto.title) } + .map { dto -> workspaceSession(SessionMapper.mapToDomain(dto)) } + }.onFailure { error -> + AppLog.e(TAG, "Failed to search sessions for ${searchDirectory ?: "global"}: ${error.message}") + } + } + }.awaitAll() + if (results.all { it.isFailure }) { + throw results.firstNotNullOf { it.exceptionOrNull() } + } + results.map { it.getOrElse { emptyList() } } + .flatten() + .distinctBy { it.id.value } + .sortedByDescending { it.session.updatedAt } + } + } + override suspend fun getSession(id: SessionId): WorkspaceSession? { val current = state.value.snapshot.sessions[id.value] if (current != null) return current @@ -822,6 +864,7 @@ class SessionRepositoryImpl( private companion object { const val FRESHNESS_MS = 30_000L const val MAX_CONCURRENT = 10 + const val SEARCH_LIMIT = 100 const val TAG = "SessionRepository" const val RESOLVED_QUESTION_TTL_MS = 30_000L } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt index 6a68008e..092702e6 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.material3.MenuAnchorType import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -23,6 +24,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -86,9 +88,9 @@ fun SessionListScreen( var showNewSessionCustomDir by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(null) } var showRenameDialog by remember { mutableStateOf(null) } - var showSearch by remember { mutableStateOf(false) } - var sessionSearchQuery by remember { mutableStateOf("") } + var showSearch by rememberSaveable { mutableStateOf(false) } val context = LocalContext.current + val keyboardController = LocalSoftwareKeyboardController.current val filterDirectory = remember(uiState.projects, filterProjectId) { filterProjectId?.let { filter -> @@ -96,19 +98,30 @@ fun SessionListScreen( } } - val displayedSessions = remember(uiState.sessions, filterDirectory) { + val baseDisplayedSessions = remember(uiState.sessions, filterDirectory) { if (filterDirectory != null) { uiState.sessions.filter { it.session.directory == filterDirectory } } else { uiState.sessions } } + val displayedSessions = if (!uiState.isSearchActive) { + baseDisplayedSessions + } else if (uiState.serverSearchQuery == null) { + baseDisplayedSessions.filter { it.session.title.contains(uiState.searchQuery.trim(), ignoreCase = true) } + } else { + uiState.displayedSearchResults + } val filteredProject = remember(uiState.projects, filterDirectory) { filterDirectory?.let { directory -> uiState.projects.find { it.worktree == directory } } } val projectName = filteredProject?.name ?: filterDirectory?.substringAfterLast("/") + LaunchedEffect(filterDirectory) { + viewModel.updateSearchDirectory(filterDirectory) + } + LaunchedEffect(autoCreateSession, autoCreateSessionTitle, autoCreateSessionDirectory) { if (autoCreateSession) { onAutoCreateSessionConsumed() @@ -165,15 +178,13 @@ fun SessionListScreen( ) } IconButton( - onClick = { - showSearch = !showSearch - if (!showSearch) sessionSearchQuery = "" - }, + onClick = { showSearch = !showSearch }, modifier = Modifier.size(Sizing.iconButtonMd).testTag("sessions_search_button") ) { Icon( - Icons.Default.Search, + imageVector = if (uiState.isSearchActive) Icons.Default.SearchOff else Icons.Default.Search, contentDescription = stringResource(R.string.cd_search), + tint = if (uiState.isSearchActive) theme.accent else LocalContentColor.current, modifier = Modifier.size(Sizing.iconAction) ) } @@ -227,8 +238,8 @@ fun SessionListScreen( // During search, flatten to matching sessions (tree roots only would // hide matching child sessions whose parent is filtered out). - val sessionTree = remember(displayedSessions, sessionSearchQuery) { - val q = sessionSearchQuery.trim() + val sessionTree = remember(displayedSessions, uiState.searchQuery) { + val q = uiState.searchQuery.trim() if (q.isBlank()) { buildSessionTree(displayedSessions) } else { @@ -243,15 +254,17 @@ fun SessionListScreen( contentPadding = PaddingValues(Spacing.md), verticalArrangement = Arrangement.spacedBy(Spacing.xs) ) { - val searchActive = sessionSearchQuery.isNotBlank() - if (showSearch) { + val searchActive = uiState.isSearchActive + if (showSearch || uiState.isSearchActive) { stickyHeader(key = "session_search") { SessionSearchField( - query = sessionSearchQuery, - onQueryChange = { sessionSearchQuery = it }, + query = uiState.searchQuery, + status = uiState.searchStatus, + onQueryChange = { query -> viewModel.updateSearchQuery(query, filterDirectory) }, onClose = { showSearch = false - sessionSearchQuery = "" + viewModel.updateSearchQuery("", filterDirectory) + keyboardController?.hide() }, ) } @@ -305,7 +318,11 @@ fun SessionListScreen( if (searchActive && sessionTree.isEmpty()) { item(key = "no_match") { Text( - text = stringResource(R.string.sessions_search_no_match), + text = if (uiState.isSearching || uiState.serverSearchQuery != uiState.searchQuery.trim()) { + stringResource(R.string.sessions_search_waiting) + } else { + stringResource(R.string.sessions_search_no_match) + }, style = MaterialTheme.typography.bodySmall, color = theme.textMuted, modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.lg) @@ -436,12 +453,17 @@ fun SessionListScreen( @Composable private fun SessionSearchField( query: String, + status: SessionSearchStatus?, onQueryChange: (String) -> Unit, onClose: () -> Unit, ) { val theme = LocalOpenCodeTheme.current val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.requestFocus() } + val keyboardController = LocalSoftwareKeyboardController.current + LaunchedEffect(Unit) { + focusRequester.requestFocus() + keyboardController?.show() + } Surface(color = theme.backgroundPanel, shape = RectangleShape, modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier @@ -473,6 +495,15 @@ private fun SessionSearchField( unfocusedTextColor = theme.text, ), ) + status?.let { searchStatus -> + val visual = searchStatusVisual(searchStatus, theme) + Icon( + imageVector = visual.icon, + contentDescription = visual.contentDescription, + tint = visual.color, + modifier = Modifier.size(Sizing.iconMd), + ) + } IconButton(onClick = onClose, modifier = Modifier.size(Sizing.iconButtonMd)) { Icon( Icons.Default.Close, @@ -485,6 +516,39 @@ private fun SessionSearchField( } } +@Composable +private fun searchStatusVisual( + status: SessionSearchStatus, + theme: dev.blazelight.p4oc.ui.theme.opencode.OpenCodeTheme, +): SearchStatusVisual = when (status) { + SessionSearchStatus.Searching -> SearchStatusVisual( + icon = Icons.Default.Search, + contentDescription = stringResource(R.string.sessions_search_status_searching), + color = theme.accent, + ) + SessionSearchStatus.Refining -> SearchStatusVisual( + icon = Icons.Default.FilterList, + contentDescription = stringResource(R.string.sessions_search_status_refining), + color = theme.warning, + ) + SessionSearchStatus.Current -> SearchStatusVisual( + icon = Icons.Default.CheckCircle, + contentDescription = stringResource(R.string.sessions_search_status_current), + color = theme.success, + ) + SessionSearchStatus.Failed -> SearchStatusVisual( + icon = Icons.Default.ErrorOutline, + contentDescription = stringResource(R.string.sessions_search_status_failed), + color = theme.error, + ) +} + +private data class SearchStatusVisual( + val icon: androidx.compose.ui.graphics.vector.ImageVector, + val contentDescription: String, + val color: Color, +) + private fun buildSessionTree(sessions: List): List { val childrenByParent = sessions .mapNotNull { swp -> swp.session.parentID?.let { parentId -> parentId to swp } } diff --git a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt index 75630cba..cd8b7d46 100644 --- a/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt +++ b/app/src/main/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModel.kt @@ -11,6 +11,8 @@ import dev.blazelight.p4oc.domain.model.SessionStatus import dev.blazelight.p4oc.domain.model.resolveSessionPresence import dev.blazelight.p4oc.domain.session.SessionId import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -27,8 +29,11 @@ class SessionListViewModel constructor( private companion object { const val LOAD_TIMEOUT_MS = 30_000L + const val SEARCH_DEBOUNCE_MS = 300L } + private var searchJob: Job? = null + init { viewModelScope.launch { sessionRepository.state.collect { repoState -> @@ -61,6 +66,7 @@ class SessionListViewModel constructor( sessionPresences = snapshot.statuses.mapValues { (_, status) -> resolveSessionPresence( status ) }, + searchResults = if (state.searchQuery.isBlank()) emptyList() else state.searchResults, error = (repoState as? RepoState.Stale)?.reason ?: state.error, ) } @@ -71,6 +77,8 @@ class SessionListViewModel constructor( fun refresh() { viewModelScope.launch { + val activeSearchQuery = _uiState.value.searchQuery + val activeSearchDirectory = _uiState.value.searchDirectory _uiState.update { it.copy(isLoading = true, loadingText = "Loading projects and sessions", error = null) } val result = withTimeoutOrNull(LOAD_TIMEOUT_MS) { sessionRepository.awaitOrFetch() @@ -99,6 +107,9 @@ class SessionListViewModel constructor( error = null, ) } + if (activeSearchQuery.isNotBlank()) { + searchSessions(activeSearchQuery, activeSearchDirectory, debounce = false) + } }, onFailure = { error -> _uiState.update { @@ -115,6 +126,86 @@ class SessionListViewModel constructor( } } + fun updateSearchQuery(query: String, directory: String?) { + _uiState.update { state -> + state.copy( + searchQuery = query, + searchDirectory = directory, + searchError = null, + searchResults = if (query.isBlank()) emptyList() else state.searchResults, + ) + } + searchSessions(query, directory, debounce = true) + } + + fun updateSearchDirectory(directory: String?) { + val query = _uiState.value.searchQuery + _uiState.update { it.copy(searchDirectory = directory) } + if (query.isNotBlank()) { + searchSessions(query, directory, debounce = false) + } + } + + private fun searchSessions(query: String, directory: String?, debounce: Boolean) { + searchJob?.cancel() + val trimmed = query.trim() + if (trimmed.isBlank()) { + _uiState.update { + it.copy( + isSearching = false, + serverSearchQuery = null, + searchResults = emptyList(), + searchError = null, + ) + } + return + } + + searchJob = viewModelScope.launch { + if (debounce) delay(SEARCH_DEBOUNCE_MS) + _uiState.update { it.copy(isSearching = true, searchError = null) } + val result = runCatching { sessionRepository.searchSessions(trimmed, directory) } + result.fold( + onSuccess = { sessions -> + _uiState.update { state -> + if (state.searchQuery.trim() == trimmed && state.searchDirectory == directory) { + val projects = state.projects + state.copy( + isSearching = false, + serverSearchQuery = trimmed, + searchResults = sessions.map { workspaceSession -> + val session = workspaceSession.session + val project = projects.find { it.worktree == session.directory } + SessionWithProject( + session = session, + projectId = project?.id, + projectName = (project?.worktree ?: session.directory).substringAfterLast("/"), + ) + }, + searchError = null, + ) + } else { + state + } + } + }, + onFailure = { error -> + if (error is CancellationException) throw error + _uiState.update { state -> + if (state.searchQuery.trim() == trimmed && state.searchDirectory == directory) { + state.copy( + isSearching = false, + searchError = "Search failed: ${error.message ?: "Unknown error"}", + ) + } else { + state + } + } + }, + ) + } + } + fun createSession(title: String?, directory: String? = null) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, loadingText = "Creating session", error = null) } @@ -254,11 +345,45 @@ data class SessionListUiState( val sessionStatuses: Map = emptyMap(), val sessionPresences: Map = emptyMap(), val projects: List = emptyList(), + val searchQuery: String = "", + val searchDirectory: String? = null, + val serverSearchQuery: String? = null, + val searchResults: List = emptyList(), + val isSearching: Boolean = false, + val searchError: String? = null, val newSessionId: String? = null, val newSessionDirectory: String? = null, val shareUrl: String? = null, val error: String? = null -) +) { + val isSearchActive: Boolean + get() = searchQuery.isNotBlank() + + val displayedSearchResults: List + get() { + val trimmed = searchQuery.trim() + if (trimmed.isBlank()) return emptyList() + if (serverSearchQuery == trimmed) return searchResults + return searchResults.filter { it.session.title.contains(trimmed, ignoreCase = true) } + } + + val searchStatus: SessionSearchStatus? + get() = when { + searchQuery.isBlank() -> null + searchError != null -> SessionSearchStatus.Failed + isSearching && serverSearchQuery != searchQuery.trim() -> SessionSearchStatus.Refining + isSearching -> SessionSearchStatus.Searching + serverSearchQuery == searchQuery.trim() -> SessionSearchStatus.Current + else -> SessionSearchStatus.Refining + } +} + +sealed interface SessionSearchStatus { + data object Searching : SessionSearchStatus + data object Refining : SessionSearchStatus + data object Current : SessionSearchStatus + data object Failed : SessionSearchStatus +} data class ProjectInfo( val id: String, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7823612a..990cd871 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -142,8 +142,13 @@ Sessions No sessions yet Tap above to start a new session - Search chats… - No matching chats + Search session titles… + No matching sessions + Searching… + Searching + Refining + Current + Failed Loading sessions New Session Delete diff --git a/app/src/test/java/dev/blazelight/p4oc/fakes/FakeWorkspaceClient.kt b/app/src/test/java/dev/blazelight/p4oc/fakes/FakeWorkspaceClient.kt index a2e0ad7b..75f8dd15 100644 --- a/app/src/test/java/dev/blazelight/p4oc/fakes/FakeWorkspaceClient.kt +++ b/app/src/test/java/dev/blazelight/p4oc/fakes/FakeWorkspaceClient.kt @@ -36,13 +36,24 @@ class FakeWorkspaceClient( var abortSessionCalls: Int = 0 private set + data class ListSessionsCall( + val directory: String?, + val scope: String?, + val roots: Boolean?, + val start: Long?, + val search: String?, + val limit: Int?, + ) + val listSessionsDirectories = mutableListOf() val listSessionsScopes = mutableListOf() + val listSessionsCallsLog = mutableListOf() val getSessionStatusesDirectories = mutableListOf() var projects: List = emptyList() var listSessionsResult: List = emptyList() var sessionsByDirectory: Map>? = null + var sessionsByDirectoryAndSearch: Map, List> = emptyMap() var statusesByDirectory: Map> = emptyMap() var listSessionsFailure: Throwable? = null var getSessionResults: MutableMap = mutableMapOf() @@ -72,8 +83,11 @@ class FakeWorkspaceClient( listSessionsCalls += 1 listSessionsDirectories += directory listSessionsScopes += scope + listSessionsCallsLog += ListSessionsCall(directory, scope, roots, start, search, limit) listSessionsFailure?.let { throw it } - return sessionsByDirectory?.get(directory) ?: listSessionsResult + return sessionsByDirectoryAndSearch[Pair(directory, search)] + ?: sessionsByDirectory?.get(directory) + ?: listSessionsResult } override suspend fun getSession(id: String): SessionDto { diff --git a/app/src/test/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModelTest.kt b/app/src/test/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModelTest.kt index 77d5ed8f..86bccc82 100644 --- a/app/src/test/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModelTest.kt +++ b/app/src/test/java/dev/blazelight/p4oc/ui/screens/sessions/SessionListViewModelTest.kt @@ -6,19 +6,24 @@ import dev.blazelight.p4oc.fakes.FakeWorkspaceClient import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlinx.serialization.json.Json import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class SessionListViewModelTest { - private val dispatcher = UnconfinedTestDispatcher() + private val dispatcher = StandardTestDispatcher() @Before fun setUp() { @@ -31,20 +36,127 @@ class SessionListViewModelTest { } @Test - fun createSession_cancellationDoesNotSetError() = runTest { + fun createSession_cancellationDoesNotSetError() = runTest(dispatcher) { val client = FakeWorkspaceClient().apply { createSessionFailure = CancellationException("leaving screen") } - val repository = SessionRepositoryImpl( - client = client, - messageMapper = MessageMapper(Json { ignoreUnknownKeys = true }), - dispatcher = dispatcher, - ) + val repository = repository(client) val viewModel = SessionListViewModel(repository) viewModel.createSession(title = "new") + advanceUntilIdle() assertNull(viewModel.uiState.value.error) repository.close() } + + @Test + fun updateSearchQuery_searchesServerAfterDebounce() = runTest(dispatcher) { + val client = FakeWorkspaceClient().apply { + projects = listOf(FakeWorkspaceClient.projectDto("p1", "/project")) + sessionsByDirectoryAndSearch = mapOf( + Pair(null, "apple") to listOf(FakeWorkspaceClient.sessionDto("global", title = "apple global", directory = "/global")), + Pair("/project", "apple") to listOf(FakeWorkspaceClient.sessionDto("project", title = "apple project", directory = "/project")), + ) + } + val repository = repository(client) + val viewModel = SessionListViewModel(repository) + advanceUntilIdle() + + viewModel.updateSearchQuery("apple", directory = null) + advanceTimeBy(299) + assertTrue(client.listSessionsCallsLog.none { it.search == "apple" }) + + advanceTimeBy(1) + advanceUntilIdle() + + val searchCalls = client.listSessionsCallsLog.filter { it.search == "apple" } + assertEquals(listOf(null, "/project"), searchCalls.map { it.directory }) + assertTrue(searchCalls.all { it.scope == null && it.roots == true && it.limit == 100 }) + assertEquals(listOf("global", "project"), viewModel.uiState.value.searchResults.map { it.session.id }) + assertEquals(SessionSearchStatus.Current, viewModel.uiState.value.searchStatus) + repository.close() + } + + @Test + fun updateSearchQuery_refinesPreviousServerResultsWhileNextSearchPending() = runTest(dispatcher) { + val client = FakeWorkspaceClient().apply { + sessionsByDirectoryAndSearch = mapOf( + Pair(null, "app") to listOf( + FakeWorkspaceClient.sessionDto("apple", title = "apple migration"), + FakeWorkspaceClient.sessionDto("apricot", title = "apricot cleanup"), + ), + Pair(null, "apple") to listOf(FakeWorkspaceClient.sessionDto("apple", title = "apple migration")), + ) + } + val repository = repository(client) + val viewModel = SessionListViewModel(repository) + advanceUntilIdle() + + viewModel.updateSearchQuery("app", directory = null) + advanceTimeBy(300) + advanceUntilIdle() + assertEquals(listOf("apple", "apricot"), viewModel.uiState.value.displayedSearchResults.map { it.session.id }) + + viewModel.updateSearchQuery("apple", directory = null) + assertEquals(listOf("apple"), viewModel.uiState.value.displayedSearchResults.map { it.session.id }) + assertEquals(SessionSearchStatus.Refining, viewModel.uiState.value.searchStatus) + + advanceTimeBy(300) + advanceUntilIdle() + assertEquals("apple", viewModel.uiState.value.serverSearchQuery) + assertEquals(listOf("apple"), viewModel.uiState.value.displayedSearchResults.map { it.session.id }) + assertEquals(SessionSearchStatus.Current, viewModel.uiState.value.searchStatus) + repository.close() + } + + @Test + fun updateSearchQuery_setsFailedStatusWhenServerSearchFails() = runTest(dispatcher) { + val client = FakeWorkspaceClient().apply { + listSessionsFailure = IllegalStateException("network down") + } + val repository = repository(client) + val viewModel = SessionListViewModel(repository) + advanceUntilIdle() + + viewModel.updateSearchQuery("apple", directory = "/project") + advanceTimeBy(300) + advanceUntilIdle() + + assertEquals(SessionSearchStatus.Failed, viewModel.uiState.value.searchStatus) + assertEquals("Search failed: network down", viewModel.uiState.value.searchError) + repository.close() + } + + @Test + fun updateSearchQuery_clearingQueryRestoresNormalList() = runTest(dispatcher) { + val client = FakeWorkspaceClient().apply { + setSessions(FakeWorkspaceClient.sessionDto("normal", title = "normal")) + sessionsByDirectoryAndSearch = mapOf( + Pair(null, "apple") to listOf(FakeWorkspaceClient.sessionDto("apple", title = "apple migration")), + ) + } + val repository = repository(client) + val viewModel = SessionListViewModel(repository) + advanceUntilIdle() + + viewModel.updateSearchQuery("apple", directory = null) + advanceTimeBy(300) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.isSearchActive) + + viewModel.updateSearchQuery("", directory = null) + advanceUntilIdle() + + assertFalse(viewModel.uiState.value.isSearchActive) + assertTrue(viewModel.uiState.value.searchResults.isEmpty()) + assertNull(viewModel.uiState.value.searchStatus) + repository.close() + } + + private fun repository(client: FakeWorkspaceClient): SessionRepositoryImpl = SessionRepositoryImpl( + client = client, + messageMapper = MessageMapper(Json { ignoreUnknownKeys = true }), + dispatcher = dispatcher, + ) }