feat: sort repositories by recent update

This commit is contained in:
최민영 2026-08-26 21:48:07 +09:00
parent 5e414a663a
commit ca8620958a
4 changed files with 91 additions and 15 deletions

View File

@ -157,6 +157,7 @@ class RepositoryListViewModel(
private var hasAuthenticatedSession = false
private var repositoryContent: RepositoryListUiState.Content? = null
private var cachedOrganizations: List<OrganizationSummary>? = null
private val repositorySnapshots = mutableMapOf<RepositorySnapshotKey, List<RepositorySummary>>()
init {
startOperation { restoreSession() }
@ -252,6 +253,7 @@ class RepositoryListViewModel(
is RepositoryListUiState.Empty -> Triple(1, current.mode, current.organization)
else -> return
}
repositorySnapshots.remove(RepositorySnapshotKey(mode, organization))
startOperation { loadRepositoryPage(account, page, mode, organization) }
}
@ -342,6 +344,7 @@ class RepositoryListViewModel(
hasAuthenticatedSession = false
repositoryContent = null
cachedOrganizations = null
repositorySnapshots.clear()
mutableState.value = RepositoryListUiState.SignedOut
}
}
@ -412,13 +415,11 @@ class RepositoryListViewModel(
cancelRowBranchJobs()
mutableState.value = RepositoryListUiState.Loading
try {
val result = when {
mode == RepositoryListMode.FAVORITES -> favoritesApi.listStarred(page, REPOSITORY_PAGE_SIZE)
organization != null -> organizationsApi.listOrganizationRepositories(organization, page, REPOSITORY_PAGE_SIZE)
else -> browserApi.listRepositories(page, REPOSITORY_PAGE_SIZE)
}
val repositories = loadRepositorySnapshot(mode, organization)
val pageStart = (page - 1) * REPOSITORY_PAGE_SIZE
val pageItems = repositories.drop(pageStart).take(REPOSITORY_PAGE_SIZE)
val organizations = if (mode == RepositoryListMode.ALL) loadOrganizationsOnce() else emptyList()
val rows = result.items.map { it.toUiModel() }
val rows = pageItems.map { it.toUiModel() }
if (rows.isEmpty() && page == 1) {
repositoryContent = null
mutableState.value = RepositoryListUiState.Empty(accountName, mode, organization)
@ -428,7 +429,7 @@ class RepositoryListViewModel(
accountName,
rows,
page,
result.nextPage != null,
pageStart + rows.size < repositories.size,
mode,
organization,
organizations,
@ -452,6 +453,22 @@ class RepositoryListViewModel(
}
}
private suspend fun loadRepositorySnapshot(
mode: RepositoryListMode,
organization: String?,
): List<RepositorySummary> {
val key = RepositorySnapshotKey(mode, organization)
repositorySnapshots[key]?.let { return it }
val loaded = loadAllPages { page ->
when {
mode == RepositoryListMode.FAVORITES -> favoritesApi.listStarred(page, PAGE_SIZE)
organization != null -> organizationsApi.listOrganizationRepositories(organization, page, PAGE_SIZE)
else -> browserApi.listRepositories(page, PAGE_SIZE)
}
}.sortedByDescending(RepositorySummary::updatedAtEpochMillis)
return loaded.also { repositorySnapshots[key] = it }
}
private suspend fun loadOrganizationsOnce(): List<OrganizationSummary> {
cachedOrganizations?.let { return it }
val loaded = try {
@ -623,6 +640,11 @@ class RepositoryListViewModel(
}
}
private data class RepositorySnapshotKey(
val mode: RepositoryListMode,
val organization: String?,
)
internal fun Throwable.toRepositoryListError(): RepositoryListError = when (this) {
is ApiFailure.AuthenticationExpired -> RepositoryListError.AUTHENTICATION_EXPIRED
is ApiFailure.PermissionDenied -> RepositoryListError.PERMISSION_DENIED

View File

@ -117,11 +117,11 @@ class RepositoryListViewModelTest {
}
@Test
fun `repository list requests recent-push order and pages five at a time`() = runTest(dispatcher.scheduler) {
fun `repository list globally sorts server pages by recent update and pages locally`() = runTest(dispatcher.scheduler) {
val browser = FakeBrowser(
repositories = listOf(
Page.of(listOf(repo("newest"), repo("older")), nextPage = 2),
Page.of(listOf(repo("oldest")), nextPage = null),
Page.of(listOf(repo("oldest", 1_000L), repo("middle", 2_000L)), nextPage = 2),
Page.of(listOf(repo("newest", 3_000L), repo("newer", 2_500L), repo("older", 1_500L), repo("old", 500L)), nextPage = null),
),
)
val viewModel = viewModel(browser)
@ -130,19 +130,33 @@ class RepositoryListViewModelTest {
val firstPage = viewModel.state.value as RepositoryListUiState.Content
assertEquals(1, firstPage.page)
assertTrue(firstPage.hasNextPage)
assertEquals(listOf("newest", "older"), firstPage.repositories.map { it.name })
assertEquals(listOf(1 to 5), browser.repositoryPageRequests)
assertEquals(listOf("newest", "newer", "middle", "older", "oldest"), firstPage.repositories.map { it.name })
assertEquals(listOf(1 to 50, 2 to 50), browser.repositoryPageRequests)
viewModel.nextPage()
advanceUntilIdle()
val secondPage = viewModel.state.value as RepositoryListUiState.Content
assertEquals(2, secondPage.page)
assertFalse(secondPage.hasNextPage)
assertEquals(listOf("oldest"), secondPage.repositories.map { it.name })
assertEquals(listOf("old"), secondPage.repositories.map { it.name })
assertEquals(listOf(1 to 50, 2 to 50), browser.repositoryPageRequests)
viewModel.previousPage()
advanceUntilIdle()
assertEquals(1, (viewModel.state.value as RepositoryListUiState.Content).page)
assertEquals(listOf(1 to 50, 2 to 50), browser.repositoryPageRequests)
}
@Test
fun `refresh invalidates the globally sorted repository snapshot`() = runTest(dispatcher.scheduler) {
val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo"))))
val viewModel = viewModel(browser)
advanceUntilIdle()
viewModel.refreshRepositories()
advanceUntilIdle()
assertEquals(listOf(1 to 50, 1 to 50), browser.repositoryPageRequests)
}
@Test
@ -406,13 +420,13 @@ class RepositoryListViewModelTest {
private fun page1Of(vararg repositories: RepositorySummary) = Page.of(repositories.toList(), null)
private fun repo(name: String) = RepositorySummary(
private fun repo(name: String, updatedAtEpochMillis: Long = 1_000L) = RepositorySummary(
coordinates = RepositoryCoordinates.of("owner", name),
description = null,
defaultBranch = RepositoryRef.of("main"),
isPrivate = true,
isFork = false,
updatedAtEpochMillis = 1_000L,
updatedAtEpochMillis = updatedAtEpochMillis,
)
private fun viewModel(

View File

@ -313,3 +313,10 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- Implementation: Repository rows now use the stable string key `owner/name`; repository components cannot contain `/`, so the key remains unique across owners and repositories while being Android-saveable.
- Regression coverage: Added tests for deterministic key encoding and for equal repository names under different owners.
- Checks: Focused tests and `assembleDebug testDebugUnitTest lintDebug` passed. The APK installed wirelessly over the authenticated device, `MainActivity` remained resumed, and the cleared crash buffer stayed empty. Final visual inspection is pending because the handset locked.
## 2026-08-26 — TASK-021 global recent-update repository order
- Agent and role: Codex, implementer and device verifier.
- Root cause: This Gitea server returned `/user/repos` in apparent name order despite the existing `sort=updated&order=desc` query, so trusting server paging could not guarantee that the newest repository appeared on page 1.
- Implementation: The ViewModel now loads all server pages in 50-row batches, performs one stable descending sort by `RepositorySummary.updatedAtEpochMillis`, and slices that immutable snapshot into five-row UI pages. Snapshots are scoped by all/favorites/organization selection, reused for next/previous navigation, and invalidated by refresh and logout.
- Checks: Focused regression tests and `assembleDebug testDebugUnitTest lintDebug` passed. The rebuilt APK was installed wirelessly; the authenticated first page visibly changed from alphabetical order to recent-update order, all five rows loaded their latest branch labels, and the crash buffer remained empty.

View File

@ -0,0 +1,33 @@
# TASK-021: Enforce recent-push repository order across pages
Status: implemented and device-verified
Owner: Codex
Reviewer: Claude
## Outcome
The repository list places the repository with the newest Gitea `updated_at` value at the top of page 1 and preserves that descending order across every local page, even when this Gitea server ignores `sort=updated&order=desc` on repository-list endpoints.
## Scope
- Read every accessible repository page for the selected list (all, organization, or favorites).
- Sort the complete result by `updatedAtEpochMillis` descending before splitting it into five-row UI pages.
- Cache the sorted snapshot during navigation and invalidate it on refresh or logout.
## Acceptance criteria
- [x] A newer repository returned on a later server page appears before older repositories from page 1.
- [x] Local next/previous navigation does not refetch the complete server list.
- [x] Refresh invalidates and rebuilds the sorted snapshot.
- [x] Empty, error, authentication-expired, organization, and favorites behavior remains intact.
- [x] Build, unit tests, lint, secret scan, and physical-device verification pass.
## Note
The Gitea repository payload exposes `updated_at`, which is the existing product proxy for recent repository push activity. Exact branch commit timestamps continue to be fetched only for the five visible rows so one repository with a broken branch endpoint cannot block the global list.
## Evidence
- Focused ViewModel tests cover cross-server-page sorting, five-row local paging, navigation caching, and refresh invalidation.
- `assembleDebug testDebugUnitTest lintDebug` passed.
- The APK was installed wirelessly over the existing authenticated app. Page 1 changed from the server's apparent alphabetical order to the newest-update order, rendered five repositories with their latest branch labels, retained next-page navigation, and produced no crash entry.