diff --git a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageContract.kt b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageContract.kt index 0256aec5fe7..0227b384ce3 100644 --- a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageContract.kt +++ b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageContract.kt @@ -7,11 +7,8 @@ data class ManageUiState( val url: String, val discovered: String?, val status: ServerState, - val projectPath: String, - val projectQuery: String, val loadingProjects: Boolean, - val favoriteProjects: List, - val otherProjects: List, + val projects: List, val selectedProject: String?, val message: String?, ) @@ -20,11 +17,7 @@ sealed interface ManageEvent { data class Connect(val url: String) : ManageEvent - data class ProjectPathChanged(val value: String) : ManageEvent - - data class ProjectQueryChanged(val value: String) : ManageEvent - - data object LoadProjectRequested : ManageEvent + data class LoadProjectRequested(val worktree: String) : ManageEvent data class ProjectSelected(val worktree: String) : ManageEvent diff --git a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageScreen.kt b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageScreen.kt index ab905293523..210ecaaf0ba 100644 --- a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageScreen.kt +++ b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageScreen.kt @@ -98,30 +98,20 @@ fun ManageScreen(state: ManageUiState, onEvent: (ManageEvent) -> Unit) { @Composable private fun ProjectListCard(state: ManageUiState, onEvent: (ManageEvent) -> Unit) { - var filterOpen by remember { mutableStateOf(state.projectQuery.isNotBlank()) } + var filterOpen by remember { mutableStateOf(false) } var pathOpen by remember { mutableStateOf(false) } var projectsExpanded by remember { mutableStateOf(false) } - var query by remember { mutableStateOf(TextFieldValue(state.projectQuery)) } - var path by remember { mutableStateOf(TextFieldValue(state.projectPath)) } + var query by remember { mutableStateOf(TextFieldValue("")) } + var path by remember { mutableStateOf(TextFieldValue(state.selectedProject.orEmpty())) } + val projects = filterProjects(state.projects, query.text) + val favoriteProjects = projects.filter { it.favorite } - LaunchedEffect(state.projectQuery) { - if (state.projectQuery.isBlank()) return@LaunchedEffect - filterOpen = true - } - - LaunchedEffect(state.projectQuery) { - if (state.projectQuery == query.text) return@LaunchedEffect - query = TextFieldValue( - text = state.projectQuery, - selection = TextRange(state.projectQuery.length), - ) - } - - LaunchedEffect(state.projectPath) { - if (state.projectPath == path.text) return@LaunchedEffect + LaunchedEffect(state.selectedProject) { + val selected = state.selectedProject.orEmpty() + if (selected == path.text) return@LaunchedEffect path = TextFieldValue( - text = state.projectPath, - selection = TextRange(state.projectPath.length), + text = selected, + selection = TextRange(selected.length), ) } @@ -170,7 +160,6 @@ private fun ProjectListCard(state: ManageUiState, onEvent: (ManageEvent) -> Unit value = query, onValueChange = { query = it - onEvent(ManageEvent.ProjectQueryChanged(it.text)) }, label = { Text("Search projects") }, singleLine = true, @@ -184,7 +173,6 @@ private fun ProjectListCard(state: ManageUiState, onEvent: (ManageEvent) -> Unit value = path, onValueChange = { path = it - onEvent(ManageEvent.ProjectPathChanged(it.text)) }, label = { Text("Open project path") }, singleLine = true, @@ -192,10 +180,7 @@ private fun ProjectListCard(state: ManageUiState, onEvent: (ManageEvent) -> Unit ) Button( onClick = { - if (state.projectPath != path.text) { - onEvent(ManageEvent.ProjectPathChanged(path.text)) - } - onEvent(ManageEvent.LoadProjectRequested) + onEvent(ManageEvent.LoadProjectRequested(path.text)) }, modifier = Modifier.fillMaxWidth(), ) { @@ -204,7 +189,7 @@ private fun ProjectListCard(state: ManageUiState, onEvent: (ManageEvent) -> Unit } } - if (state.favoriteProjects.isEmpty() && state.otherProjects.isEmpty()) { + if (projects.isEmpty()) { Text( text = if (state.loadingProjects) "Loading projects..." else "No projects found", color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -212,8 +197,8 @@ private fun ProjectListCard(state: ManageUiState, onEvent: (ManageEvent) -> Unit return@Column } - if (state.favoriteProjects.isNotEmpty()) { - state.favoriteProjects.chunked(2).forEach { row -> + if (favoriteProjects.isNotEmpty()) { + favoriteProjects.chunked(2).forEach { row -> Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -236,23 +221,10 @@ private fun ProjectListCard(state: ManageUiState, onEvent: (ManageEvent) -> Unit } val removable = state.selectedProject?.takeIf { selected -> - (state.favoriteProjects + state.otherProjects) - .any { workspaceId(it.worktree) == workspaceId(selected) } + projects.any { workspaceId(it.worktree) == workspaceId(selected) } } - if (state.otherProjects.isEmpty()) { - if (removable != null) { - OutlinedButton( - onClick = { onEvent(ManageEvent.ProjectRemoved(removable)) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text("Remove selected project") - } - } - return@Column - } - - val count = state.otherProjects.size + val count = projects.size val suffix = if (count == 1) "project" else "projects" Row( modifier = Modifier @@ -275,9 +247,9 @@ private fun ProjectListCard(state: ManageUiState, onEvent: (ManageEvent) -> Unit Icon( imageVector = if (projectsExpanded) Icons.ChevronUp else Icons.ChevronDown, contentDescription = if (projectsExpanded) { - "Collapse other projects" + "Collapse projects" } else { - "Expand other projects" + "Expand projects" }, tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -294,7 +266,7 @@ private fun ProjectListCard(state: ManageUiState, onEvent: (ManageEvent) -> Unit return@Column } - state.otherProjects.forEach { + projects.forEach { ProjectCard( project = it, selected = workspaceId(state.selectedProject.orEmpty()) == workspaceId(it.worktree), @@ -384,3 +356,11 @@ private fun workspaceId(path: String): String { if (value.isBlank()) return path return value } + +private fun filterProjects(projects: List, query: String): List { + val value = query.trim().lowercase() + if (value.isBlank()) return projects + return projects.filter { + it.name.lowercase().contains(value) || it.worktree.lowercase().contains(value) + } +} diff --git a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModel.kt b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModel.kt index 110637549fb..d766170efb9 100644 --- a/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModel.kt +++ b/packages/mobile/app/src/main/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModel.kt @@ -9,12 +9,11 @@ import de.chennemann.opencode.mobile.domain.session.SessionServiceApi import de.chennemann.opencode.mobile.navigation.LogsRoute import de.chennemann.opencode.mobile.navigation.NavEvent import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn class ManageViewModel( @@ -22,38 +21,23 @@ class ManageViewModel( private val dispatchers: DispatcherProvider, ) : ViewModel() { private val lane = dispatchers.default.limitedParallelism(1) - - private data class LocalState( - val projectPath: String, - val projectQuery: String, - ) - - private val local = MutableStateFlow( - LocalState( - projectPath = service.state.value.selectedProject.orEmpty(), - projectQuery = "", - ) - ) private val navFlow = MutableSharedFlow(extraBufferCapacity = 1) val nav = navFlow.asSharedFlow() - val state: StateFlow = combine(service.state, local) { global, local -> - val listed = global.projects.map(::displayProject) - val projects = filterProjects(listed, local.projectQuery) - ManageUiState( - url = global.url, - discovered = global.discovered, - status = global.status, - projectPath = local.projectPath, - projectQuery = local.projectQuery, - loadingProjects = global.loadingProjects, - favoriteProjects = projects.filter { it.favorite }, - otherProjects = projects.filterNot { it.favorite }, - selectedProject = global.selectedProject, - message = global.message, - ) - } + val state: StateFlow = service.state + .map { global -> + val projects = global.projects.map(::displayProject) + ManageUiState( + url = global.url, + discovered = global.discovered, + status = global.status, + loadingProjects = global.loadingProjects, + projects = projects, + selectedProject = global.selectedProject, + message = global.message, + ) + } .flowOn(lane) .stateIn( scope = viewModelScope, @@ -62,11 +46,8 @@ class ManageViewModel( url = service.state.value.url, discovered = service.state.value.discovered, status = ServerState.Idle, - projectPath = service.state.value.selectedProject.orEmpty(), - projectQuery = "", loadingProjects = false, - favoriteProjects = emptyList(), - otherProjects = emptyList(), + projects = emptyList(), selectedProject = null, message = null, ), @@ -82,27 +63,13 @@ class ManageViewModel( service.updateUrl(event.url) service.refresh() } - is ManageEvent.ProjectPathChanged -> { - local.value = local.value.copy(projectPath = event.value) - } - - is ManageEvent.ProjectQueryChanged -> { - local.value = local.value.copy(projectQuery = event.value) - } - - ManageEvent.LoadProjectRequested -> { - val worktree = local.value.projectPath.trim() + is ManageEvent.LoadProjectRequested -> { + val worktree = event.worktree.trim() if (worktree.isBlank()) return service.selectProject(worktree) - local.value = local.value.copy( - projectPath = worktree, - ) } is ManageEvent.ProjectSelected -> { - local.value = local.value.copy( - projectPath = event.worktree, - ) service.selectProject(event.worktree) } @@ -124,14 +91,6 @@ class ManageViewModel( } } - private fun filterProjects(projects: List, query: String): List { - val value = query.trim().lowercase() - if (value.isBlank()) return projects - return projects.filter { - it.name.lowercase().contains(value) || it.worktree.lowercase().contains(value) - } - } - private fun displayProject(project: ProjectState): ProjectState { return project.copy(name = folderName(project.worktree)) } diff --git a/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModelTest.kt b/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModelTest.kt index e788ebe3478..f4028dbe456 100644 --- a/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModelTest.kt +++ b/packages/mobile/app/src/test/kotlin/de/chennemann/opencode/mobile/ui/manage/ManageViewModelTest.kt @@ -53,8 +53,8 @@ class ManageViewModelTest { val value = viewModel.state.value assertEquals(1, service.startCalls) - assertEquals(listOf("main"), value.favoriteProjects.map { it.name }) - assertEquals(listOf("other"), value.otherProjects.map { it.name }) + assertEquals(listOf("main", "other"), value.projects.map { it.name }) + assertEquals(listOf(true, false), value.projects.map { it.favorite }) assertEquals("/repo/main", value.selectedProject) collect.cancel() } @@ -83,32 +83,30 @@ class ManageViewModelTest { } @Test - fun filtersProjectsByQueryFromNameAndPath() = runTest(TestCoroutineScheduler()) { + fun loadsProjectFromRequestedPath() = runTest(TestCoroutineScheduler()) { val main = StandardTestDispatcher(testScheduler) Dispatchers.setMain(main) val worker = StandardTestDispatcher(testScheduler) val service = StubSessionService() val viewModel = ManageViewModel(service, lanes(main, worker)) - val collect = backgroundScope.launch(worker) { viewModel.state.collect {} } - service.state.value = state( - projects = listOf( - ProjectState(id = "p1", worktree = "/repo/alpha", name = "Alpha", favorite = true), - ProjectState(id = "p2", worktree = "/workspaces/beta", name = "Beta", favorite = false), - ), - selectedProject = "/repo/alpha", - ) + viewModel.onEvent(ManageEvent.LoadProjectRequested(" /repo/alpha ")) advanceUntilIdle() - viewModel.onEvent(ManageEvent.ProjectQueryChanged(" ALPha ")) - advanceUntilIdle() - assertEquals(listOf("alpha"), viewModel.state.value.favoriteProjects.map { it.name }) - assertEquals(emptyList(), viewModel.state.value.otherProjects) + assertEquals(listOf("/repo/alpha"), service.selectRequests) + } - viewModel.onEvent(ManageEvent.ProjectQueryChanged("WORKSPACES")) + @Test + fun ignoresBlankLoadProjectRequest() = runTest(TestCoroutineScheduler()) { + val main = StandardTestDispatcher(testScheduler) + Dispatchers.setMain(main) + val worker = StandardTestDispatcher(testScheduler) + val service = StubSessionService() + val viewModel = ManageViewModel(service, lanes(main, worker)) + + viewModel.onEvent(ManageEvent.LoadProjectRequested(" ")) advanceUntilIdle() - assertEquals(emptyList(), viewModel.state.value.favoriteProjects) - assertEquals(listOf("beta"), viewModel.state.value.otherProjects.map { it.name }) - collect.cancel() + + assertEquals(emptyList(), service.selectRequests) } @Test @@ -221,6 +219,7 @@ private class StubSessionService : SessionServiceApi { ) var startCalls = 0 var refreshCalls = 0 + val selectRequests = mutableListOf() val removeRequests = mutableListOf() val urls = mutableListOf() @@ -238,7 +237,9 @@ private class StubSessionService : SessionServiceApi { refreshCalls += 1 } - override fun selectProject(worktree: String) = Unit + override fun selectProject(worktree: String) { + selectRequests += worktree + } override fun toggleProjectFavorite(worktree: String) = Unit