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 hasAuthenticatedSession = false
|
||||||
private var repositoryContent: RepositoryListUiState.Content? = null
|
private var repositoryContent: RepositoryListUiState.Content? = null
|
||||||
private var cachedOrganizations: List<OrganizationSummary>? = null
|
private var cachedOrganizations: List<OrganizationSummary>? = null
|
||||||
|
private val repositorySnapshots = mutableMapOf<RepositorySnapshotKey, List<RepositorySummary>>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
startOperation { restoreSession() }
|
startOperation { restoreSession() }
|
||||||
@ -252,6 +253,7 @@ class RepositoryListViewModel(
|
|||||||
is RepositoryListUiState.Empty -> Triple(1, current.mode, current.organization)
|
is RepositoryListUiState.Empty -> Triple(1, current.mode, current.organization)
|
||||||
else -> return
|
else -> return
|
||||||
}
|
}
|
||||||
|
repositorySnapshots.remove(RepositorySnapshotKey(mode, organization))
|
||||||
startOperation { loadRepositoryPage(account, page, mode, organization) }
|
startOperation { loadRepositoryPage(account, page, mode, organization) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -342,6 +344,7 @@ class RepositoryListViewModel(
|
|||||||
hasAuthenticatedSession = false
|
hasAuthenticatedSession = false
|
||||||
repositoryContent = null
|
repositoryContent = null
|
||||||
cachedOrganizations = null
|
cachedOrganizations = null
|
||||||
|
repositorySnapshots.clear()
|
||||||
mutableState.value = RepositoryListUiState.SignedOut
|
mutableState.value = RepositoryListUiState.SignedOut
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -412,13 +415,11 @@ class RepositoryListViewModel(
|
|||||||
cancelRowBranchJobs()
|
cancelRowBranchJobs()
|
||||||
mutableState.value = RepositoryListUiState.Loading
|
mutableState.value = RepositoryListUiState.Loading
|
||||||
try {
|
try {
|
||||||
val result = when {
|
val repositories = loadRepositorySnapshot(mode, organization)
|
||||||
mode == RepositoryListMode.FAVORITES -> favoritesApi.listStarred(page, REPOSITORY_PAGE_SIZE)
|
val pageStart = (page - 1) * REPOSITORY_PAGE_SIZE
|
||||||
organization != null -> organizationsApi.listOrganizationRepositories(organization, page, REPOSITORY_PAGE_SIZE)
|
val pageItems = repositories.drop(pageStart).take(REPOSITORY_PAGE_SIZE)
|
||||||
else -> browserApi.listRepositories(page, REPOSITORY_PAGE_SIZE)
|
|
||||||
}
|
|
||||||
val organizations = if (mode == RepositoryListMode.ALL) loadOrganizationsOnce() else emptyList()
|
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) {
|
if (rows.isEmpty() && page == 1) {
|
||||||
repositoryContent = null
|
repositoryContent = null
|
||||||
mutableState.value = RepositoryListUiState.Empty(accountName, mode, organization)
|
mutableState.value = RepositoryListUiState.Empty(accountName, mode, organization)
|
||||||
@ -428,7 +429,7 @@ class RepositoryListViewModel(
|
|||||||
accountName,
|
accountName,
|
||||||
rows,
|
rows,
|
||||||
page,
|
page,
|
||||||
result.nextPage != null,
|
pageStart + rows.size < repositories.size,
|
||||||
mode,
|
mode,
|
||||||
organization,
|
organization,
|
||||||
organizations,
|
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> {
|
private suspend fun loadOrganizationsOnce(): List<OrganizationSummary> {
|
||||||
cachedOrganizations?.let { return it }
|
cachedOrganizations?.let { return it }
|
||||||
val loaded = try {
|
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) {
|
internal fun Throwable.toRepositoryListError(): RepositoryListError = when (this) {
|
||||||
is ApiFailure.AuthenticationExpired -> RepositoryListError.AUTHENTICATION_EXPIRED
|
is ApiFailure.AuthenticationExpired -> RepositoryListError.AUTHENTICATION_EXPIRED
|
||||||
is ApiFailure.PermissionDenied -> RepositoryListError.PERMISSION_DENIED
|
is ApiFailure.PermissionDenied -> RepositoryListError.PERMISSION_DENIED
|
||||||
|
|||||||
@ -117,11 +117,11 @@ class RepositoryListViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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(
|
val browser = FakeBrowser(
|
||||||
repositories = listOf(
|
repositories = listOf(
|
||||||
Page.of(listOf(repo("newest"), repo("older")), nextPage = 2),
|
Page.of(listOf(repo("oldest", 1_000L), repo("middle", 2_000L)), nextPage = 2),
|
||||||
Page.of(listOf(repo("oldest")), nextPage = null),
|
Page.of(listOf(repo("newest", 3_000L), repo("newer", 2_500L), repo("older", 1_500L), repo("old", 500L)), nextPage = null),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
val viewModel = viewModel(browser)
|
val viewModel = viewModel(browser)
|
||||||
@ -130,19 +130,33 @@ class RepositoryListViewModelTest {
|
|||||||
val firstPage = viewModel.state.value as RepositoryListUiState.Content
|
val firstPage = viewModel.state.value as RepositoryListUiState.Content
|
||||||
assertEquals(1, firstPage.page)
|
assertEquals(1, firstPage.page)
|
||||||
assertTrue(firstPage.hasNextPage)
|
assertTrue(firstPage.hasNextPage)
|
||||||
assertEquals(listOf("newest", "older"), firstPage.repositories.map { it.name })
|
assertEquals(listOf("newest", "newer", "middle", "older", "oldest"), firstPage.repositories.map { it.name })
|
||||||
assertEquals(listOf(1 to 5), browser.repositoryPageRequests)
|
assertEquals(listOf(1 to 50, 2 to 50), browser.repositoryPageRequests)
|
||||||
|
|
||||||
viewModel.nextPage()
|
viewModel.nextPage()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
val secondPage = viewModel.state.value as RepositoryListUiState.Content
|
val secondPage = viewModel.state.value as RepositoryListUiState.Content
|
||||||
assertEquals(2, secondPage.page)
|
assertEquals(2, secondPage.page)
|
||||||
assertFalse(secondPage.hasNextPage)
|
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()
|
viewModel.previousPage()
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
assertEquals(1, (viewModel.state.value as RepositoryListUiState.Content).page)
|
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
|
@Test
|
||||||
@ -406,13 +420,13 @@ class RepositoryListViewModelTest {
|
|||||||
|
|
||||||
private fun page1Of(vararg repositories: RepositorySummary) = Page.of(repositories.toList(), null)
|
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),
|
coordinates = RepositoryCoordinates.of("owner", name),
|
||||||
description = null,
|
description = null,
|
||||||
defaultBranch = RepositoryRef.of("main"),
|
defaultBranch = RepositoryRef.of("main"),
|
||||||
isPrivate = true,
|
isPrivate = true,
|
||||||
isFork = false,
|
isFork = false,
|
||||||
updatedAtEpochMillis = 1_000L,
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun viewModel(
|
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.
|
- 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.
|
- 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.
|
- 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