From 683305e6c8f913da5aee18416dc3e9de17d28e24 Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Sat, 29 Aug 2026 17:36:15 +0900 Subject: [PATCH] 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 --- .../repositories/RepositoryListViewModel.kt | 53 ++++++++- .../RepositoryListViewModelTest.kt | 34 ++++++ docs/development-log.md | 14 +++ ...ready-starred-repos-show-filled-on-load.md | 101 ++++++++++++++++++ 4 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 tasks/037-already-starred-repos-show-filled-on-load.md diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt index 1f508bd..5c73fb8 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt @@ -164,6 +164,13 @@ class RepositoryListViewModel( private var repositoryContent: RepositoryListUiState.Content? = null private var cachedOrganizations: List? = null private val repositorySnapshots = mutableMapOf>() + // 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? = null init { startOperation { restoreSession() } @@ -260,6 +267,7 @@ class RepositoryListViewModel( else -> return } repositorySnapshots.remove(RepositorySnapshotKey(mode, organization)) + starredCoordinatesCache = null startOperation { loadRepositoryPage(account, page, mode, organization) } } @@ -308,8 +316,18 @@ class RepositoryListViewModel( viewModelScope.launch { try { when { - alreadyStarredThisSession -> favoritesApi.unstar(repository.coordinates) - mode == RepositoryListMode.ALL -> favoritesApi.star(repository.coordinates) + alreadyStarredThisSession -> { + 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 -> { favoritesApi.unstar(repository.coordinates) removeRow(repository.coordinates) @@ -389,6 +407,7 @@ class RepositoryListViewModel( repositoryContent = null cachedOrganizations = null repositorySnapshots.clear() + starredCoordinatesCache = null mutableState.value = RepositoryListUiState.SignedOut } } @@ -461,7 +480,11 @@ class RepositoryListViewModel( 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 = 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) { repositoryContent = null mutableState.value = RepositoryListUiState.Empty(accountName, mode, organization) @@ -524,6 +547,27 @@ class RepositoryListViewModel( 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 { + 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) { content.repositories .filter { it.lastPushedBranch is LastPushedBranchUiState.Loading } @@ -651,7 +695,7 @@ class RepositoryListViewModel( operation = viewModelScope.launch { block() } } - private fun RepositorySummary.toUiModel() = RepositoryRowUiModel( + private fun RepositorySummary.toUiModel(alreadyStarred: Boolean = false) = RepositoryRowUiModel( coordinates = coordinates, description = description, isPrivate = isPrivate, @@ -659,6 +703,7 @@ class RepositoryListViewModel( updatedAtEpochMillis = updatedAtEpochMillis, language = language, starsCount = starsCount, + favoriteAction = if (alreadyStarred) FavoriteActionUiState.Starred else FavoriteActionUiState.Idle, ) class Factory(private val application: Application) : ViewModelProvider.Factory { diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt index 1501920..547b9f1 100644 --- a/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModelTest.kt @@ -323,6 +323,40 @@ class RepositoryListViewModelTest { 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 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")))) diff --git a/docs/development-log.md b/docs/development-log.md index dc5dbdf..3ca38e3 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -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. - 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. + +## 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. diff --git a/tasks/037-already-starred-repos-show-filled-on-load.md b/tasks/037-already-starred-repos-show-filled-on-load.md new file mode 100644 index 0000000..be6e2cd --- /dev/null +++ b/tasks/037-already-starred-repos-show-filled-on-load.md @@ -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.