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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })();
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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