# TASK-025: Tabbed branch detail screen (Files + History), repository row commit time, swipeable repository/favorites tabs, and live-testing follow-up fixes Status: implemented; all required checks pass; device verification pending Owner: Claude Reviewer: Codex ## Outcome Three more live requests from the same testing session: 1. Tapping a branch/tag now opens one screen with two swipeable tabs — **파일** (Files, the code browser, default) and **히스토리** (History) — over the same ref, restoring file/folder browsing (removed from the UI by TASK-024) while keeping the direct-to-history behavior the human product owner asked for, by making history the second tab instead of the only destination. 2. Each repository row on the repository list now shows its last-commit time (reusing the existing `updatedAtEpochMillis` field and the shared `formatInstant` helper from TASK-024 — no new API data needed). 3. The repository list's 전체(All)/즐겨찾기(Favorites) tabs are now swipeable (swipe right from All → Favorites, swipe left from Favorites → All), not just tap-to-switch. Plus seven more follow-up fixes/changes found or requested during the same live-testing pass on the same branch, kept in this task rather than split out since they're all small, tightly related touch-ups to screens this task already modifies or reuses: 4. **Overlap bug fix**: the narrow-layout (non-Z-Fold-unfolded) branch of `CommitDetailScreen`'s `BoxWithConstraints` had `Header(...)` and its content as bare siblings instead of children of a `Column`, so `BoxWithConstraints` (which behaves like `Box` — it does not arrange multi-child branches vertically on its own) rendered them stacked on top of each other. Reported twice by the human product owner via screenshot before being root-caused; fixed by wrapping each branch's contents in `Column(Modifier.fillMaxSize()) { ... }`. 5. **Status-bar padding fix**: `BranchDetailScreen`'s `TabRow` was jammed against the status bar because, unlike every other top-level screen in the app, it wasn't wrapped in its own `Scaffold`. Fixed with `Modifier.statusBarsPadding()` on the outer `Column` (not a nested `Scaffold`, to avoid double-padding since `CodeBrowserScreen`/`HistoryScreen` already have their own `Scaffold`s internally). 6. **Diff horizontal-scroll fix, in two passes**: `DiffSideLine` (in the History tab's file-diff view) originally had `Modifier.fillMaxWidth().horizontalScroll(state)` and no `softWrap = false`, so long lines wrapped instead of overflowing — nothing to scroll. First pass fixed that by removing `fillMaxWidth()` and adding `softWrap = false`, matching the already-working `PatchText` composable's modifier pattern — but that pattern shares one `ScrollState` across every row in the panel (so dragging any line moves them all together), and a single `ScrollState`'s scroll bound is set from whichever row's layout pass ran most recently; with many differently-wide lines in the same list, that bound kept getting overwritten mid-drag, snapping the visible offset back and forth ("글자가 춤을 추네" — reported live, with a screenshot, after the first pass had already been installed). Second pass replaced the shared `ScrollState` with a hand-rolled scroll: one `Modifier.scrollable(...)` per row feeding a single shared offset value, clamped against one bound computed once up front (via `TextMeasurer`, from the longest line in the file) instead of recomputed per row, then applied to each row with a plain `Modifier.offset { ... }` — so no per-row layout pass can stomp on another row's bound anymore. 7. **Loading-text simplification**: the references-loading message on the repository screen previously interpolated the repository name into a longer sentence; simplified to a plain "로딩 중" per direct request, and the now-unused `%1$s` placeholder removed from `repositories_references_loading`. 8. **In-app back buttons removed**: every screen-header back button (`CodeBrowserScreen`'s `BrowserHeader`, `HistoryScreen`'s `Header`, `NotificationsScreen`'s `FilterTabs`, `WorkItemsScreen`'s `ModeTabs` and `DetailHeader`, `RepositoryListScreen`'s `ReferencesContent` header) removed, since every screen already has a `BackHandler` wired to the system back gesture and the human product owner asked to rely on that instead. `WorkItemsScreen`'s `DetailHeader` composable (which consisted only of a back button) removed entirely along with all 5 call sites. Now-unused `history_back`/`notifications_back`/`workitems_back`/`codebrowser_back` string resources removed; `repositories_back` kept, since it's a distinct recovery action on the references-error state's secondary button, not a header back button. 9. **Pull-to-refresh replaces manual refresh buttons**: `RepositoryListScreen`'s `RepositoryContent` and `ReferencesContent` each had a manual "새로고침" `OutlinedButton`; both replaced with `PullToRefreshBox` (Material3, `@OptIn(ExperimentalMaterial3Api::class)`) wrapping their `LazyColumn`s, so pulling down from the top refreshes instead. 10. **Tab-swipe vs. code-scroll gesture conflict fixed**: the Files/History `HorizontalPager` and the diff view's horizontal code scroll (item 6) are both horizontal gestures over the same screen; nested, they fought over every drag — reported live as visible "trembling" and, separately, source text intermittently scrolling out of view entirely. Root-caused (by the human product owner, from directly watching it happen) as the pager and the inner scroll both trying to consume the same drag. Fixed by disabling the pager's own swipe gesture (`userScrollEnabled = false`) whenever the currently-visible tab is showing horizontally-scrollable source text — a code file's contents on the Files tab, or a selected diff on the History tab — and re-enabling it everywhere else (folder listings, the commit list, a commit's changed-file list), so tab-swiping still works right up to the point actual code is on screen. Tapping a tab always still works regardless, since that's a direct `animateScrollToPage` call, not a gesture. Verified in both directions live via scripted `adb shell input swipe` (an earlier same-session test of this had looked like a regression, but was actually just a swipe fired in the wrong direction from the last page, which has nowhere further to go — not a real bug). ## Scope - Included: `ui/navigation/Route.kt`/`RouteStackSaver.kt` (`Route.CodeBrowser`/`Route.History` replaced by one `Route.BranchDetail`), new `feature/branchdetail/BranchDetailScreen.kt`, `MainActivity.kt` wiring, `strings_branchdetail.xml`, plus the follow-up touch-ups (items 4-10 above) to `feature/branchdetail/BranchDetailScreen.kt` (item 10), `feature/history/HistoryScreen.kt`, `feature/codebrowser/CodeBrowserScreen.kt`, `feature/notifications/NotificationsScreen.kt`, `feature/workitems/WorkItemsScreen.kt`, `feature/repositories/RepositoryListScreen.kt`, and their string resources. - Excluded: No change to `CodeBrowserViewModel`/`HistoryViewModel` internals — both are reused exactly as they were, including their own internal drill-down (subfolder navigation, commit detail) and their own per-state `BackHandler`s. This screen only adds the `TabRow` + `HorizontalPager` chrome around them. No change to actual refresh/pagination/network logic — item 9 only changes the trigger UI, not what `onRefresh` does. ## Design notes - Both tabs' ViewModels (`codeBrowserViewModel`, `historyViewModel`) are opened together when `Route.BranchDetail` becomes current, and closed together when either tab's internal back-handling has nothing left to unwind (`onCodeBrowserBack`/`onHistoryBack` both close both ViewModels and pop the route) — a single shared exit point rather than two independently-tracked one. - `HorizontalPager`'s default `beyondViewportPageCount` (0) means only the currently visible tab is composed, so each tab's own internal `BackHandler`s never conflict with each other — the system back button always applies to whichever tab is actually on screen, with no extra coordination code needed. - `CodeBrowserScreen`'s existing "히스토리" header button is kept, repurposed to animate the pager to the History tab instead of pushing a route — a working existing affordance, not dead code. - `Route.CodeBrowser` and `Route.History` are fully removed (not left dead), since `Route.BranchDetail` is now the only way either screen is reached. ## Acceptance criteria - [x] Tapping a branch/tag opens `Route.BranchDetail`, defaulting to the Files tab. - [x] Files tab supports folder drill-down and reading file contents (the restored TASK-006 code browser, unmodified). - [x] Swiping left/right between tabs works (`HorizontalPager`); tapping a tab also works (`TabRow`). - [x] System back: within a subfolder/commit-detail, back unwinds that tab's own internal state first; only at each tab's own root does back exit the branch-detail screen entirely. - [x] `RouteEncoding`/`RouteStackSaver` round-trip `Route.BranchDetail` including a ref containing a slash, and fall back to `Repositories` on truncated/malformed persisted data. - [x] Commit-detail screen renders correctly (no overlapping content) in the narrow/folded layout. - [x] `BranchDetailScreen`'s tab row sits below the status bar, not jammed against it. - [x] Diff view supports horizontal scroll on long lines (synced across before/after panels). - [x] References-loading message reads a plain "로딩 중". - [x] No in-app back buttons remain on any screen header; system back gesture still exits each screen/subview correctly. - [x] Pulling down from the top of the repository list or references list triggers a refresh; no manual refresh button remains. - [x] Build, unit tests, and lint pass. - [ ] Full device confirmation of the tab UI, swipe gesture, back-button removal, and pull-to-refresh together in one pass. ## Required checks ```text ./gradlew assembleDebug ./gradlew testDebugUnitTest ./gradlew lintDebug ``` ## Evidence - `feature/branchdetail/BranchDetailScreen.kt` (new): `TabRow` + `HorizontalPager(pageCount = { 2 })`, page 0 = `CodeBrowserScreen`, page 1 = `HistoryScreen`, both passed through unmodified with their full existing parameter lists. - `ui/navigation/Route.kt`: `CodeBrowser`/`History` replaced by `BranchDetail(owner, name, refKind, ref)`. `RouteStackSaver.kt`'s `RouteEncoding` updated to match (`TYPE_BRANCH_DETAIL` replaces `TYPE_CODE_BROWSER`/`TYPE_HISTORY`). - `MainActivity.kt`: `onReferenceSelected` now maps the tapped reference's `ReferenceKind` (branch/tag) to `CodeReferenceKind` and pushes one `Route.BranchDetail`, matching what previously only fed `Route.CodeBrowser`'s `refKind` field — this mapping had been temporarily lost when TASK-024 redirected the tap straight to `Route.History` (which didn't need a ref kind); this task restores it. - `RouteEncodingTest.kt` updated: `code browser route round-trips` and `history route round-trips` tests replaced by one `branch detail route round-trips including a ref containing a slash` test; the truncated-route and full-back-stack tests updated to match. - `HistoryScreen.kt`'s `CommitDetailScreen`: each `BoxWithConstraints` branch now wraps `Header(...)` + content in `Column(Modifier.fillMaxSize()) { ... }` instead of leaving them as bare siblings. - `BranchDetailScreen.kt`: outer `Column` gained `Modifier.statusBarsPadding()`. - `HistoryScreen.kt`'s `DiffSideLine` (first pass, superseded — see below): `Modifier.horizontalScroll(horizontalScrollState).padding(...)` with `softWrap = false`, no `fillMaxWidth()`. - `HistoryScreen.kt`'s `DiffSideLine`/`FileDiffView` (second pass, current): shared `ScrollState` replaced by a manually-driven offset — `rememberScrollableState { delta -> ... }` updates one `MutableFloatState` clamped to `[0, maxHorizontalOffsetPx]`, where `maxHorizontalOffsetPx` is computed once per selected file via `rememberTextMeasurer().measure(...)` against the longest line's character count (monospace, so any character stands in for the widest line's glyph width). Each row applies `Modifier.scrollable(sharedScrollableState, Orientation.Horizontal)` for gesture capture and `Modifier.offset { IntOffset(-horizontalOffsetPx.value.roundToInt(), 0) }` for rendering, wrapped in `Box(Modifier.fillMaxWidth().clipToBounds())` since a plain `offset` (unlike `horizontalScroll`) doesn't clip overflow on its own. - `strings_repositories.xml`: `repositories_references_loading` simplified to a fixed "로딩 중" string (no `%1$s` placeholder); call site in `RepositoryListScreen.kt` updated to match. - Back buttons removed from `CodeBrowserScreen.BrowserHeader`, `HistoryScreen.Header`, `NotificationsScreen.FilterTabs`, `WorkItemsScreen.ModeTabs`; `WorkItemsScreen.DetailHeader` composable and its 5 call sites removed entirely; `ReferencesContent`'s header back button removed. `history_back`/`notifications_back`/`workitems_back`/`codebrowser_back` string resources removed as now-unused. - `RepositoryListScreen.kt`: `RepositoryContent`/`ReferencesContent` wrapped in `PullToRefreshBox` (`@OptIn(ExperimentalMaterial3Api::class)`), manual "새로고침" `OutlinedButton`s removed from both. - `./gradlew compileDebugKotlin`: BUILD SUCCESSFUL, both after the `@OptIn(ExperimentalMaterial3Api::class)` fix for `PullToRefreshBox` and again after the diff-scroll rewrite. - `./gradlew assembleDebug testDebugUnitTest lintDebug` (`--rerun-tasks` on the first full pass, incremental on the diff-scroll-rewrite follow-up): BUILD SUCCESSFUL both times, all unit tests passing, 0 lint errors. - `HistoryScreen.kt`'s diff-scroll `rememberScrollableState` (third pass): verified live (by directly swiping the device over `adb`) that the jitter-free scroll from the second pass ran in the wrong direction — dragging left revealed the start of the line instead of further along it, backwards from every other horizontally-scrollable surface on Android. Root cause: `newOffset = horizontalOffsetPx + delta` had the sign backwards for this gesture; fixed to `newOffset = horizontalOffsetPx - delta`, confirmed correct by the same live-swipe method (drag left now reveals later content, drag right returns toward the start, both panels stay in sync). - `BranchDetailScreen.kt`: `HorizontalPager` gained `userScrollEnabled = !currentPageShowsScrollableCode`, where `currentPageShowsScrollableCode` checks `codeBrowserState is CodeBrowserUiState.File && codeBrowserState.content is ContentClassification.Text` on page 0, or `historyState is HistoryUiState.CommitDetailContent && historyState.selectedFile != null` on page 1 — both states were already being collected in `MainActivity` and passed into this screen, so no new state plumbing was needed. - Live device: build installed over wireless `adb` (`adb-RFCYA0RD1CZ-ztOzAA._adb-tls-connect._tcp`, Galaxy Z Fold) five times across this task — initial batch, after the diff-scroll jitter fix, after the direction fix, and after the tab-swipe/code-scroll conflict fix. The last two were verified directly via scripted `adb shell input swipe` plus before/after screenshots (the human product owner requested this directly — "좌우 스크롤 해서 이상한걸 감지해볼래?" — rather than reporting a symptom first) instead of waiting on a live report; the human product owner then separately diagnosed the tab-swipe/scroll conflict's root cause from watching it happen and directed the fix. Human product owner asked to confirm the rest of the batch (tabs, overlap fix, status-bar padding, back-button removal, pull-to-refresh) together — result pending as of this commit. ## Review findings None yet — no Codex review pending (Codex retired, 2026-09-29).