TASK-069: rows keep their branch while the repository list reloads

Going between the two repository tabs sent every row back to "checking"
and then to its branch chip, which changed each row's height and the
lines of its text twice per move. The view model keeps the branch last
found for each repository and starts a reloaded list with it, still
checking for a newer one.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-10-12 17:26:22 +09:00
parent e1abdfc4ca
commit 4dab3a4ba4
3 changed files with 51 additions and 16 deletions

View File

@ -185,6 +185,10 @@ class RepositoryListViewModel(
val browserRequests: SharedFlow<String> = mutableBrowserRequests.asSharedFlow() val browserRequests: SharedFlow<String> = mutableBrowserRequests.asSharedFlow()
private var operation: Job? = null private var operation: Job? = null
private val rowBranchJobs = mutableMapOf<RepositoryCoordinates, Job>() private val rowBranchJobs = mutableMapOf<RepositoryCoordinates, Job>()
// The branch each repository was last found to have been pushed to. A list that is loaded
// again (a tab change, a refresh, a page turn) shows this while it checks, where it used to
// show "checking" on every row: the rows changed height and their text changed line, twice.
private val knownRowBranches = mutableMapOf<RepositoryCoordinates, LastPushedBranchUiState>()
// Repositories whose star/unstar request is still on its way. A second tap on the same row is // Repositories whose star/unstar request is still on its way. A second tap on the same row is
// ignored until the first request settles: the tap flips the star at once, so without this a // ignored until the first request settles: the tap flips the star at once, so without this a
// quick double tap sent a star and an unstar together, and whichever reached the server last // quick double tap sent a star and an unstar together, and whichever reached the server last
@ -475,6 +479,7 @@ class RepositoryListViewModel(
tokenVault.clear() tokenVault.clear()
repositoryListCache.clear() repositoryListCache.clear()
activeAccountName = null activeAccountName = null
knownRowBranches.clear()
hasAuthenticatedSession = false hasAuthenticatedSession = false
repositoryContent = null repositoryContent = null
cachedOrganizations = null cachedOrganizations = null
@ -496,6 +501,7 @@ class RepositoryListViewModel(
if (failure is ApiFailure.AuthenticationExpired) { if (failure is ApiFailure.AuthenticationExpired) {
tokenVault.clear() tokenVault.clear()
activeAccountName = null activeAccountName = null
knownRowBranches.clear()
hasAuthenticatedSession = false hasAuthenticatedSession = false
} }
mutableState.value = RepositoryListUiState.Error( mutableState.value = RepositoryListUiState.Error(
@ -527,6 +533,7 @@ class RepositoryListViewModel(
if (failure is ApiFailure.AuthenticationExpired) { if (failure is ApiFailure.AuthenticationExpired) {
tokenVault.clear() tokenVault.clear()
activeAccountName = null activeAccountName = null
knownRowBranches.clear()
hasAuthenticatedSession = false hasAuthenticatedSession = false
} }
val error = failure.toRepositoryListError() val error = failure.toRepositoryListError()
@ -563,7 +570,10 @@ class RepositoryListViewModel(
// showing) -- FavoriteStar's own rendering already handles that mode without consulting // showing) -- FavoriteStar's own rendering already handles that mode without consulting
// favoriteAction at all, so the cross-reference below only matters for ALL. // favoriteAction at all, so the cross-reference below only matters for ALL.
val alreadyStarred = if (mode == RepositoryListMode.ALL) loadStarredCoordinatesOnce() else emptySet() val alreadyStarred = if (mode == RepositoryListMode.ALL) loadStarredCoordinatesOnce() else emptySet()
val rows = pageItems.map { it.toUiModel(alreadyStarred = it.coordinates in alreadyStarred) } val rows = pageItems.map { summary ->
val row = summary.toUiModel(alreadyStarred = summary.coordinates in alreadyStarred)
knownRowBranches[row.coordinates]?.let { row.copy(lastPushedBranch = it) } ?: row
}
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)
@ -580,12 +590,13 @@ class RepositoryListViewModel(
) )
repositoryContent = content repositoryContent = content
mutableState.value = content mutableState.value = content
loadRowBranches(content) loadRowBranches(content, all = true)
} catch (failure: Throwable) { } catch (failure: Throwable) {
if (failure is CancellationException) throw failure if (failure is CancellationException) throw failure
if (failure is ApiFailure.AuthenticationExpired) { if (failure is ApiFailure.AuthenticationExpired) {
tokenVault.clear() tokenVault.clear()
activeAccountName = null activeAccountName = null
knownRowBranches.clear()
hasAuthenticatedSession = false hasAuthenticatedSession = false
repositoryContent = null repositoryContent = null
} }
@ -731,9 +742,9 @@ class RepositoryListViewModel(
return loaded return loaded
} }
private fun loadRowBranches(content: RepositoryListUiState.Content) { private fun loadRowBranches(content: RepositoryListUiState.Content, all: Boolean = false) {
content.repositories content.repositories
.filter { it.lastPushedBranch is LastPushedBranchUiState.Loading } .filter { all || it.lastPushedBranch is LastPushedBranchUiState.Loading }
.forEach { launchRowBranchFetch(it.coordinates) } .forEach { launchRowBranchFetch(it.coordinates) }
} }
@ -745,22 +756,21 @@ class RepositoryListViewModel(
val latest = branches val latest = branches
.filter { it.commitAtEpochMillis != null } .filter { it.commitAtEpochMillis != null }
.maxByOrNull { requireNotNull(it.commitAtEpochMillis) } .maxByOrNull { requireNotNull(it.commitAtEpochMillis) }
updateRow(coordinates) { val found = latest?.let { branch ->
it.copy(
lastPushedBranch = latest?.let { branch ->
LastPushedBranchUiState.Loaded(branch.name.value) LastPushedBranchUiState.Loaded(branch.name.value)
} ?: if (branches.isEmpty()) { } ?: if (branches.isEmpty()) {
LastPushedBranchUiState.Empty LastPushedBranchUiState.Empty
} else { } else {
LastPushedBranchUiState.Failed LastPushedBranchUiState.Failed
},
)
} }
if (found != LastPushedBranchUiState.Failed) knownRowBranches[coordinates] = found
updateRow(coordinates) { it.copy(lastPushedBranch = found) }
} catch (failure: Throwable) { } catch (failure: Throwable) {
if (failure is CancellationException) throw failure if (failure is CancellationException) throw failure
if (failure is ApiFailure.AuthenticationExpired) { if (failure is ApiFailure.AuthenticationExpired) {
clearSessionOnAuthenticationExpired() clearSessionOnAuthenticationExpired()
} else { } else if (coordinates !in knownRowBranches) {
// A check that fails leaves a branch already known where it is.
updateRow(coordinates) { it.copy(lastPushedBranch = LastPushedBranchUiState.Failed) } updateRow(coordinates) { it.copy(lastPushedBranch = LastPushedBranchUiState.Failed) }
} }
} }
@ -771,6 +781,7 @@ class RepositoryListViewModel(
cancelRowBranchJobs() cancelRowBranchJobs()
tokenVault.clear() tokenVault.clear()
activeAccountName = null activeAccountName = null
knownRowBranches.clear()
hasAuthenticatedSession = false hasAuthenticatedSession = false
repositoryContent = null repositoryContent = null
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED) mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)
@ -830,6 +841,7 @@ class RepositoryListViewModel(
if (failure is ApiFailure.AuthenticationExpired) { if (failure is ApiFailure.AuthenticationExpired) {
tokenVault.clear() tokenVault.clear()
activeAccountName = null activeAccountName = null
knownRowBranches.clear()
hasAuthenticatedSession = false hasAuthenticatedSession = false
repositoryContent = null repositoryContent = null
mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED) mutableState.value = RepositoryListUiState.Error(RepositoryListError.AUTHENTICATION_EXPIRED)

View File

@ -1708,3 +1708,21 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- Caution: what remains (about 25 to 50 ms on the first frame) is in good part the debug build; - Caution: what remains (about 25 to 50 ms on the first frame) is in good part the debug build;
a non-debuggable build of the same code measured about a third less. R8 is still on hold a non-debuggable build of the same code measured about a third less. R8 is still on hold
under TASK-016. under TASK-016.
## 2026-10-12 (continued) — TASK-069: rows keep their lines between repository tabs
- Request: the product owner turned the screen back on for the measurement left open, and
reported that going between 전체 저장소 and 즐겨찾기 kept changing the lines of text.
- Measured, the branches beside the list: opening starts with a frame of 30–42 ms and slides for
the full 390 ms, and the rows are built in one 52–64 ms frame after the slide has stopped;
Back starts with 13–17 ms and slides without a slow frame (it was a cut with 69–105 ms).
- What was found: a capture taken 120 ms into the slide showed every row saying
"마지막 push 브랜치 확인 중…". Each load of the list began every row at that text and
replaced it with the branch chip when the check returned; the chip is taller and, with a long
branch name, pushes the date to a line of its own, so each row changed height twice per move.
- What changed: `RepositoryListViewModel` keeps the branch last found for each repository and a
list loaded again starts its rows with it, still checking every row for a newer one. A check
that fails leaves a known branch in place. The record is dropped with the session.
- Measured after: the same capture shows the chips in place during the slide; the repository
tab change starts with 35–53 ms and has no frame over 25 ms after it.
- Checks and results: the five required checks pass.

View File

@ -39,6 +39,10 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
120 Hz screen. A move now starts without every screen being drawn again; the two repository 120 Hz screen. A move now starts without every screen being drawn again; the two repository
lists stay built side by side; a repository's branches open beside the list and not in place lists stay built side by side; a repository's branches open beside the list and not in place
of it; nothing that loops is drawn while it is off screen. of it; nothing that loops is drawn while it is off screen.
14. Reported by the product owner (2026-10-12): going between 전체 저장소 and 즐겨찾기 changed
the lines of text in the rows. Each row keeps the branch it was last found to have been
pushed to while the list is loaded again, where it went back to "확인 중" and then to the
branch, changing the row's height twice.
## Scope ## Scope
@ -53,6 +57,7 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt` - `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt` - `app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt` - `app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt` - `app/src/main/java/com/ubisam/ubgitea/feature/work/WorkScreen.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt` - `app/src/main/java/com/ubisam/ubgitea/feature/notifications/NotificationsScreen.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt` - `app/src/main/java/com/ubisam/ubgitea/feature/workitems/WorkItemsScreen.kt`
@ -63,7 +68,7 @@ The app moves where it used to jump, and what moves is the launcher icon's tea c
## Acceptance criteria ## Acceptance criteria
- [x] Each of the thirteen items above is in the code and the app builds. - [x] Each of the fourteen items above is in the code and the app builds.
- [x] The splash covers the app without delaying it: sign-in, loading and the OAuth callback run - [x] The splash covers the app without delaying it: sign-in, loading and the OAuth callback run
underneath, and taps do not pass through the cover. underneath, and taps do not pass through the cover.
- [x] Moving between code tabs, or between pages of one screen, does not replay the screen - [x] Moving between code tabs, or between pages of one screen, does not replay the screen