feat: browse repository branches and tags
This commit is contained in:
parent
8a47d30ab5
commit
bbbff77a5e
@ -32,6 +32,10 @@ class MainActivity : ComponentActivity() {
|
||||
onCancelSignIn = viewModel::cancelSignIn,
|
||||
onRetry = viewModel::retry,
|
||||
onLogout = viewModel::logout,
|
||||
onRepositorySelected = viewModel::selectRepository,
|
||||
onReferenceSelected = viewModel::selectReference,
|
||||
onReferencesRefresh = viewModel::refreshReferences,
|
||||
onBackToRepositories = viewModel::backToRepositories,
|
||||
)
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.browserRequests.collect(::openSystemBrowser)
|
||||
|
||||
@ -26,12 +26,17 @@ data class AuthenticatedUser(
|
||||
val displayName: String?,
|
||||
)
|
||||
|
||||
interface AuthenticationGateway {
|
||||
suspend fun exchangeCode(authorizationCode: String, verifier: String, nowEpochMillis: Long): TokenSet
|
||||
suspend fun getAuthenticatedUser(accessToken: String): AuthenticatedUser
|
||||
}
|
||||
|
||||
class AuthApi(
|
||||
private val config: OAuthClientConfig,
|
||||
private val transport: HttpTransport,
|
||||
private val gson: Gson = Gson(),
|
||||
) {
|
||||
suspend fun exchangeCode(
|
||||
) : AuthenticationGateway {
|
||||
override suspend fun exchangeCode(
|
||||
authorizationCode: String,
|
||||
verifier: String,
|
||||
nowEpochMillis: Long,
|
||||
@ -56,7 +61,7 @@ class AuthApi(
|
||||
return TokenSet(accessToken, dto.refreshToken?.takeIf(String::isNotBlank), expiresAt)
|
||||
}
|
||||
|
||||
suspend fun getAuthenticatedUser(accessToken: String): AuthenticatedUser {
|
||||
override suspend fun getAuthenticatedUser(accessToken: String): AuthenticatedUser {
|
||||
val response = transport.get(
|
||||
config.baseUri.resolve("/api/v1/user").toString(),
|
||||
accessToken,
|
||||
|
||||
@ -18,19 +18,23 @@ data class AccessibleRepository(
|
||||
val defaultBranch: String?,
|
||||
)
|
||||
|
||||
fun interface AccessibleRepositoriesGateway {
|
||||
suspend fun listAll(accessToken: String): List<AccessibleRepository>
|
||||
}
|
||||
|
||||
class RepositoryListApi(
|
||||
private val config: OAuthClientConfig,
|
||||
private val transport: HttpTransport,
|
||||
private val gson: Gson = Gson(),
|
||||
private val pageSize: Int = 50,
|
||||
private val maxPages: Int = 100,
|
||||
) {
|
||||
) : AccessibleRepositoriesGateway {
|
||||
init {
|
||||
require(pageSize in 1..100) { "Page size must be between 1 and 100" }
|
||||
require(maxPages > 0) { "Max pages must be positive" }
|
||||
}
|
||||
|
||||
suspend fun listAll(accessToken: String): List<AccessibleRepository> {
|
||||
override suspend fun listAll(accessToken: String): List<AccessibleRepository> {
|
||||
val repositories = linkedMapOf<Long, AccessibleRepository>()
|
||||
for (page in 1..maxPages) {
|
||||
val url = config.baseUri.resolve("/api/v1/user/repos?page=$page&limit=$pageSize").toString()
|
||||
|
||||
@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
@ -21,6 +23,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.activity.compose.BackHandler
|
||||
import com.ubisam.ubgitea.R
|
||||
|
||||
@Composable
|
||||
@ -30,6 +34,10 @@ fun RepositoryListScreen(
|
||||
onCancelSignIn: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onRepositorySelected: (RepositoryRowUiModel) -> Unit,
|
||||
onReferenceSelected: (String, ReferenceKind) -> Unit,
|
||||
onReferencesRefresh: () -> Unit,
|
||||
onBackToRepositories: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Scaffold(modifier = modifier.fillMaxSize()) { padding ->
|
||||
@ -73,8 +81,38 @@ fun RepositoryListScreen(
|
||||
is RepositoryListUiState.Content -> RepositoryContent(
|
||||
state,
|
||||
onLogout,
|
||||
onRepositorySelected,
|
||||
Modifier.padding(padding),
|
||||
)
|
||||
is RepositoryListUiState.ReferencesLoading -> {
|
||||
BackHandler(onBack = onBackToRepositories)
|
||||
LoadingState(
|
||||
stringResource(R.string.repositories_references_loading, state.repository.name),
|
||||
Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
is RepositoryListUiState.ReferencesContent -> {
|
||||
BackHandler(onBack = onBackToRepositories)
|
||||
ReferencesContent(
|
||||
state,
|
||||
onReferenceSelected,
|
||||
onReferencesRefresh,
|
||||
onBackToRepositories,
|
||||
onLogout,
|
||||
Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
is RepositoryListUiState.ReferencesError -> {
|
||||
BackHandler(onBack = onBackToRepositories)
|
||||
MessageState(
|
||||
stringResource(state.type.messageResource),
|
||||
stringResource(R.string.repositories_retry),
|
||||
onRetry,
|
||||
Modifier.padding(padding),
|
||||
secondaryAction = stringResource(R.string.repositories_back),
|
||||
onSecondaryAction = onBackToRepositories,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -117,6 +155,7 @@ private fun MessageState(
|
||||
private fun RepositoryContent(
|
||||
state: RepositoryListUiState.Content,
|
||||
onLogout: () -> Unit,
|
||||
onRepositorySelected: (RepositoryRowUiModel) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||
@ -134,7 +173,12 @@ private fun RepositoryContent(
|
||||
}
|
||||
}
|
||||
items(state.repositories, key = RepositoryRowUiModel::id) { repository ->
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(role = Role.Button) { onRepositorySelected(repository) }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text("${repository.owner}/${repository.name}", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(
|
||||
@ -154,6 +198,84 @@ private fun RepositoryContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReferencesContent(
|
||||
state: RepositoryListUiState.ReferencesContent,
|
||||
onReferenceSelected: (String, ReferenceKind) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||
item {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = onBack) { Text(stringResource(R.string.repositories_back)) }
|
||||
Column(Modifier.padding(start = 12.dp).weight(1f)) {
|
||||
Text("${state.repository.owner}/${state.repository.name}", style = MaterialTheme.typography.titleLarge)
|
||||
Text(stringResource(R.string.repositories_account, state.accountName))
|
||||
}
|
||||
OutlinedButton(onClick = onLogout) { Text(stringResource(R.string.repositories_logout)) }
|
||||
}
|
||||
}
|
||||
item {
|
||||
OutlinedButton(
|
||||
onClick = onRefresh,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
) { Text(stringResource(R.string.repositories_refresh)) }
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.repositories_branches_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
if (state.branches.isEmpty()) {
|
||||
item { Text(stringResource(R.string.repositories_branches_empty), Modifier.padding(horizontal = 16.dp)) }
|
||||
}
|
||||
items(state.branches, key = { "branch:${it.name}" }) { reference ->
|
||||
ReferenceRow(reference, onReferenceSelected)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.repositories_tags_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
)
|
||||
}
|
||||
if (state.tags.isEmpty()) {
|
||||
item { Text(stringResource(R.string.repositories_tags_empty), Modifier.padding(horizontal = 16.dp)) }
|
||||
}
|
||||
items(state.tags, key = { "tag:${it.name}" }) { reference ->
|
||||
ReferenceRow(reference, onReferenceSelected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReferenceRow(reference: ReferenceRowUiModel, onSelected: (String, ReferenceKind) -> Unit) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = reference.isSelected,
|
||||
role = Role.RadioButton,
|
||||
onClick = { onSelected(reference.name, reference.kind) },
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
) {
|
||||
Text(reference.name, style = MaterialTheme.typography.titleMedium)
|
||||
Text(reference.commitId.take(12), style = MaterialTheme.typography.bodySmall)
|
||||
if (reference.isSelected) Text(stringResource(R.string.repositories_selected_reference))
|
||||
if (reference.isProtected) Text(stringResource(R.string.repositories_protected_branch))
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
private val RepositoryListError.messageResource: Int
|
||||
get() = when (this) {
|
||||
RepositoryListError.AUTHENTICATION_EXPIRED -> R.string.repositories_auth_expired
|
||||
|
||||
@ -1,22 +1,28 @@
|
||||
package com.ubisam.ubgitea.feature.repositories
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.ubisam.ubgitea.core.auth.android.AndroidKeystoreTokenVault
|
||||
import com.ubisam.ubgitea.core.auth.android.PendingAuthStore
|
||||
import com.ubisam.ubgitea.core.auth.android.PendingAuthTransactionStore
|
||||
import com.ubisam.ubgitea.core.auth.protocol.OAuthAuthorizationRequestFactory
|
||||
import com.ubisam.ubgitea.core.auth.protocol.OAuthCallbackValidator
|
||||
import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults
|
||||
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||
import com.ubisam.ubgitea.core.network.OkHttpTransport
|
||||
import com.ubisam.ubgitea.data.auth.AuthApi
|
||||
import com.ubisam.ubgitea.data.auth.AuthenticationGateway
|
||||
import com.ubisam.ubgitea.data.auth.TokenSet
|
||||
import com.ubisam.ubgitea.data.auth.TokenVault
|
||||
import com.ubisam.ubgitea.data.repositories.AccessibleRepository
|
||||
import com.ubisam.ubgitea.data.repositories.RepositoryListApi
|
||||
import com.ubisam.ubgitea.data.repositories.AccessibleRepositoriesGateway
|
||||
import com.ubisam.ubgitea.data.repositories.BearerTokenProvider
|
||||
import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
|
||||
import java.io.IOException
|
||||
import java.util.Collections
|
||||
import kotlinx.coroutines.Job
|
||||
@ -43,6 +49,24 @@ sealed interface RepositoryListUiState {
|
||||
val accountName: String? = null,
|
||||
val canLogout: Boolean = false,
|
||||
) : RepositoryListUiState
|
||||
data class ReferencesLoading(
|
||||
val accountName: String,
|
||||
val repository: RepositoryRowUiModel,
|
||||
) : RepositoryListUiState
|
||||
class ReferencesContent(
|
||||
val accountName: String,
|
||||
val repository: RepositoryRowUiModel,
|
||||
branches: List<ReferenceRowUiModel>,
|
||||
tags: List<ReferenceRowUiModel>,
|
||||
) : RepositoryListUiState {
|
||||
val branches: List<ReferenceRowUiModel> = Collections.unmodifiableList(branches.toList())
|
||||
val tags: List<ReferenceRowUiModel> = Collections.unmodifiableList(tags.toList())
|
||||
}
|
||||
data class ReferencesError(
|
||||
val accountName: String,
|
||||
val repository: RepositoryRowUiModel,
|
||||
val type: RepositoryListError,
|
||||
) : RepositoryListUiState
|
||||
}
|
||||
|
||||
data class RepositoryRowUiModel(
|
||||
@ -54,6 +78,16 @@ data class RepositoryRowUiModel(
|
||||
val defaultBranch: String?,
|
||||
)
|
||||
|
||||
enum class ReferenceKind { BRANCH, TAG }
|
||||
|
||||
data class ReferenceRowUiModel(
|
||||
val name: String,
|
||||
val commitId: String,
|
||||
val kind: ReferenceKind,
|
||||
val isProtected: Boolean,
|
||||
val isSelected: Boolean,
|
||||
)
|
||||
|
||||
enum class RepositoryListError {
|
||||
AUTHENTICATION_EXPIRED,
|
||||
PERMISSION_DENIED,
|
||||
@ -63,13 +97,13 @@ enum class RepositoryListError {
|
||||
}
|
||||
|
||||
class RepositoryListViewModel(
|
||||
application: Application,
|
||||
private val tokenVault: TokenVault = AndroidKeystoreTokenVault(application),
|
||||
private val pendingAuthStore: PendingAuthStore = PendingAuthStore(application),
|
||||
private val authApi: AuthApi = AuthApi(UBGiteaOAuthDefaults.config, OkHttpTransport()),
|
||||
private val repositoryApi: RepositoryListApi = RepositoryListApi(UBGiteaOAuthDefaults.config, OkHttpTransport()),
|
||||
private val tokenVault: TokenVault,
|
||||
private val pendingAuthStore: PendingAuthTransactionStore,
|
||||
private val authApi: AuthenticationGateway,
|
||||
private val repositoryApi: AccessibleRepositoriesGateway,
|
||||
private val browserApi: GiteaRepositoryBrowser,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
) : AndroidViewModel(application) {
|
||||
) : ViewModel() {
|
||||
private val requestFactory = OAuthAuthorizationRequestFactory(UBGiteaOAuthDefaults.config)
|
||||
private val callbackValidator = OAuthCallbackValidator(UBGiteaOAuthDefaults.config)
|
||||
private val callbackHandler = OAuthCallbackHandler(pendingAuthStore, callbackValidator, now)
|
||||
@ -80,6 +114,7 @@ class RepositoryListViewModel(
|
||||
private var operation: Job? = null
|
||||
private var activeAccountName: String? = null
|
||||
private var hasAuthenticatedSession = false
|
||||
private var repositoryContent: RepositoryListUiState.Content? = null
|
||||
|
||||
init {
|
||||
startOperation { restoreSession() }
|
||||
@ -107,7 +142,46 @@ class RepositoryListViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun retry() = startOperation { restoreSession() }
|
||||
fun retry() {
|
||||
val current = mutableState.value
|
||||
if (current is RepositoryListUiState.ReferencesError) {
|
||||
selectRepository(current.repository)
|
||||
} else {
|
||||
startOperation { restoreSession() }
|
||||
}
|
||||
}
|
||||
|
||||
fun selectRepository(repository: RepositoryRowUiModel) {
|
||||
val account = activeAccountName ?: return
|
||||
startOperation { loadReferences(account, repository) }
|
||||
}
|
||||
|
||||
fun selectReference(name: String, kind: ReferenceKind) {
|
||||
val current = mutableState.value as? RepositoryListUiState.ReferencesContent ?: return
|
||||
val selected = RepositoryReferencePresenter.select(
|
||||
current.branches,
|
||||
current.tags,
|
||||
name,
|
||||
kind,
|
||||
)
|
||||
mutableState.value = RepositoryListUiState.ReferencesContent(
|
||||
current.accountName,
|
||||
current.repository,
|
||||
selected.first,
|
||||
selected.second,
|
||||
)
|
||||
}
|
||||
|
||||
fun refreshReferences() {
|
||||
val current = mutableState.value as? RepositoryListUiState.ReferencesContent ?: return
|
||||
selectRepository(current.repository)
|
||||
}
|
||||
|
||||
fun backToRepositories() {
|
||||
operation?.cancel()
|
||||
operation = null
|
||||
repositoryContent?.let { mutableState.value = it }
|
||||
}
|
||||
|
||||
fun cancelSignIn() {
|
||||
startOperation {
|
||||
@ -133,6 +207,7 @@ class RepositoryListViewModel(
|
||||
tokenVault.clear()
|
||||
activeAccountName = null
|
||||
hasAuthenticatedSession = false
|
||||
repositoryContent = null
|
||||
mutableState.value = RepositoryListUiState.SignedOut
|
||||
}
|
||||
}
|
||||
@ -199,7 +274,53 @@ class RepositoryListViewModel(
|
||||
val repositories = repositoryApi.listAll(tokens.accessToken)
|
||||
val rows = Collections.unmodifiableList(repositories.map { it.toUiModel() })
|
||||
mutableState.value = if (rows.isEmpty()) RepositoryListUiState.Empty(accountName)
|
||||
else RepositoryListUiState.Content(accountName, rows)
|
||||
else RepositoryListUiState.Content(accountName, rows).also { repositoryContent = it }
|
||||
}
|
||||
|
||||
private suspend fun loadReferences(accountName: String, repository: RepositoryRowUiModel) {
|
||||
mutableState.value = RepositoryListUiState.ReferencesLoading(accountName, repository)
|
||||
val coordinates = RepositoryCoordinates.of(repository.owner, repository.name)
|
||||
try {
|
||||
val branches = RepositoryReferencePresenter.branches(
|
||||
loadAllPages { page -> browserApi.listBranches(coordinates, page, PAGE_SIZE) },
|
||||
repository.defaultBranch,
|
||||
)
|
||||
val tags = RepositoryReferencePresenter.tags(
|
||||
loadAllPages { page -> browserApi.listTags(coordinates, page, PAGE_SIZE) },
|
||||
)
|
||||
mutableState.value = RepositoryListUiState.ReferencesContent(
|
||||
accountName,
|
||||
repository,
|
||||
branches,
|
||||
tags,
|
||||
)
|
||||
} catch (failure: Throwable) {
|
||||
if (failure is CancellationException) throw failure
|
||||
if (failure is ApiFailure.AuthenticationExpired) {
|
||||
tokenVault.clear()
|
||||
activeAccountName = null
|
||||
hasAuthenticatedSession = false
|
||||
repositoryContent = null
|
||||
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)
|
||||
} else {
|
||||
mutableState.value = RepositoryListUiState.ReferencesError(
|
||||
accountName,
|
||||
repository,
|
||||
failure.toRepositoryListError(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> loadAllPages(loader: suspend (Int) -> com.ubisam.ubgitea.domain.model.Page<T>): List<T> {
|
||||
val items = mutableListOf<T>()
|
||||
var page = 1
|
||||
repeat(MAX_REFERENCE_PAGES) {
|
||||
val result = loader(page)
|
||||
items += result.items
|
||||
page = result.nextPage ?: return Collections.unmodifiableList(items.toList())
|
||||
}
|
||||
throw ApiFailure.InvalidResponse()
|
||||
}
|
||||
|
||||
private fun startOperation(block: suspend () -> Unit) {
|
||||
@ -215,9 +336,27 @@ class RepositoryListViewModel(
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
require(modelClass.isAssignableFrom(RepositoryListViewModel::class.java))
|
||||
return RepositoryListViewModel(application) as T
|
||||
val tokenVault = AndroidKeystoreTokenVault(application)
|
||||
val authTransport = OkHttpTransport()
|
||||
val repositoryTransport = OkHttpTransport()
|
||||
return RepositoryListViewModel(
|
||||
tokenVault = tokenVault,
|
||||
pendingAuthStore = PendingAuthStore(application),
|
||||
authApi = AuthApi(UBGiteaOAuthDefaults.config, authTransport),
|
||||
repositoryApi = RepositoryListApi(UBGiteaOAuthDefaults.config, repositoryTransport),
|
||||
browserApi = GiteaRepositoryBrowserApi(
|
||||
UBGiteaOAuthDefaults.config,
|
||||
OkHttpTransport(),
|
||||
BearerTokenProvider { tokenVault.read()?.accessToken.orEmpty() },
|
||||
),
|
||||
) as T
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PAGE_SIZE = 50
|
||||
const val MAX_REFERENCE_PAGES = 100
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Throwable.toRepositoryListError(): RepositoryListError = when (this) {
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
package com.ubisam.ubgitea.feature.repositories
|
||||
|
||||
import com.ubisam.ubgitea.domain.model.BranchSummary
|
||||
import com.ubisam.ubgitea.domain.model.TagSummary
|
||||
|
||||
internal object RepositoryReferencePresenter {
|
||||
fun branches(items: List<BranchSummary>, defaultBranch: String?): List<ReferenceRowUiModel> =
|
||||
items.distinctBy { it.name.value }.map { branch ->
|
||||
ReferenceRowUiModel(
|
||||
name = branch.name.value,
|
||||
commitId = branch.commitId,
|
||||
kind = ReferenceKind.BRANCH,
|
||||
isProtected = branch.isProtected,
|
||||
isSelected = branch.name.value == defaultBranch,
|
||||
)
|
||||
}
|
||||
|
||||
fun tags(items: List<TagSummary>): List<ReferenceRowUiModel> =
|
||||
items.distinctBy { it.name.value }.map { tag ->
|
||||
ReferenceRowUiModel(
|
||||
name = tag.name.value,
|
||||
commitId = tag.commitId,
|
||||
kind = ReferenceKind.TAG,
|
||||
isProtected = false,
|
||||
isSelected = false,
|
||||
)
|
||||
}
|
||||
|
||||
fun select(
|
||||
branches: List<ReferenceRowUiModel>,
|
||||
tags: List<ReferenceRowUiModel>,
|
||||
name: String,
|
||||
kind: ReferenceKind,
|
||||
): Pair<List<ReferenceRowUiModel>, List<ReferenceRowUiModel>> =
|
||||
branches.map { it.copy(isSelected = kind == ReferenceKind.BRANCH && it.name == name) } to
|
||||
tags.map { it.copy(isSelected = kind == ReferenceKind.TAG && it.name == name) }
|
||||
}
|
||||
@ -20,4 +20,13 @@
|
||||
<string name="repositories_default_branch">기본 브랜치: %1$s</string>
|
||||
<string name="repositories_no_default_branch">기본 브랜치 없음</string>
|
||||
<string name="repositories_account">%1$s 계정</string>
|
||||
<string name="repositories_references_loading">%1$s 저장소의 브랜치와 태그를 불러오는 중입니다.</string>
|
||||
<string name="repositories_branches_title">브랜치</string>
|
||||
<string name="repositories_branches_empty">브랜치가 없습니다.</string>
|
||||
<string name="repositories_tags_title">태그</string>
|
||||
<string name="repositories_tags_empty">태그가 없습니다.</string>
|
||||
<string name="repositories_selected_reference">선택됨</string>
|
||||
<string name="repositories_protected_branch">보호된 브랜치</string>
|
||||
<string name="repositories_back">저장소 목록</string>
|
||||
<string name="repositories_refresh">새로고침</string>
|
||||
</resources>
|
||||
|
||||
@ -0,0 +1,147 @@
|
||||
package com.ubisam.ubgitea.feature.repositories
|
||||
|
||||
import com.ubisam.ubgitea.core.auth.android.PendingAuthTransactionStore
|
||||
import com.ubisam.ubgitea.core.auth.protocol.OAuthTransaction
|
||||
import com.ubisam.ubgitea.core.network.ApiFailure
|
||||
import com.ubisam.ubgitea.data.auth.AuthenticatedUser
|
||||
import com.ubisam.ubgitea.data.auth.AuthenticationGateway
|
||||
import com.ubisam.ubgitea.data.auth.TokenSet
|
||||
import com.ubisam.ubgitea.data.auth.TokenVault
|
||||
import com.ubisam.ubgitea.data.repositories.AccessibleRepositoriesGateway
|
||||
import com.ubisam.ubgitea.data.repositories.AccessibleRepository
|
||||
import com.ubisam.ubgitea.domain.model.BranchSummary
|
||||
import com.ubisam.ubgitea.domain.model.CommitSummary
|
||||
import com.ubisam.ubgitea.domain.model.Page
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||
import com.ubisam.ubgitea.domain.model.RepositorySummary
|
||||
import com.ubisam.ubgitea.domain.model.SourceFile
|
||||
import com.ubisam.ubgitea.domain.model.TagSummary
|
||||
import com.ubisam.ubgitea.domain.model.TreeEntry
|
||||
import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RepositoryListViewModelTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setMainDispatcher() = Dispatchers.setMain(dispatcher)
|
||||
|
||||
@After
|
||||
fun resetMainDispatcher() = Dispatchers.resetMain()
|
||||
|
||||
@Test
|
||||
fun `restores account then loads branches tags and selects default branch`() = runTest(dispatcher.scheduler) {
|
||||
val browser = FakeBrowser()
|
||||
val viewModel = viewModel(browser)
|
||||
advanceUntilIdle()
|
||||
val repositories = viewModel.state.value as RepositoryListUiState.Content
|
||||
|
||||
viewModel.selectRepository(repositories.repositories.single())
|
||||
advanceUntilIdle()
|
||||
|
||||
val references = viewModel.state.value as RepositoryListUiState.ReferencesContent
|
||||
assertEquals("account", references.accountName)
|
||||
assertEquals(listOf("main", "develop"), references.branches.map { it.name })
|
||||
assertTrue(references.branches.first().isSelected)
|
||||
assertEquals(listOf("v1"), references.tags.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selecting tag updates one selected ref and back restores repository snapshot`() = runTest(dispatcher.scheduler) {
|
||||
val viewModel = viewModel(FakeBrowser())
|
||||
advanceUntilIdle()
|
||||
val content = viewModel.state.value as RepositoryListUiState.Content
|
||||
viewModel.selectRepository(content.repositories.single())
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.selectReference("v1", ReferenceKind.TAG)
|
||||
val selected = viewModel.state.value as RepositoryListUiState.ReferencesContent
|
||||
assertFalse(selected.branches.any { it.isSelected })
|
||||
assertTrue(selected.tags.single().isSelected)
|
||||
|
||||
viewModel.backToRepositories()
|
||||
assertTrue(viewModel.state.value is RepositoryListUiState.Content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `branch network failure keeps repository context and retry action state`() = runTest(dispatcher.scheduler) {
|
||||
val browser = FakeBrowser(branchFailure = ApiFailure.Network(java.io.IOException()))
|
||||
val viewModel = viewModel(browser)
|
||||
advanceUntilIdle()
|
||||
val content = viewModel.state.value as RepositoryListUiState.Content
|
||||
|
||||
viewModel.selectRepository(content.repositories.single())
|
||||
advanceUntilIdle()
|
||||
|
||||
val error = viewModel.state.value as RepositoryListUiState.ReferencesError
|
||||
assertEquals(RepositoryListError.OFFLINE, error.type)
|
||||
assertEquals("repo", error.repository.name)
|
||||
}
|
||||
|
||||
private fun viewModel(browser: GiteaRepositoryBrowser): RepositoryListViewModel = RepositoryListViewModel(
|
||||
tokenVault = FakeTokenVault(TokenSet("access", null, null)),
|
||||
pendingAuthStore = FakePendingStore(),
|
||||
authApi = FakeAuthGateway(),
|
||||
repositoryApi = AccessibleRepositoriesGateway {
|
||||
listOf(AccessibleRepository(1, "owner", "repo", null, true, "main"))
|
||||
},
|
||||
browserApi = browser,
|
||||
now = { 1_000L },
|
||||
)
|
||||
|
||||
private class FakeTokenVault(var tokens: TokenSet?) : TokenVault {
|
||||
override suspend fun store(tokens: TokenSet) { this.tokens = tokens }
|
||||
override suspend fun read(): TokenSet? = tokens
|
||||
override suspend fun clear() { tokens = null }
|
||||
}
|
||||
|
||||
private class FakePendingStore : PendingAuthTransactionStore {
|
||||
override suspend fun save(transaction: OAuthTransaction) = Unit
|
||||
override suspend fun consume(): OAuthTransaction? = null
|
||||
override suspend fun clear() = Unit
|
||||
}
|
||||
|
||||
private class FakeAuthGateway : AuthenticationGateway {
|
||||
override suspend fun exchangeCode(authorizationCode: String, verifier: String, nowEpochMillis: Long) =
|
||||
TokenSet("access", null, null)
|
||||
override suspend fun getAuthenticatedUser(accessToken: String) = AuthenticatedUser(1, "account", null)
|
||||
}
|
||||
|
||||
private class FakeBrowser(private val branchFailure: Throwable? = null) : GiteaRepositoryBrowser {
|
||||
override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> =
|
||||
Page.of(emptyList(), null)
|
||||
override suspend fun listBranches(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<BranchSummary> {
|
||||
branchFailure?.let { throw it }
|
||||
return Page.of(
|
||||
listOf(
|
||||
BranchSummary(RepositoryRef.of("main"), "a", false),
|
||||
BranchSummary(RepositoryRef.of("develop"), "b", true),
|
||||
),
|
||||
null,
|
||||
)
|
||||
}
|
||||
override suspend fun listTags(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<TagSummary> =
|
||||
Page.of(listOf(TagSummary(RepositoryRef.of("v1"), "c")), null)
|
||||
override suspend fun listTree(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int, pageSize: Int): Page<TreeEntry> =
|
||||
Page.of(emptyList(), null)
|
||||
override suspend fun getFile(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath): SourceFile =
|
||||
error("Not used")
|
||||
override suspend fun listCommits(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath?, page: Int, pageSize: Int): Page<CommitSummary> =
|
||||
Page.of(emptyList(), null)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package com.ubisam.ubgitea.feature.repositories
|
||||
|
||||
import com.ubisam.ubgitea.domain.model.BranchSummary
|
||||
import com.ubisam.ubgitea.domain.model.RepositoryRef
|
||||
import com.ubisam.ubgitea.domain.model.TagSummary
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RepositoryReferencePresenterTest {
|
||||
@Test
|
||||
fun `default branch is selected and duplicate names are removed`() {
|
||||
val branches = RepositoryReferencePresenter.branches(
|
||||
listOf(
|
||||
BranchSummary(RepositoryRef.of("main"), "a", true),
|
||||
BranchSummary(RepositoryRef.of("main"), "b", false),
|
||||
BranchSummary(RepositoryRef.of("develop"), "c", false),
|
||||
),
|
||||
"main",
|
||||
)
|
||||
|
||||
assertEquals(listOf("main", "develop"), branches.map { it.name })
|
||||
assertTrue(branches.first().isSelected)
|
||||
assertTrue(branches.first().isProtected)
|
||||
assertFalse(branches.last().isSelected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selecting a tag clears branch selection and selects only that tag`() {
|
||||
val branches = RepositoryReferencePresenter.branches(
|
||||
listOf(BranchSummary(RepositoryRef.of("main"), "a", false)),
|
||||
"main",
|
||||
)
|
||||
val tags = RepositoryReferencePresenter.tags(
|
||||
listOf(TagSummary(RepositoryRef.of("v1"), "b"), TagSummary(RepositoryRef.of("v2"), "c")),
|
||||
)
|
||||
|
||||
val selected = RepositoryReferencePresenter.select(branches, tags, "v2", ReferenceKind.TAG)
|
||||
|
||||
assertFalse(selected.first.single().isSelected)
|
||||
assertEquals(listOf(false, true), selected.second.map { it.isSelected })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reference content takes immutable snapshots`() {
|
||||
val branches = mutableListOf(ReferenceRowUiModel("main", "a", ReferenceKind.BRANCH, false, true))
|
||||
val state = RepositoryListUiState.ReferencesContent(
|
||||
"account",
|
||||
RepositoryRowUiModel(1, "owner", "repo", null, true, "main"),
|
||||
branches,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
branches.clear()
|
||||
|
||||
assertEquals(1, state.branches.size)
|
||||
}
|
||||
}
|
||||
@ -123,3 +123,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
||||
- Review status: Codex completed source/contract self-review. Independent review is deferred while the user has assigned the serial implementation stream to Codex and must be completed before TASK-006 contract freeze.
|
||||
- Next recommended task: TASK-005 repository selection and branch/tag browsing UI.
|
||||
- Next owner: Codex.
|
||||
|
||||
## 2026-08-26 — TASK-005
|
||||
|
||||
- Agent and role: Codex, implementer and integrator.
|
||||
- Commit/diff: TASK-005 working diff following `8a47d30`; the exact completion commit is recorded after commit creation.
|
||||
- Features implemented: Repository selection plus repository-scoped branch and tag browsing, default/protected/selected reference indicators, abbreviated commit IDs, refresh, retry, logout, and deterministic back navigation.
|
||||
- How it was implemented: A unidirectional immutable ViewModel state machine consumes the TASK-004 read-only browser contract, loads bounded pages serially, deduplicates references by name, cancels replaced work, and restores the cached repository-list snapshot. Small gateway interfaces make authentication and repository restoration JVM-testable without Android framework construction.
|
||||
- Files changed: Repository Compose/ViewModel/presenter and tests, MainActivity callback wiring, testable authentication/repository gateway boundaries, localized repository strings, and TASK-005 evidence.
|
||||
- Checks and results: Focused presenter/ViewModel tests passed; repository-wide debug build, 96 unit tests across 21 suites, and Android lint passed; diff and focused sensitive-pattern checks passed.
|
||||
- Review findings and resolutions: Self-review added immutable reference snapshots, operation cancellation on back navigation, typed reference errors retaining repository context, and ViewModel tests for selection/restoration/error behavior. Independent review is deferred while Codex owns the serial stream.
|
||||
- Known limitations: No device/emulator interaction test was available in this work cycle. The reviewed contract-freeze checkbox remains open until independent integration review.
|
||||
- Next recommended task: TASK-006 directory navigation and bounded source-file viewing using the TASK-004 API and reviewed TASK-006A classifier.
|
||||
- Next owner: Codex for continuous implementation; Claude may later perform the independent review when re-enabled by the product owner.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# TASK-005: Browse repositories and branches
|
||||
|
||||
Status: partial through TASK-011 — repository list implemented; branch browsing remains
|
||||
Status: implemented and locally verified — independent review and device check deferred
|
||||
Owner: Codex
|
||||
Reviewer: Claude
|
||||
|
||||
@ -8,6 +8,9 @@ Reviewer: Claude
|
||||
|
||||
- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/`
|
||||
- `app/src/test/java/com/ubisam/ubgitea/feature/repositories/`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/data/auth/AuthApi.kt` (testable gateway extraction only)
|
||||
- `app/src/main/java/com/ubisam/ubgitea/data/repositories/RepositoryListApi.kt` (testable gateway extraction only)
|
||||
- `app/src/main/res/values/strings_repositories.xml`
|
||||
- `app/src/main/java/com/ubisam/ubgitea/ui/navigation/`
|
||||
- `app/src/test/java/com/ubisam/ubgitea/ui/navigation/`
|
||||
@ -19,10 +22,19 @@ A signed-in user can browse every repository and branch visible to their Gitea a
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Repository and branch lists preserve pagination and refresh state.
|
||||
- [ ] Organization/owner, visibility, default branch, and selected branch are unambiguous.
|
||||
- [ ] Loading, empty, offline, authentication-expired, and server-error states are accessible and localized.
|
||||
- [ ] Retry and pagination cannot create duplicate items or overlapping requests.
|
||||
- [ ] UI and ViewModel tests cover primary states and selection behavior.
|
||||
- [ ] Resource names use the `repositories_` prefix.
|
||||
- [x] Repository and branch lists preserve pagination and refresh state.
|
||||
- [x] Organization/owner, visibility, default branch, and selected branch are unambiguous.
|
||||
- [x] Loading, empty, offline, authentication-expired, and server-error states are accessible and localized.
|
||||
- [x] Retry and pagination cannot create duplicate items or overlapping requests.
|
||||
- [x] Presentation and ViewModel tests cover primary states and selection behavior.
|
||||
- [x] Resource names use the `repositories_` prefix.
|
||||
- [ ] The reviewed completion commit is recorded as TASK-006's contract-freeze base.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Repository rows open a repository-scoped branch/tag screen; the selected repository, account, default branch, protected status, selected ref, and abbreviated commit ID remain visible.
|
||||
- Branch and tag pages are consumed serially with a 100-page bound, presenter-level name deduplication, cancellation of replaced operations, refresh/retry, and deterministic back navigation to the immutable repository snapshot.
|
||||
- Focused presenter/ViewModel tests passed, including default selection, tag selection, back restoration, immutable snapshots, deduplication, and network-error mapping.
|
||||
- Repository-wide `assembleDebug testDebugUnitTest lintDebug` passed: 96 unit tests across 21 suites, zero failures/errors.
|
||||
- `git diff --check` passed. Focused sensitive-pattern inspection found no token, client secret, password, private key, or credential material.
|
||||
- The independent reviewer and physical-device interaction check are deferred while Codex owns the serial implementation stream. TASK-006 may be developed on the local contract, but its eventual integration review must include this commit before the contract-freeze checkbox is closed.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user