Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ import de.chennemann.opencode.mobile.domain.session.SessionStreamCoordinator
import de.chennemann.opencode.mobile.domain.session.StreamGateway
import de.chennemann.opencode.mobile.domain.v2.DefaultSynchronizationService
import de.chennemann.opencode.mobile.domain.v2.projects.ProjectRepository
import de.chennemann.opencode.mobile.domain.v2.projects.DefaultProjectService
import de.chennemann.opencode.mobile.domain.v2.session.SessionRepository
import de.chennemann.opencode.mobile.domain.v2.session.DefaultSessionService
import de.chennemann.opencode.mobile.domain.v2.servers.DefaultServerService
import de.chennemann.opencode.mobile.domain.v2.message.MessageRepository as MessageRepositoryV2
import de.chennemann.opencode.mobile.domain.v2.message.MessageService as MessageServiceV2
import de.chennemann.opencode.mobile.domain.v2.projects.ProjectService as ProjectServiceV2
import de.chennemann.opencode.mobile.domain.v2.session.SessionService as SessionServiceV2
import de.chennemann.opencode.mobile.domain.v2.servers.ServerRepository as ServerRepositoryV2
import de.chennemann.opencode.mobile.domain.v2.servers.ServerService as ServerServiceV2
Expand Down Expand Up @@ -107,6 +109,7 @@ val appModule = module {
single<MessageRepositoryV2> { SqlDelightMessageRepository(get(), get()) }
single<SynchronizationServiceV2> { DefaultSynchronizationService(get(), get(), get(), get()) }
single<ServerServiceV2> { DefaultServerService(get(), get(), get()) }
single<ProjectServiceV2> { DefaultProjectService(get()) }
single<SessionServiceV2> { DefaultSessionService(get()) }
single<MessageServiceV2> { DefaultMessageService(get()) }
single<ConnectionGateway> { get<ServerRepository>() }
Expand All @@ -129,6 +132,6 @@ val appModule = module {
single<SessionServiceApi> { get<SessionService>() }
viewModel { ConversationViewModel(get(), get()) }
viewModel { (projectKey: String) -> SessionSelectionViewModel(projectKey, get(), get(), get()) }
viewModel { ManageViewModel(get(), get()) }
viewModel { ManageViewModel(get(), get(), get()) }
viewModel { LogsViewModel(get(), get()) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package de.chennemann.opencode.mobile.domain.v2.projects

import kotlinx.coroutines.flow.Flow

interface ProjectService {
fun observeProjects(serverId: String? = null): Flow<List<LocalProjectInfo>>

suspend fun togglePinnedById(projectId: String): Boolean
}

class DefaultProjectService(
private val projectRepository: ProjectRepository,
) : ProjectService {
override fun observeProjects(serverId: String?): Flow<List<LocalProjectInfo>> {
return projectRepository.observeProjects(serverId)
}

override suspend fun togglePinnedById(projectId: String): Boolean {
val id = projectId.trim()
if (id.isBlank()) return false
val project = projectRepository.selectProject(id) ?: return false
projectRepository.updateProject(
project.copy(pinned = !project.pinned)
)
return true
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ sealed interface ManageEvent {

data class ProjectSelected(val worktree: String) : ManageEvent

data class ProjectFavoriteToggled(val worktree: String) : ManageEvent
data class ProjectFavoriteToggled(val projectId: String) : ManageEvent

data class ProjectRemoved(val worktree: String) : ManageEvent

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ private fun ProjectListCard(state: ManageUiState, onEvent: (ManageEvent) -> Unit
selected = workspaceId(state.selectedProject.orEmpty()) == workspaceId(project.worktree),
compact = true,
modifier = Modifier.weight(1f),
onFavoriteToggle = { onEvent(ManageEvent.ProjectFavoriteToggled(project.worktree)) },
onFavoriteToggle = { onEvent(ManageEvent.ProjectFavoriteToggled(project.id)) },
onSelect = { onEvent(ManageEvent.ProjectSelected(project.worktree)) },
)
}
Expand Down Expand Up @@ -272,7 +272,7 @@ private fun ProjectListCard(state: ManageUiState, onEvent: (ManageEvent) -> Unit
selected = workspaceId(state.selectedProject.orEmpty()) == workspaceId(it.worktree),
compact = false,
modifier = Modifier.fillMaxWidth(),
onFavoriteToggle = { onEvent(ManageEvent.ProjectFavoriteToggled(it.worktree)) },
onFavoriteToggle = { onEvent(ManageEvent.ProjectFavoriteToggled(it.id)) },
onSelect = { onEvent(ManageEvent.ProjectSelected(it.worktree)) },
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,37 @@ import de.chennemann.opencode.mobile.di.DispatcherProvider
import de.chennemann.opencode.mobile.domain.session.ProjectState
import de.chennemann.opencode.mobile.domain.session.ServerState
import de.chennemann.opencode.mobile.domain.session.SessionServiceApi
import de.chennemann.opencode.mobile.domain.v2.projects.LocalProjectInfo
import de.chennemann.opencode.mobile.domain.v2.projects.ProjectService as ProjectServiceV2
import de.chennemann.opencode.mobile.navigation.LogsRoute
import de.chennemann.opencode.mobile.navigation.NavEvent
import kotlinx.coroutines.flow.MutableSharedFlow
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
import kotlinx.coroutines.launch

class ManageViewModel(
private val service: SessionServiceApi,
private val projectService: ProjectServiceV2,
private val dispatchers: DispatcherProvider,
) : ViewModel() {
private val lane = dispatchers.default.limitedParallelism(1)
private val navFlow = MutableSharedFlow<NavEvent>(extraBufferCapacity = 1)

val nav = navFlow.asSharedFlow()

val state: StateFlow<ManageUiState> = service.state
.map { global ->
val projects = global.projects.map(::displayProject)
val state: StateFlow<ManageUiState> = combine(service.state, projectService.observeProjects()) { global, projects ->
val displayed = projects.map(::displayProject)
ManageUiState(
url = global.url,
discovered = global.discovered,
status = global.status,
loadingProjects = global.loadingProjects,
projects = projects,
projects = displayed,
selectedProject = global.selectedProject,
message = global.message,
)
Expand Down Expand Up @@ -74,7 +77,9 @@ class ManageViewModel(
}

is ManageEvent.ProjectFavoriteToggled -> {
service.toggleProjectFavorite(event.worktree)
viewModelScope.launch(lane) {
projectService.togglePinnedById(event.projectId)
}
}

is ManageEvent.ProjectRemoved -> {
Expand All @@ -91,8 +96,14 @@ class ManageViewModel(
}
}

private fun displayProject(project: ProjectState): ProjectState {
return project.copy(name = folderName(project.worktree))
private fun displayProject(project: LocalProjectInfo): ProjectState {
return ProjectState(
id = project.id,
worktree = project.path,
name = folderName(project.path),
sandboxes = emptyList(),
favorite = project.pinned,
)
}

private fun folderName(path: String): String {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package de.chennemann.opencode.mobile.domain.v2.projects

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class ProjectServiceTest {
@Test
fun observeProjectsDelegatesToRepository() {
val repository = FakeProjectRepository()
val service = DefaultProjectService(repository)

val observed = service.observeProjects("server-1")

assertSame(repository.flow, observed)
assertEquals("server-1", repository.lastObservedServerId)
}

@Test
fun togglePinnedByIdFlipsPinnedAndPersists() = runTest {
val repository = FakeProjectRepository(
initial = linkedMapOf(
"p1" to LocalProjectInfo(
id = "p1",
serverId = "server-1",
name = "Main",
path = "/repo/main",
pinned = false,
)
)
)
val service = DefaultProjectService(repository)

val toggled = service.togglePinnedById(" p1 ")

assertTrue(toggled)
assertEquals(true, repository.projects["p1"]?.pinned)
assertEquals(listOf("p1"), repository.updateCalls.map { it.id })
}

@Test
fun togglePinnedByIdReturnsFalseForMissingOrBlankId() = runTest {
val repository = FakeProjectRepository()
val service = DefaultProjectService(repository)

assertFalse(service.togglePinnedById(" "))
assertFalse(service.togglePinnedById("missing"))
assertTrue(repository.updateCalls.isEmpty())
}
}

private class FakeProjectRepository(
initial: LinkedHashMap<String, LocalProjectInfo> = linkedMapOf(),
) : ProjectRepository {
val projects = initial
val flow = flowOf(projects.values.toList())
val updateCalls = mutableListOf<LocalProjectInfo>()
var lastObservedServerId: String? = null

override fun observeProjects(serverId: String?): Flow<List<LocalProjectInfo>> {
lastObservedServerId = serverId
return flow
}

override suspend fun selectProject(id: String): LocalProjectInfo? {
return projects[id]
}

override suspend fun insertProject(project: LocalProjectInfo) {
projects[project.id] = project
}

override suspend fun updateProject(project: LocalProjectInfo) {
updateCalls += project
projects[project.id] = project
}

override suspend fun deleteProject(id: String) {
projects.remove(id)
}
}
Loading