TASK-037: already-starred repositories show filled on the ALL list's first load
Closes a limitation accepted since TASK-018/TASK-030: a repository already starred before the current session showed outlined until directly toggled. loadStarredCoordinatesOnce fetches the account's full starred set once per session (one request for the whole list, not N+1 per row) via the same /user/starred endpoint already used for the FAVORITES tab, mirroring loadOrganizationsOnce's caching shape. toggleFavorite keeps the cache in sync on success; refreshRepositories and logout invalidate/clear it the same way the equivalent repositorySnapshots/cachedOrganizations caches already are. Live-verified: a repository independently confirmed starred via the FAVORITES tab showed filled on the ALL list immediately after a fresh app launch, no interaction needed, screenshot-confirmed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
18e59d960c
commit
683305e6c8
@ -164,6 +164,13 @@ class RepositoryListViewModel(
|
|||||||
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>>()
|
private val repositorySnapshots = mutableMapOf<RepositorySnapshotKey, List<RepositorySummary>>()
|
||||||
|
// Which repositories the account already had starred before this app session touched
|
||||||
|
// anything, so the ALL list can show a filled star from the very first load instead of only
|
||||||
|
// after this session's own toggleFavorite call -- the limitation TASK-018/TASK-030 originally
|
||||||
|
// accepted. Kept up to date locally by toggleFavorite (below) rather than re-fetched from the
|
||||||
|
// server on every star/unstar, and invalidated by refreshRepositories the same way
|
||||||
|
// repositorySnapshots is.
|
||||||
|
private var starredCoordinatesCache: Set<RepositoryCoordinates>? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
startOperation { restoreSession() }
|
startOperation { restoreSession() }
|
||||||
@ -260,6 +267,7 @@ class RepositoryListViewModel(
|
|||||||
else -> return
|
else -> return
|
||||||
}
|
}
|
||||||
repositorySnapshots.remove(RepositorySnapshotKey(mode, organization))
|
repositorySnapshots.remove(RepositorySnapshotKey(mode, organization))
|
||||||
|
starredCoordinatesCache = null
|
||||||
startOperation { loadRepositoryPage(account, page, mode, organization) }
|
startOperation { loadRepositoryPage(account, page, mode, organization) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -308,8 +316,18 @@ class RepositoryListViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
when {
|
when {
|
||||||
alreadyStarredThisSession -> favoritesApi.unstar(repository.coordinates)
|
alreadyStarredThisSession -> {
|
||||||
mode == RepositoryListMode.ALL -> favoritesApi.star(repository.coordinates)
|
favoritesApi.unstar(repository.coordinates)
|
||||||
|
// Keeps loadStarredCoordinatesOnce's cache consistent with what actually
|
||||||
|
// happened, without a network round trip just to confirm it -- only touches
|
||||||
|
// the cache when it's already populated; if it isn't, the next ALL-list load
|
||||||
|
// fetches a fresh one anyway.
|
||||||
|
starredCoordinatesCache = starredCoordinatesCache?.minus(repository.coordinates)
|
||||||
|
}
|
||||||
|
mode == RepositoryListMode.ALL -> {
|
||||||
|
favoritesApi.star(repository.coordinates)
|
||||||
|
starredCoordinatesCache = starredCoordinatesCache?.plus(repository.coordinates)
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
favoritesApi.unstar(repository.coordinates)
|
favoritesApi.unstar(repository.coordinates)
|
||||||
removeRow(repository.coordinates)
|
removeRow(repository.coordinates)
|
||||||
@ -389,6 +407,7 @@ class RepositoryListViewModel(
|
|||||||
repositoryContent = null
|
repositoryContent = null
|
||||||
cachedOrganizations = null
|
cachedOrganizations = null
|
||||||
repositorySnapshots.clear()
|
repositorySnapshots.clear()
|
||||||
|
starredCoordinatesCache = null
|
||||||
mutableState.value = RepositoryListUiState.SignedOut
|
mutableState.value = RepositoryListUiState.SignedOut
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -461,7 +480,11 @@ class RepositoryListViewModel(
|
|||||||
val pageStart = (page - 1) * REPOSITORY_PAGE_SIZE
|
val pageStart = (page - 1) * REPOSITORY_PAGE_SIZE
|
||||||
val pageItems = repositories.drop(pageStart).take(REPOSITORY_PAGE_SIZE)
|
val pageItems = repositories.drop(pageStart).take(REPOSITORY_PAGE_SIZE)
|
||||||
val organizations = if (mode == RepositoryListMode.ALL) loadOrganizationsOnce() else emptyList()
|
val organizations = if (mode == RepositoryListMode.ALL) loadOrganizationsOnce() else emptyList()
|
||||||
val rows = pageItems.map { it.toUiModel() }
|
// Every row on the FAVORITES list is inherently already starred (that's why it's
|
||||||
|
// showing) -- FavoriteStar's own rendering already handles that mode without consulting
|
||||||
|
// favoriteAction at all, so the cross-reference below only matters for ALL.
|
||||||
|
val alreadyStarred = if (mode == RepositoryListMode.ALL) loadStarredCoordinatesOnce() else emptySet()
|
||||||
|
val rows = pageItems.map { it.toUiModel(alreadyStarred = it.coordinates in alreadyStarred) }
|
||||||
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)
|
||||||
@ -524,6 +547,27 @@ class RepositoryListViewModel(
|
|||||||
return loaded
|
return loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetches the account's full starred-repository set once per session (or since the last
|
||||||
|
// refresh/toggle-driven invalidation) so the ALL list can show an already-starred repository
|
||||||
|
// as filled from its very first load, closing the gap TASK-018/TASK-030 originally accepted.
|
||||||
|
// One request for the whole list, not one per row -- Gitea has no "is this repo starred by me"
|
||||||
|
// field on the plain repository-list response itself, but it does return the account's full
|
||||||
|
// starred set via the same /user/starred endpoint this app already calls for the FAVORITES tab.
|
||||||
|
private suspend fun loadStarredCoordinatesOnce(): Set<RepositoryCoordinates> {
|
||||||
|
starredCoordinatesCache?.let { return it }
|
||||||
|
val loaded = try {
|
||||||
|
loadAllPages { page -> favoritesApi.listStarred(page, PAGE_SIZE) }.map { it.coordinates }.toSet()
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
if (failure is CancellationException) throw failure
|
||||||
|
// Non-fatal, same reasoning as loadOrganizationsOnce: the repository list itself is
|
||||||
|
// more important than which rows start out filled -- a repository already starred
|
||||||
|
// just falls back to this session's own known limitation (outlined until acted on).
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
starredCoordinatesCache = loaded
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadRowBranches(content: RepositoryListUiState.Content) {
|
private fun loadRowBranches(content: RepositoryListUiState.Content) {
|
||||||
content.repositories
|
content.repositories
|
||||||
.filter { it.lastPushedBranch is LastPushedBranchUiState.Loading }
|
.filter { it.lastPushedBranch is LastPushedBranchUiState.Loading }
|
||||||
@ -651,7 +695,7 @@ class RepositoryListViewModel(
|
|||||||
operation = viewModelScope.launch { block() }
|
operation = viewModelScope.launch { block() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun RepositorySummary.toUiModel() = RepositoryRowUiModel(
|
private fun RepositorySummary.toUiModel(alreadyStarred: Boolean = false) = RepositoryRowUiModel(
|
||||||
coordinates = coordinates,
|
coordinates = coordinates,
|
||||||
description = description,
|
description = description,
|
||||||
isPrivate = isPrivate,
|
isPrivate = isPrivate,
|
||||||
@ -659,6 +703,7 @@ class RepositoryListViewModel(
|
|||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
language = language,
|
language = language,
|
||||||
starsCount = starsCount,
|
starsCount = starsCount,
|
||||||
|
favoriteAction = if (alreadyStarred) FavoriteActionUiState.Starred else FavoriteActionUiState.Idle,
|
||||||
)
|
)
|
||||||
|
|
||||||
class Factory(private val application: Application) : ViewModelProvider.Factory {
|
class Factory(private val application: Application) : ViewModelProvider.Factory {
|
||||||
|
|||||||
@ -323,6 +323,40 @@ class RepositoryListViewModelTest {
|
|||||||
assertEquals(listOf("starred-repo"), content.repositories.map { it.name })
|
assertEquals(listOf("starred-repo"), content.repositories.map { it.name })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a repository already starred before this session shows filled from the very first load`() = runTest(dispatcher.scheduler) {
|
||||||
|
val browser = FakeBrowser(
|
||||||
|
repositories = listOf(page1Of(repo("already-starred"), repo("not-starred"))),
|
||||||
|
starred = listOf(page1Of(repo("already-starred"))),
|
||||||
|
)
|
||||||
|
val viewModel = viewModel(browser)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val rows = (viewModel.state.value as RepositoryListUiState.Content).repositories.associateBy { it.name }
|
||||||
|
assertEquals(FavoriteActionUiState.Starred, rows.getValue("already-starred").favoriteAction)
|
||||||
|
assertEquals(FavoriteActionUiState.Idle, rows.getValue("not-starred").favoriteAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `un-starring an already-session-known-starred row updates the cache so a later reload stays consistent`() = runTest(dispatcher.scheduler) {
|
||||||
|
val browser = FakeBrowser(
|
||||||
|
repositories = listOf(page1Of(repo("repo"))),
|
||||||
|
starred = listOf(page1Of(repo("repo"))),
|
||||||
|
)
|
||||||
|
val viewModel = viewModel(browser)
|
||||||
|
advanceUntilIdle()
|
||||||
|
val row = (viewModel.state.value as RepositoryListUiState.Content).repositories.single()
|
||||||
|
assertEquals(FavoriteActionUiState.Starred, row.favoriteAction)
|
||||||
|
|
||||||
|
viewModel.toggleFavorite(row)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.showAllRepositories()
|
||||||
|
advanceUntilIdle()
|
||||||
|
val reloaded = (viewModel.state.value as RepositoryListUiState.Content).repositories.single()
|
||||||
|
assertEquals(FavoriteActionUiState.Idle, reloaded.favoriteAction)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `starring a repository from the all-repositories list marks it starred without removing it`() = runTest(dispatcher.scheduler) {
|
fun `starring a repository from the all-repositories list marks it starred without removing it`() = runTest(dispatcher.scheduler) {
|
||||||
val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo"))))
|
val browser = FakeBrowser(repositories = listOf(page1Of(repo("repo"))))
|
||||||
|
|||||||
@ -645,3 +645,17 @@ This is the append-only implementation and handoff record shared by Codex and Cl
|
|||||||
- Known limitations: deep link targets the default branch, not necessarily the specifically-pushed branch (by design, see above); a repository with no default branch still notifies, just without deep-link extras.
|
- Known limitations: deep link targets the default branch, not necessarily the specifically-pushed branch (by design, see above); a repository with no default branch still notifies, just without deep-link extras.
|
||||||
- Next recommended task: this closes out the last explicitly-scoped item from this session's feature queue. Remaining work is live-confirmation of everything already built across this whole session (TASK-027 through TASK-036) — pinch-zoom/CSV/PDF viewers, Issues/PR pagination, the green theme, and this notification feature's actual end-to-end firing.
|
- Next recommended task: this closes out the last explicitly-scoped item from this session's feature queue. Remaining work is live-confirmation of everything already built across this whole session (TASK-027 through TASK-036) — pinch-zoom/CSV/PDF viewers, Issues/PR pagination, the green theme, and this notification feature's actual end-to-end firing.
|
||||||
- Next owner: Claude, continuing per the standing direction.
|
- Next owner: Claude, continuing per the standing direction.
|
||||||
|
|
||||||
|
## 2026-08-29 (continued) — TASK-037: already-starred repositories show filled on the ALL list's first load
|
||||||
|
|
||||||
|
- Agent and role: Claude, direct continuation of the same live session — "계속 이어서 개발해줘 멈추지말고" (keep developing, don't stop) after TASK-036's deep-link follow-up. Picked up a real, previously-accepted-not-fixed known limitation from TASK-018/TASK-030 rather than waiting on further live confirmation.
|
||||||
|
- Commit/diff: on `claude/task-015-notifications`; `feature/repositories/RepositoryListViewModel.kt` (+ test additions), `tasks/037-already-starred-repos-show-filled-on-load.md`.
|
||||||
|
- Feature implemented: the ALL repository list now shows a filled star from its very first load for any repository the account has already starred — not just after this session's own toggle interaction.
|
||||||
|
- How it was implemented: `loadStarredCoordinatesOnce()` fetches the account's full starred-repository set once per session via the same `/user/starred` endpoint already used for the FAVORITES tab (Gitea's plain repository-list response has no "is this starred by me" field, and calling a per-repository IsStarring endpoint once per row would have turned one list load into N+1 requests) — mirrors `loadOrganizationsOnce`'s exact shape (cache-check, non-fatal try/catch fallback). `toggleFavorite`'s success paths now also keep this cache directly in sync (`+`/`-` the toggled repository) so later reloads within the same session stay consistent without a re-fetch; `refreshRepositories()`/`logout()` invalidate/clear it the same way the equivalent `repositorySnapshots`/`cachedOrganizations` caches already are.
|
||||||
|
- Files changed: `RepositoryListViewModel.kt`, `RepositoryListViewModelTest.kt`, `tasks/037-already-starred-repos-show-filled-on-load.md`.
|
||||||
|
- Checks and results: `./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug` BUILD SUCCESSFUL, all 28 tests in `RepositoryListViewModelTest` passing including every pre-existing one unmodified (new coverage: an already-starred repository shows filled on first load while a sibling shows outlined; un-starring then reloading the ALL list confirms the cache itself, not just the visible row, updated), 0 lint errors. Installed on the human product owner's device.
|
||||||
|
- Live verification: confirmed `RobotAutomationDevelopment-Dept/LGE-OC6-Inline-CylinderTypeWelder` was genuinely starred server-side via the FAVORITES tab (★ 1 shown there), then located it on the ALL list (page 2) after a fresh app launch — showed filled green ★ 1 immediately with no tap needed, screenshot-verified, no crash.
|
||||||
|
- Review findings and resolutions: None from a separate review pass.
|
||||||
|
- Known limitations: still session-scoped (re-fetches on a fresh session, same as every other cached list in this app); doesn't reflect a starred-set change made elsewhere (Gitea web, another device) until the next pull-to-refresh or re-login.
|
||||||
|
- Next recommended task: this closes a real, previously-documented gap rather than adding new surface area. Remaining work across this whole session is now mostly live-confirmation of already-built features (TASK-027 through TASK-036) that haven't been directly exercised by the human product owner yet.
|
||||||
|
- Next owner: Claude, continuing per the standing direction.
|
||||||
|
|||||||
101
tasks/037-already-starred-repos-show-filled-on-load.md
Normal file
101
tasks/037-already-starred-repos-show-filled-on-load.md
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# TASK-037: Already-starred repositories show filled on the ALL list's first load
|
||||||
|
|
||||||
|
Status: implemented; all required checks pass; live-confirmed on the human product owner's device
|
||||||
|
Owner: Claude
|
||||||
|
Reviewer: Codex
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Closes a limitation accepted (not fixed) all the way back in TASK-018/TASK-030: a repository
|
||||||
|
already starred before the current app session started an ALL-list session showing outlined
|
||||||
|
(unstarred) until the human product owner directly tapped it. The ALL list now cross-references the
|
||||||
|
account's actual starred set once per session and shows a filled star from the very first load for
|
||||||
|
any repository that's genuinely already starred server-side.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Included: `feature/repositories/RepositoryListViewModel.kt` (`loadStarredCoordinatesOnce`,
|
||||||
|
`toUiModel` gains an `alreadyStarred` parameter, `toggleFavorite` keeps the new cache in sync,
|
||||||
|
`refreshRepositories`/`logout` invalidate/clear it), matching test additions in
|
||||||
|
`RepositoryListViewModelTest.kt`.
|
||||||
|
- Excluded: No change to the FAVORITES list (every row there is inherently already starred, and
|
||||||
|
`FavoriteStar`'s rendering for that mode never consulted `favoriteAction` in the first place — see
|
||||||
|
`RepositoryListScreen.kt`'s existing doc comment). No per-row network call — this is one list
|
||||||
|
fetch for the whole page, not N+1 (see Design notes).
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
- **One request for the whole list, not one per row**: Gitea's plain repository-list response has
|
||||||
|
no "is this starred by me" field, but the account's full starred set is already reachable via the
|
||||||
|
same `/user/starred` endpoint this app already calls for the FAVORITES tab
|
||||||
|
(`favoritesApi.listStarred`). `loadStarredCoordinatesOnce` fetches and caches that whole set once
|
||||||
|
per session (mirroring `loadOrganizationsOnce`'s exact shape: cache-check, try/catch with a
|
||||||
|
non-fatal empty fallback on failure, cache the result either way) rather than calling Gitea's
|
||||||
|
per-repository `IsStarring` endpoint once per row, which would turn a single list load into N+1
|
||||||
|
requests.
|
||||||
|
- **The cache stays self-consistent without extra network calls**: `toggleFavorite`'s success paths
|
||||||
|
now also update `starredCoordinatesCache` directly (`+`/`-` the toggled repository) instead of
|
||||||
|
leaving it stale until the next full refetch — so switching between ALL/FAVORITES, paging, or
|
||||||
|
reloading later in the same session keeps reflecting what this session itself just did, not just
|
||||||
|
what the very first load saw.
|
||||||
|
- **Invalidated exactly where the equivalent `repositorySnapshots` cache already is**:
|
||||||
|
`refreshRepositories()` (pull-to-refresh) clears it alongside the snapshot removal it already
|
||||||
|
does, and `logout()` clears it alongside `cachedOrganizations`/`repositorySnapshots`. Deliberately
|
||||||
|
*not* cleared in `clearSessionOnAuthenticationExpired()`, matching that those two existing caches
|
||||||
|
aren't cleared there either — consistency with the established (if arguably imperfect) precedent
|
||||||
|
was chosen over introducing a new asymmetry just for this one cache.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] A repository the account has already starred (independent of this app session) shows a
|
||||||
|
filled, primary-colored star on the ALL list's very first load, with no prior interaction.
|
||||||
|
- [x] A repository not starred still shows outlined, as before.
|
||||||
|
- [x] Toggling a star/unstar keeps working exactly as TASK-034 left it, and additionally keeps the
|
||||||
|
new cache consistent for any later reload within the same session.
|
||||||
|
- [x] Pull-to-refresh re-derives the starred set fresh rather than reusing a stale cache.
|
||||||
|
- [x] Build, unit tests, and lint pass.
|
||||||
|
- [x] Live-confirmed on the human product owner's device: a repository independently verified as
|
||||||
|
starred via the FAVORITES tab (`LGE-OC6-Inline-CylinderTypeWelder`, ★ 1) showed filled on the
|
||||||
|
ALL list immediately after a fresh app launch, screenshot-verified.
|
||||||
|
|
||||||
|
## Required checks
|
||||||
|
|
||||||
|
```text
|
||||||
|
./gradlew compileDebugKotlin compileDebugUnitTestKotlin
|
||||||
|
./gradlew testDebugUnitTest
|
||||||
|
./gradlew assembleDebug lintDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- `RepositoryListViewModel.kt`: new `starredCoordinatesCache` field; new `loadStarredCoordinatesOnce()`;
|
||||||
|
`loadRepositoryPage` computes an `alreadyStarred` set for ALL-mode loads and passes it through
|
||||||
|
`toUiModel(alreadyStarred = ...)`; `toggleFavorite`'s success paths update the cache; `refreshRepositories()`/
|
||||||
|
`logout()` invalidate/clear it.
|
||||||
|
- `RepositoryListViewModelTest.kt`: two new tests — a repository already starred shows `Starred`
|
||||||
|
(filled) on first load while an unstarred sibling shows `Idle`; un-starring an already-session-
|
||||||
|
known-starred row and then reloading the ALL list (via `showAllRepositories()`) shows `Idle`,
|
||||||
|
proving the cache itself (not just the immediately-visible row) was updated. All 28 tests in this
|
||||||
|
file pass, including every pre-existing one, unmodified.
|
||||||
|
- `./gradlew compileDebugKotlin compileDebugUnitTestKotlin`: BUILD SUCCESSFUL.
|
||||||
|
- `./gradlew testDebugUnitTest`: BUILD SUCCESSFUL, all unit tests passing.
|
||||||
|
- `./gradlew assembleDebug lintDebug`: BUILD SUCCESSFUL, 0 lint errors.
|
||||||
|
- Installed on the human product owner's device over wireless `adb`. Live-verified: confirmed
|
||||||
|
`RobotAutomationDevelopment-Dept/LGE-OC6-Inline-CylinderTypeWelder` was genuinely starred
|
||||||
|
server-side via the FAVORITES tab (★ 1 shown there), then located the same repository on the ALL
|
||||||
|
list (page 2) after a fresh app launch and confirmed it showed a filled green ★ 1 immediately,
|
||||||
|
with no tap needed — screenshot-verified, no crash in `logcat`.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- Still session-scoped, not persisted across app restarts independent of this cache's own
|
||||||
|
invalidation rules — this was always going to require re-fetching the starred set on a fresh
|
||||||
|
session anyway, since Gitea (not this app) is the source of truth for who has starred what.
|
||||||
|
- If the account's starred set changes from somewhere else (the Gitea web UI, another device)
|
||||||
|
mid-session, the ALL list won't reflect that until the next `refreshRepositories()` (pull-to-refresh)
|
||||||
|
or a full logout/login — the same staleness window every other cached list in this app already
|
||||||
|
has.
|
||||||
|
|
||||||
|
## Review findings
|
||||||
|
|
||||||
|
None yet — pending Codex/human review.
|
||||||
Loading…
x
Reference in New Issue
Block a user