feat: sort repositories by recent update
This commit is contained in:
parent
5e414a663a
commit
ca8620958a
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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.
|
||||
|
||||
33
tasks/021-repository-global-recent-update-order.md
Normal file
33
tasks/021-repository-global-recent-update-order.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user