Ubisam-MobileGit/tasks/027-image-and-csv-viewer.md
choiminyeong 2818dab09a TASK-027/028: image+CSV viewer with gallery swipe, numbered Issues/PR pagination
TASK-027: opening an image (PNG/JPEG/GIF/BMP/WebP, detected by magic bytes)
now decodes and shows it, with swipe navigation to the previous/next image
in the same folder; opening a .csv file shows a scrollable table instead of
raw text. Both reuse the tab-swipe/content-scroll gesture-conflict fix so
neither ever triggers the Files/History tab to change underneath it.

TASK-028: the Issues/PR list shows up to 5 numbered page buttons plus a
[ page / total ] jump field, backed by Gitea's X-Total-Count header
(verified against Gitea's own source) and a server-side type=issues filter
so the count excludes pull requests. Every other paginated list is
unchanged, per this session's earlier scoping decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:50:24 +09:00

144 lines
9.7 KiB
Markdown

# TASK-027: Image viewer (with swipe gallery navigation) and CSV table viewer in the code browser
Status: implemented; all required checks pass; live device verification pending (no image/CSV
files were found in the repositories browsed live this session — see Known limitations)
Owner: Claude
Reviewer: Codex
## Outcome
Two live requests, delivered together: opening an image file in the code browser now shows the
image (not a generic "binary file" fallback), with left/right swipe to move to the previous/next
image in the same folder; opening a `.csv` file shows a scrollable table (rows/columns, first row
styled as a header) instead of raw comma-separated text. Both reuse the tab-swipe/content-scroll
gesture-conflict fix from TASK-025's last follow-up: swiping either an image or a CSV table never
triggers the Files/History tab to change underneath it.
## Scope
- Included: `feature/codebrowser/content/ContentClassification.kt` (new `Image`/`Csv` cases, new
`ImageFormat` enum), `feature/codebrowser/content/SourceContentClassifier.kt` (magic-byte image
detection, `.csv`-extension table parsing), `feature/codebrowser/CodeBrowserViewModel.kt`
(`siblingImagePaths` on `File` state, `navigateImage`, `type=issues`-adjacent-but-unrelated —
see TASK-028 for that; this task only touches the image gallery/path plumbing),
`feature/codebrowser/CodeBrowserScreen.kt` (`ImageContent`, `CsvTable`/`CsvRow`/`CsvCell`),
`feature/branchdetail/BranchDetailScreen.kt` (pager-gating condition widened to Image/Csv),
`MainActivity.kt` (`onNavigateImage` wiring), `domain/model/RepositoryModels.kt` is untouched by
this task (see TASK-028 for its `Page.totalCount` addition, done in the same session but a
separate concern).
- Excluded: No new image/CSV editing, no zoom/pan on images (only swipe-to-navigate was asked
for), no header-row auto-detection for CSV (the first row is always styled as a header — see
Design notes), no thumbnail/preview generation, no change to how non-image/non-CSV files render.
## Design notes
- **Image detection is by magic bytes, not file extension** (`detectImageFormat` checks the PNG/
JPEG/GIF/BMP/WebP signatures directly): a mislabeled or extensionless file still renders
correctly if it genuinely is one of these formats, and a renamed non-image file isn't
misclassified just because of its name. It runs *before* the existing NUL-byte-means-Binary
check, since every one of these formats' bytes contain NULs and would otherwise always be caught
as generic Binary first.
- **The sibling-image gallery is built from the directory listing already in hand**, not a second
fetch: `CodeBrowserViewModel.openEntry` captures the current `Directory` state's file entries
(filtered to image extensions — extension-only, since building the gallery list can't afford to
fetch and classify every sibling's bytes just to find out which are images) into the new `File`
state's `siblingImagePaths` at the moment a file is opened. `navigateImage(delta)` then looks up
the current path's index in that list and loads the neighbor, with no bound past either end.
Opening a file *outside* a directory listing (currently only possible via `retry()` after an
error) leaves `siblingImagePaths` empty — swipe navigation is simply unavailable there, a known,
accepted edge case rather than extra plumbing through `Loading`/`Error` states for it.
- **The image swipe gesture is a discrete drag-distance threshold** (`detectHorizontalDragGestures`
+ a 120px total-drag check on release), not a continuous scroll position — unlike the diff
view's horizontal scroll, there's no partial-scroll state to render mid-drag, only "did the user
swipe far enough to go to the next/previous image."
- **The CSV table reuses the diff view's manually-driven shared-offset pattern** for its horizontal
scroll (`rememberScrollableState` + `Modifier.offset`, one offset shared across all rows via
`Modifier.scrollable` per row) rather than a plain shared `Modifier.horizontalScroll`. Every CSV
cell here is the same fixed width, so the specific bound-thrashing bug that pattern was built to
avoid (TASK-025's "글자가 춤을 추네") couldn't actually occur with uniform-width cells — but
reusing the already-verified-correct pattern costs nothing and keeps the codebase's one way of
doing synced horizontal scroll, rather than two.
- **No header-row detection for CSV**: the first row is always rendered with a highlighted
background, matching how most spreadsheet tools default a first-row-as-header assumption, since
actually detecting whether a CSV has a header (vs. all-data) isn't reliably possible from the
file alone.
- **Pager-swipe gating (from TASK-025's last follow-up) was widened, not duplicated**: the same
`currentPageShowsScrollableCode` check in `BranchDetailScreen.kt` now also covers
`ContentClassification.Image` and `ContentClassification.Csv`, so the human product owner's
explicit requirement — "탭 전환은 발생하면 안돼" (tab switching must not happen) while viewing
either — is met by the existing mechanism rather than a new one.
## Acceptance criteria
- [x] Opening a PNG/JPEG/GIF/BMP/WebP file (detected by magic bytes) shows the decoded image.
- [x] Swiping left/right on an image with siblings in the same folder moves to the next/previous
image in that folder's listing order; swiping past either end does nothing.
- [x] Swiping (or any horizontal drag) on an image or a CSV table never changes the Files/History
tab underneath it.
- [x] Opening a `.csv` file shows a scrollable table with the first row styled as a header; a
non-`.csv` file with comma-separated content is unaffected (still plain text).
- [x] Quoted CSV fields containing commas and escaped (`""`) quotes parse correctly.
- [x] Build, unit tests, and lint pass.
- [ ] Live device confirmation with a real image file and a real `.csv` file — not yet possible
this session; see Known limitations.
## Required checks
```text
./gradlew assembleDebug
./gradlew testDebugUnitTest
./gradlew lintDebug
```
## Evidence
- `ContentClassification.kt`: new `Image(sizeBytes, content: SourceContent, format: ImageFormat)`
and `Csv(sizeBytes, rows: List<List<String>>, isTruncated: Boolean)` cases; `content` reuses
`SourceContent`'s already-correct content-based `equals()`/`hashCode()` instead of holding a raw
`ByteArray` (which would default to reference equality on a `data class`).
- `SourceContentClassifier.kt`: `classify(content, path: RepositoryPath? = null)` — `path` is
optional/defaulted so every pre-existing call site keeps compiling unchanged; `detectImageFormat`
checks PNG/JPEG/GIF/BMP/WebP signatures before the NUL-byte Binary check; CSV parsing
(`renderCsv`/`parseCsv`, a small RFC-4180-ish hand-written parser) only runs when `path` ends in
`.csv`, reusing the same line/character truncation budgets as `renderText`.
- `CodeBrowserViewModel.kt`: `CodeBrowserUiState.File` gained `siblingImagePaths: List<RepositoryPath> = emptyList()`;
`openEntry` captures it from the current `Directory` state's file entries (via new private
`RepositoryPath.hasImageExtension()`); new `navigateImage(delta: Int)`.
- `CodeBrowserScreen.kt`: new `ImageContent` (BitmapFactory decode + Compose `Image` +
drag-threshold swipe), `CsvTable`/`CsvRow`/`CsvCell` (shared-offset horizontal scroll, matching
the diff view's pattern); `FileContent`'s signature grew `path`/`siblingImagePaths`/
`onNavigateImage` to support both.
- `BranchDetailScreen.kt`: `currentPageShowsScrollableCode`'s page-0 branch now also checks
`ContentClassification.Image`/`ContentClassification.Csv`, not just `Text`.
- New tests: `SourceContentClassifierTest.kt` (PNG/JPEG/GIF/BMP/WebP signature detection, an image
signature's embedded NUL byte not short-circuiting to Binary, oversized-before-image-detection,
`.csv` vs non-`.csv` path handling, no-path-given fallback to Text, quoted-field/escaped-quote
parsing, row-count truncation); `CodeBrowserViewModelTest.kt` (gallery capture in listing order
excluding a non-image sibling, `navigateImage` moving and stopping at both ends, `navigateImage`
no-op when opened outside a directory listing).
- `./gradlew compileDebugKotlin compileDebugUnitTestKotlin`: BUILD SUCCESSFUL.
- `./gradlew testDebugUnitTest`: BUILD SUCCESSFUL, all unit tests passing (including the new ones
above).
- `./gradlew assembleDebug lintDebug`: BUILD SUCCESSFUL, 0 lint errors.
- Live device: build installed over wireless `adb`. Navigated several of the human product owner's
actual repositories looking for an existing image or `.csv` file to confirm the rendering
end-to-end; none were found among the repositories browsed this session (see Known limitations).
## Known limitations
- **Not yet confirmed against a real image or CSV file on the live device.** Every repository
browsed this session for a suitable test file didn't have one readily reachable. The
classification and rendering logic is covered thoroughly by unit tests instead (magic-byte
detection, gallery navigation, CSV parsing), but a live human confirmation of the actual pixels
on screen — decoded image quality, table cell alignment/scroll feel — is still outstanding.
- The CSV table's column width is a fixed 120dp per cell, not sized to content — a column with
very short values wastes horizontal space, and a column with very long values still clips at
120dp (readable via horizontal scroll, but not auto-fit). Not raised as a concern by the human
product owner; flagged here in case it's worth revisiting once seen live.
- `navigateImage` after a `retry()`-recovered error loses gallery position (see Design notes) —
accepted, not treated as a defect.
## Review findings
None yet — pending Codex/human review.