Add TASK-024: history screen redesign

Live, iterative round of changes driven by the human product owner
testing on their own device over wireless adb:
- Tapping a branch/tag opens commit history instead of the file browser
- Selected reference row is color-coded instead of a text label
- Branch/tag rows and commit rows show commit date/time with time zone
- Tapping a changed file shows a parsed before/after diff, client-side
  (new UnifiedDiffParser, no extra network call)
- The two diff panels scroll vertically in sync and share one
  horizontal scroll position
- Diff lines are colored by text only (red/green)
- On a >=600dp-wide screen (a Z Fold's inner screen unfolded) the file
  list and selected diff show side by side
- Removed the branch/tag compare feature entirely, per direct request

Also fixes a real bug found via live device debugging: getCommitDetail
requested the wrong URL path (missing "git/"), causing every commit
tap to 404.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-08-27 00:32:42 +09:00
parent 7da04a77ae
commit 4b3c11d849
21 changed files with 623 additions and 408 deletions

View File

@ -25,7 +25,6 @@ import com.ubisam.ubgitea.feature.history.HistoryTarget
import com.ubisam.ubgitea.feature.history.HistoryViewModel import com.ubisam.ubgitea.feature.history.HistoryViewModel
import com.ubisam.ubgitea.feature.notifications.NotificationsScreen import com.ubisam.ubgitea.feature.notifications.NotificationsScreen
import com.ubisam.ubgitea.feature.notifications.NotificationsViewModel import com.ubisam.ubgitea.feature.notifications.NotificationsViewModel
import com.ubisam.ubgitea.feature.repositories.ReferenceKind
import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen
import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel
import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen import com.ubisam.ubgitea.feature.workitems.WorkItemsScreen
@ -142,16 +141,13 @@ class MainActivity : ComponentActivity() {
onRetryList = historyViewModel::retryList, onRetryList = historyViewModel::retryList,
onRetryDetail = historyViewModel::retryDetail, onRetryDetail = historyViewModel::retryDetail,
onRetryPatch = historyViewModel::retryPatch, onRetryPatch = historyViewModel::retryPatch,
onFileSelected = historyViewModel::openFileDiff,
onClearFileSelection = historyViewModel::clearFileSelection,
onBackToList = historyViewModel::backToList, onBackToList = historyViewModel::backToList,
onBack = { onBack = {
historyViewModel.close() historyViewModel.close()
pop() pop()
}, },
onCompare = historyViewModel::compareAgainstCurrentRef,
onCompareNextPage = historyViewModel::compareNextPage,
onComparePreviousPage = historyViewModel::comparePreviousPage,
onRetryCompare = historyViewModel::retryCompare,
onLoadComparePatch = historyViewModel::loadComparePatch,
onAuthenticationExpired = ::authenticationExpired, onAuthenticationExpired = ::authenticationExpired,
) )
is Route.CodeBrowser -> CodeBrowserScreen( is Route.CodeBrowser -> CodeBrowserScreen(
@ -179,18 +175,7 @@ class MainActivity : ComponentActivity() {
onRepositorySelected = viewModel::selectRepository, onRepositorySelected = viewModel::selectRepository,
onReferenceSelected = { repository, name, kind -> onReferenceSelected = { repository, name, kind ->
viewModel.selectReference(name, kind) viewModel.selectReference(name, kind)
push( push(Route.History(repository.coordinates.owner, repository.coordinates.name, name))
Route.CodeBrowser(
owner = repository.coordinates.owner,
name = repository.coordinates.name,
refKind = if (kind == ReferenceKind.BRANCH) {
CodeReferenceKind.BRANCH.name
} else {
CodeReferenceKind.TAG.name
},
ref = name,
),
)
}, },
onReferencesRefresh = viewModel::refreshReferences, onReferencesRefresh = viewModel::refreshReferences,
onBackToRepositories = viewModel::backToRepositories, onBackToRepositories = viewModel::backToRepositories,

View File

@ -15,9 +15,7 @@ import com.ubisam.ubgitea.domain.history.FileChange
import com.ubisam.ubgitea.domain.history.FileChangeStatus import com.ubisam.ubgitea.domain.history.FileChangeStatus
import com.ubisam.ubgitea.domain.history.HistoryRepository import com.ubisam.ubgitea.domain.history.HistoryRepository
import com.ubisam.ubgitea.domain.model.CommitSummary import com.ubisam.ubgitea.domain.model.CommitSummary
import com.ubisam.ubgitea.domain.model.Page
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryRef
import java.io.IOException import java.io.IOException
import java.time.Instant import java.time.Instant
import java.time.format.DateTimeParseException import java.time.format.DateTimeParseException
@ -25,8 +23,8 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
/** /**
* Read-only commit detail, bounded diff/patch text, and ref comparison against Gitea. See * Read-only commit detail and bounded diff/patch text against Gitea. See [HistoryRepository] for
* [HistoryRepository] for why commit *listing* is intentionally absent from this class. * why commit *listing* is intentionally absent from this class.
*/ */
class GiteaHistoryApi( class GiteaHistoryApi(
private val config: OAuthClientConfig, private val config: OAuthClientConfig,
@ -42,7 +40,7 @@ class GiteaHistoryApi(
} }
override suspend fun getCommitDetail(repository: RepositoryCoordinates, sha: String): CommitDetail { override suspend fun getCommitDetail(repository: RepositoryCoordinates, sha: String): CommitDetail {
val dto = parseObject<CommitDto>(get(repoUrl(repository, "commits", sha.required())).body) val dto = parseObject<CommitDto>(get(repoUrl(repository, "git", "commits", sha.required())).body)
return mapCommitDetail(dto) return mapCommitDetail(dto)
} }
@ -51,28 +49,6 @@ class GiteaHistoryApi(
return bound(get(url).body) return bound(get(url).body)
} }
override suspend fun compare(
repository: RepositoryCoordinates,
baseRef: RepositoryRef,
headRef: RepositoryRef,
page: Int,
pageSize: Int,
): Page<CommitDetail> {
validatePage(page, pageSize)
val dto = parseObject<CompareDto>(get(compareUrl(repository, baseRef, headRef)).body)
val commits = dto.commits.orEmpty().map(::mapCommitDetail)
val fromIndex = ((page - 1L) * pageSize).coerceAtMost(commits.size.toLong()).toInt()
val toIndex = (fromIndex + pageSize).coerceAtMost(commits.size)
val next = if (toIndex < commits.size) page.safeNextPage() else null
return Page.of(commits.subList(fromIndex, toIndex), next)
}
override suspend fun getComparePatch(
repository: RepositoryCoordinates,
baseRef: RepositoryRef,
headRef: RepositoryRef,
): BoundedPatch = bound(get(compareUrl(repository, baseRef, headRef, suffix = ".diff")).body)
private fun bound(text: String): BoundedPatch { private fun bound(text: String): BoundedPatch {
val byteTruncated = text.toByteArray(Charsets.UTF_8).size > maxPatchBytes val byteTruncated = text.toByteArray(Charsets.UTF_8).size > maxPatchBytes
val bytesBounded = if (byteTruncated) { val bytesBounded = if (byteTruncated) {
@ -144,28 +120,9 @@ class GiteaHistoryApi(
private fun repoUrl(repository: RepositoryCoordinates, vararg tail: String): HttpUrl = private fun repoUrl(repository: RepositoryCoordinates, vararg tail: String): HttpUrl =
apiUrl("api", "v1", "repos", repository.owner, repository.name, *tail) apiUrl("api", "v1", "repos", repository.owner, repository.name, *tail)
// Gitea's compare endpoint takes one combined "base...head" path segment. Building that string
// first and passing it through a single addPathSegment call encodes each ref's characters
// (including any "/" inside a branch name) exactly once; the literal ".." separator is left
// untouched because "." is an unreserved URL character.
private fun compareUrl(
repository: RepositoryCoordinates,
baseRef: RepositoryRef,
headRef: RepositoryRef,
suffix: String = "",
): HttpUrl = repoUrl(repository, "compare", "${baseRef.value}...${headRef.value}$suffix")
private fun String?.required(): String = takeIf { !it.isNullOrBlank() } private fun String?.required(): String = takeIf { !it.isNullOrBlank() }
?: throw ApiFailure.InvalidResponse() ?: throw ApiFailure.InvalidResponse()
private fun validatePage(page: Int, pageSize: Int) {
require(page > 0) { "Page must be positive" }
require(pageSize in 1..100) { "Page size must be between 1 and 100" }
}
private fun Int.safeNextPage(): Int =
if (this == Int.MAX_VALUE) throw ApiFailure.InvalidResponse() else this + 1
private data class CommitAuthorDto(val name: String?, val date: String?) private data class CommitAuthorDto(val name: String?, val date: String?)
private data class CommitDataDto(val message: String?, val author: CommitAuthorDto?) private data class CommitDataDto(val message: String?, val author: CommitAuthorDto?)
private data class CommitFileDto( private data class CommitFileDto(
@ -178,8 +135,4 @@ class GiteaHistoryApi(
val commit: CommitDataDto?, val commit: CommitDataDto?,
val files: List<CommitFileDto>?, val files: List<CommitFileDto>?,
) )
private data class CompareDto(
@SerializedName("total_commits") val totalCommits: Int?,
val commits: List<CommitDto>?,
)
} }

View File

@ -127,6 +127,7 @@ class GiteaRepositoryBrowserApi(
TagSummary( TagSummary(
name = RepositoryRef.of(dto.name.required()), name = RepositoryRef.of(dto.name.required()),
commitId = (dto.commit?.sha ?: dto.id).required(), commitId = (dto.commit?.sha ?: dto.id).required(),
commitAtEpochMillis = dto.commit?.created.toEpochMillisOrNull(),
) )
} }
return response.toPage(items, page, pageSize) return response.toPage(items, page, pageSize)
@ -359,7 +360,7 @@ class GiteaRepositoryBrowserApi(
) )
private data class BranchCommitDto(val id: String?, val timestamp: String?) private data class BranchCommitDto(val id: String?, val timestamp: String?)
private data class TagDto(val name: String?, val id: String?, val commit: TagCommitDto?) private data class TagDto(val name: String?, val id: String?, val commit: TagCommitDto?)
private data class TagCommitDto(val sha: String?) private data class TagCommitDto(val sha: String?, val created: String?)
private data class ContentsDto( private data class ContentsDto(
val name: String?, val name: String?,
val path: String?, val path: String?,

View File

@ -1,32 +1,15 @@
package com.ubisam.ubgitea.domain.history package com.ubisam.ubgitea.domain.history
import com.ubisam.ubgitea.domain.model.Page
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryRef
/** /**
* Read-only commit detail, bounded diff/patch text, and ref comparison. Commit *history listing* * Read-only commit detail and bounded diff/patch text. Commit *history listing* itself is already
* itself is already provided by [com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser.listCommits] * provided by [com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser.listCommits] (TASK-004);
* (TASK-004); this interface adds only what that contract does not: per-commit changed files, * this interface adds only what that contract does not: per-commit changed files and bounded patch
* bounded patch text, and comparing two refs. No write method exists here or may be added without * text. No write method exists here or may be added without a separate acceptance-criteria task.
* a separate acceptance-criteria task.
*/ */
interface HistoryRepository { interface HistoryRepository {
suspend fun getCommitDetail(repository: RepositoryCoordinates, sha: String): CommitDetail suspend fun getCommitDetail(repository: RepositoryCoordinates, sha: String): CommitDetail
suspend fun getCommitPatch(repository: RepositoryCoordinates, sha: String): BoundedPatch suspend fun getCommitPatch(repository: RepositoryCoordinates, sha: String): BoundedPatch
suspend fun compare(
repository: RepositoryCoordinates,
baseRef: RepositoryRef,
headRef: RepositoryRef,
page: Int,
pageSize: Int,
): Page<CommitDetail>
suspend fun getComparePatch(
repository: RepositoryCoordinates,
baseRef: RepositoryRef,
headRef: RepositoryRef,
): BoundedPatch
} }

View File

@ -0,0 +1,104 @@
package com.ubisam.ubgitea.domain.history
sealed interface DiffLine {
val text: String
data class Context(override val text: String) : DiffLine
data class Removed(override val text: String) : DiffLine
data class Added(override val text: String) : DiffLine
}
/**
* One row of a side-by-side diff. `left`/`right` are independently nullable: both null marks a
* gap between two hunks (lines the patch didn't include), one null marks a pure addition or
* removal, and both non-null (always the same [DiffLine.Context] instance) marks unchanged
* context shown on both sides.
*/
data class DiffRow(val left: DiffLine?, val right: DiffLine?) {
companion object {
val Gap = DiffRow(null, null)
}
}
/**
* Splits one file's slice out of a combined multi-file `.diff`/patch body (as returned by
* Gitea's raw diff endpoints) and lays it out as side-by-side rows. Reused across commit and
* comparison patches — both are the same unified-diff text format, just with a different set of
* files inside.
*/
object UnifiedDiffParser {
fun parseFile(combinedPatch: String, path: String): List<DiffRow> {
val section = splitIntoFileSections(combinedPatch).firstOrNull { sectionPath(it) == path }
?: return emptyList()
val hunks = parseHunks(section)
val rows = mutableListOf<DiffRow>()
hunks.forEachIndexed { index, hunk ->
if (index > 0) rows += DiffRow.Gap
rows += hunkToRows(hunk)
}
return rows
}
private fun splitIntoFileSections(patch: String): List<List<String>> {
val sections = mutableListOf<MutableList<String>>()
for (line in patch.lineSequence()) {
if (line.startsWith("diff --git ")) {
sections += mutableListOf(line)
} else if (sections.isNotEmpty()) {
sections.last() += line
}
}
return sections
}
private fun sectionPath(section: List<String>): String? {
val added = section.firstOrNull { it.startsWith("+++ ") }?.let { stripDiffPathPrefix(it, "+++ ") }
if (added != null) return added
return section.firstOrNull { it.startsWith("--- ") }?.let { stripDiffPathPrefix(it, "--- ") }
}
private fun stripDiffPathPrefix(line: String, marker: String): String? {
val value = line.removePrefix(marker).trim()
if (value == "/dev/null") return null
return value.removePrefix("b/").removePrefix("a/")
}
private fun parseHunks(section: List<String>): List<List<DiffLine>> {
val hunks = mutableListOf<MutableList<DiffLine>>()
for (line in section) {
when {
line.startsWith("@@") -> hunks.add(mutableListOf<DiffLine>())
hunks.isEmpty() -> Unit
line.startsWith("+++") || line.startsWith("---") -> Unit
line.startsWith("+") -> hunks.last() += DiffLine.Added(line.substring(1))
line.startsWith("-") -> hunks.last() += DiffLine.Removed(line.substring(1))
line.startsWith(" ") -> hunks.last() += DiffLine.Context(line.substring(1))
else -> Unit // e.g. "\ No newline at end of file"
}
}
return hunks
}
private fun hunkToRows(hunk: List<DiffLine>): List<DiffRow> {
val rows = mutableListOf<DiffRow>()
var removed = mutableListOf<DiffLine.Removed>()
var added = mutableListOf<DiffLine.Added>()
fun flush() {
val count = maxOf(removed.size, added.size)
for (i in 0 until count) rows += DiffRow(removed.getOrNull(i), added.getOrNull(i))
removed = mutableListOf()
added = mutableListOf()
}
for (line in hunk) {
when (line) {
is DiffLine.Removed -> removed += line
is DiffLine.Added -> added += line
is DiffLine.Context -> {
flush()
rows += DiffRow(line, line)
}
}
}
flush()
return rows
}
}

View File

@ -21,6 +21,7 @@ data class BranchSummary(
data class TagSummary( data class TagSummary(
val name: RepositoryRef, val name: RepositoryRef,
val commitId: String, val commitId: String,
val commitAtEpochMillis: Long?,
) )
enum class TreeEntryType { enum class TreeEntryType {

View File

@ -3,6 +3,7 @@ package com.ubisam.ubgitea.feature.history
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -10,9 +11,11 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
@ -23,23 +26,30 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.ubisam.ubgitea.R import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.domain.history.CommitDetail import com.ubisam.ubgitea.domain.history.CommitDetail
import com.ubisam.ubgitea.domain.history.DiffLine
import com.ubisam.ubgitea.domain.history.DiffRow
import com.ubisam.ubgitea.domain.history.FileChange import com.ubisam.ubgitea.domain.history.FileChange
import com.ubisam.ubgitea.domain.history.FileChangeStatus import com.ubisam.ubgitea.domain.history.FileChangeStatus
import com.ubisam.ubgitea.domain.model.CommitSummary import com.ubisam.ubgitea.domain.model.CommitSummary
import com.ubisam.ubgitea.domain.model.RepositoryRef import com.ubisam.ubgitea.domain.model.RepositoryRef
import com.ubisam.ubgitea.ui.formatInstant
@Composable @Composable
fun HistoryScreen( fun HistoryScreen(
@ -50,13 +60,10 @@ fun HistoryScreen(
onRetryList: () -> Unit, onRetryList: () -> Unit,
onRetryDetail: () -> Unit, onRetryDetail: () -> Unit,
onRetryPatch: () -> Unit, onRetryPatch: () -> Unit,
onFileSelected: (FileChange) -> Unit,
onClearFileSelection: () -> Unit,
onBackToList: () -> Unit, onBackToList: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onCompare: (RepositoryRef) -> Unit,
onCompareNextPage: () -> Unit,
onComparePreviousPage: () -> Unit,
onRetryCompare: () -> Unit,
onLoadComparePatch: () -> Unit,
onAuthenticationExpired: () -> Unit, onAuthenticationExpired: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -72,7 +79,6 @@ fun HistoryScreen(
is HistoryUiState.CommitList -> { is HistoryUiState.CommitList -> {
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
Header(state.target, onBack) Header(state.target, onBack)
CompareInput(onCompare)
if (state.commits.isEmpty()) { if (state.commits.isEmpty()) {
CenterMessage { Text(stringResource(R.string.history_empty)) } CenterMessage { Text(stringResource(R.string.history_empty)) }
} else { } else {
@ -90,9 +96,7 @@ fun HistoryScreen(
CenterMessage { CircularProgressIndicator() } CenterMessage { CircularProgressIndicator() }
} }
is HistoryUiState.CommitDetailContent -> { is HistoryUiState.CommitDetailContent -> {
BackHandler(onBack = onBackToList) CommitDetailScreen(state, onRetryPatch, onFileSelected, onClearFileSelection, onBackToList)
Header(state.target, onBackToList)
CommitDetailView(state, onRetryPatch)
} }
is HistoryUiState.Error -> { is HistoryUiState.Error -> {
BackHandler(onBack = onBack) BackHandler(onBack = onBack)
@ -108,41 +112,6 @@ fun HistoryScreen(
} }
} }
} }
is HistoryUiState.CompareLoading -> {
BackHandler(onBack = onBackToList)
Header(state.target, onBackToList)
CenterMessage { CircularProgressIndicator() }
}
is HistoryUiState.CompareResult -> {
BackHandler(onBack = onBackToList)
Header(state.target, onBackToList)
Text(
stringResource(R.string.history_compare_title, state.baseRef.value, state.headRef.value),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
if (state.commits.isEmpty()) {
CenterMessage { Text(stringResource(R.string.history_compare_empty)) }
} else {
LazyColumn(Modifier.weight(1f)) {
items(state.commits, key = { it.summary.id }) { detail -> CompareCommitRow(detail) }
item { ComparePatchSection(state.patch, onLoadComparePatch) }
}
}
PageFooter(state.page, state.page > 1, state.hasNextPage, onComparePreviousPage, onCompareNextPage)
}
is HistoryUiState.CompareError -> {
BackHandler(onBack = onBackToList)
Header(state.target, onBackToList)
CenterMessage {
Text(stringResource(state.type.messageResource))
if (state.type == HistoryError.AUTHENTICATION_EXPIRED) {
Button(onClick = onAuthenticationExpired) { Text(stringResource(R.string.history_sign_in_again)) }
} else {
Button(onClick = onRetryCompare) { Text(stringResource(R.string.history_retry)) }
}
}
}
} }
} }
} }
@ -162,28 +131,6 @@ private fun Header(target: HistoryTarget, onBack: () -> Unit) {
HorizontalDivider() HorizontalDivider()
} }
@Composable
private fun CompareInput(onCompare: (RepositoryRef) -> Unit) {
var headRefText by rememberSaveable { mutableStateOf("") }
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = headRefText,
onValueChange = { headRefText = it },
label = { Text(stringResource(R.string.history_compare_head_label)) },
singleLine = true,
modifier = Modifier.weight(1f),
)
OutlinedButton(
onClick = { headRefText.trim().takeIf(String::isNotBlank)?.let { onCompare(RepositoryRef.of(it)) } },
modifier = Modifier.padding(start = 8.dp),
) { Text(stringResource(R.string.history_compare_action)) }
}
HorizontalDivider()
}
@Composable @Composable
private fun CommitRow(commit: CommitSummary, onSelected: () -> Unit) { private fun CommitRow(commit: CommitSummary, onSelected: () -> Unit) {
Column( Column(
@ -192,52 +139,198 @@ private fun CommitRow(commit: CommitSummary, onSelected: () -> Unit) {
) { ) {
Text(commit.shortId, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) Text(commit.shortId, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
Text(commit.message, style = MaterialTheme.typography.titleMedium, maxLines = 2) Text(commit.message, style = MaterialTheme.typography.titleMedium, maxLines = 2)
Text(commit.authorName, style = MaterialTheme.typography.bodySmall) Text(
stringResource(R.string.history_commit_by_at, commit.authorName, formatInstant(commit.authoredAtEpochMillis)),
style = MaterialTheme.typography.bodySmall,
)
} }
HorizontalDivider() HorizontalDivider()
} }
// Below the "medium" width breakpoint (Android's own guidance for list-detail layouts, and
// roughly a phone in any orientation), a selected file replaces the list as its own screen. At
// or above it — a tablet, or a foldable's inner screen unfolded — the file list and the selected
// file's diff show side by side at once, since there is room for both.
private val WideLayoutBreakpoint = 600.dp
@Composable @Composable
private fun CompareCommitRow(detail: CommitDetail) { private fun CommitDetailScreen(
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { state: HistoryUiState.CommitDetailContent,
Text(detail.summary.shortId, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) onRetryPatch: () -> Unit,
Text(detail.summary.message, style = MaterialTheme.typography.titleMedium, maxLines = 2) onFileSelected: (FileChange) -> Unit,
detail.files.forEach { FileChangeRow(it) } onClearFileSelection: () -> Unit,
onBackToList: () -> Unit,
) {
BoxWithConstraints(Modifier.fillMaxSize()) {
if (maxWidth >= WideLayoutBreakpoint) {
BackHandler(onBack = onBackToList)
Row(Modifier.fillMaxSize()) {
Column(Modifier.weight(1f)) {
Header(state.target, onBackToList)
CommitDetailView(state, onRetryPatch, onFileSelected)
}
VerticalDivider()
Column(Modifier.weight(1f)) {
val selected = state.selectedFile
if (selected != null) {
FileDiffView(selected)
} else {
CenterMessage { Text(stringResource(R.string.history_file_diff_select_prompt)) }
}
}
}
} else if (state.selectedFile != null) {
BackHandler(onBack = onClearFileSelection)
Header(state.target, onClearFileSelection)
FileDiffView(state.selectedFile)
} else {
BackHandler(onBack = onBackToList)
Header(state.target, onBackToList)
CommitDetailView(state, onRetryPatch, onFileSelected)
}
} }
HorizontalDivider()
} }
@Composable @Composable
private fun CommitDetailView(state: HistoryUiState.CommitDetailContent, onRetryPatch: () -> Unit) { private fun CommitDetailView(
state: HistoryUiState.CommitDetailContent,
onRetryPatch: () -> Unit,
onFileSelected: (FileChange) -> Unit,
) {
val canOpenFileDiff = state.patch is PatchUiState.Loaded
LazyColumn(Modifier.fillMaxSize()) { LazyColumn(Modifier.fillMaxSize()) {
item { item {
Column(Modifier.fillMaxWidth().padding(16.dp)) { Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text(state.commit.id, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) Text(state.commit.id, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall)
Text(state.commit.message, style = MaterialTheme.typography.titleMedium) Text(state.commit.message, style = MaterialTheme.typography.titleMedium)
Text(state.commit.authorName, style = MaterialTheme.typography.bodySmall) Text(
stringResource(R.string.history_commit_by_at, state.commit.authorName, formatInstant(state.commit.authoredAtEpochMillis)),
style = MaterialTheme.typography.bodySmall,
)
} }
} }
items(state.files, key = FileChange::path) { FileChangeRow(it, Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } items(state.files, key = FileChange::path) { file ->
FileChangeRow(
file,
Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
onSelected = if (canOpenFileDiff) ({ onFileSelected(file) }) else null,
)
}
item { HorizontalDivider(Modifier.padding(vertical = 12.dp)) } item { HorizontalDivider(Modifier.padding(vertical = 12.dp)) }
item { PatchSection(state.patch, onRetryPatch) } item { PatchSection(state.patch, onRetryPatch) }
} }
} }
@Composable @Composable
private fun FileChangeRow(file: FileChange, modifier: Modifier = Modifier) { private fun FileChangeRow(file: FileChange, modifier: Modifier = Modifier, onSelected: (() -> Unit)? = null) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Column(
if (onSelected != null) modifier.clickable(role = Role.Button, onClick = onSelected) else modifier,
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(file.status.labelResource), style = MaterialTheme.typography.bodySmall) Text(stringResource(file.status.labelResource), style = MaterialTheme.typography.bodySmall)
Text(file.path, style = MaterialTheme.typography.bodySmall, maxLines = 1) Text(file.path, style = MaterialTheme.typography.bodySmall, maxLines = 1)
} }
file.previousPath?.let { file.previousPath?.let {
Text( Text(stringResource(R.string.history_renamed_from, it), style = MaterialTheme.typography.bodySmall)
stringResource(R.string.history_renamed_from, it), }
style = MaterialTheme.typography.bodySmall,
modifier = modifier,
)
} }
} }
@Composable
private fun FileDiffView(state: HistoryUiState.SelectedFileDiff) {
Column(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text(state.file.path, style = MaterialTheme.typography.titleMedium)
if (state.isTruncated) Text(stringResource(R.string.history_patch_truncated), style = MaterialTheme.typography.bodySmall)
}
if (state.rows.isEmpty()) {
CenterMessage { Text(stringResource(R.string.history_file_diff_unavailable)) }
} else {
// Both lists always have exactly state.rows.size items (a missing side just renders
// a blank line for that index), so mirroring first-visible-index/offset between them
// keeps the two panels showing the same rows without needing per-line height math.
val beforeState = rememberLazyListState()
val afterState = rememberLazyListState()
// One shared horizontal scroll position for every line in both panels: dragging any
// line scrolls the whole diff sideways together, matching the synced vertical scroll,
// instead of each line only revealing its own clipped text independently.
val horizontalScrollState = rememberScrollState()
LaunchedEffect(beforeState, afterState) {
snapshotFlow { beforeState.firstVisibleItemIndex to beforeState.firstVisibleItemScrollOffset }
.collect { (index, offset) ->
if (afterState.firstVisibleItemIndex != index || afterState.firstVisibleItemScrollOffset != offset) {
afterState.scrollToItem(index, offset)
}
}
}
LaunchedEffect(beforeState, afterState) {
snapshotFlow { afterState.firstVisibleItemIndex to afterState.firstVisibleItemScrollOffset }
.collect { (index, offset) ->
if (beforeState.firstVisibleItemIndex != index || beforeState.firstVisibleItemScrollOffset != offset) {
beforeState.scrollToItem(index, offset)
}
}
}
Text(
stringResource(R.string.history_file_diff_before),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
LazyColumn(Modifier.weight(1f), state = beforeState) {
items(state.rows.size) { index ->
DiffSideLine(state.rows[index].left, state.rows[index].isGap, horizontalScrollState)
}
}
HorizontalDivider(thickness = 2.dp)
Text(
stringResource(R.string.history_file_diff_after),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
LazyColumn(Modifier.weight(1f), state = afterState) {
items(state.rows.size) { index ->
DiffSideLine(state.rows[index].right, state.rows[index].isGap, horizontalScrollState)
}
}
}
}
}
private val DiffRow.isGap: Boolean
get() = left == null && right == null
@Composable
private fun DiffSideLine(line: DiffLine?, isGap: Boolean, horizontalScrollState: ScrollState) {
if (isGap) {
Text(
"⋯",
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodySmall,
)
return
}
val color = when (line) {
is DiffLine.Removed -> MaterialTheme.colorScheme.error
is DiffLine.Added -> DiffAddedColor
else -> Color.Unspecified
}
val prefix = when (line) {
is DiffLine.Removed -> "- "
is DiffLine.Added -> "+ "
else -> " "
}
Text(
prefix + (line?.text ?: ""),
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
color = color,
modifier = Modifier.fillMaxWidth().horizontalScroll(horizontalScrollState).padding(horizontal = 16.dp, vertical = 2.dp),
)
}
// Material3 has no built-in "success" role to match colorScheme.error, so this is hardcoded.
private val DiffAddedColor = Color(0xFF2E7D32)
@Composable @Composable
private fun PatchSection(patch: PatchUiState, onRetry: () -> Unit) { private fun PatchSection(patch: PatchUiState, onRetry: () -> Unit) {
when (patch) { when (patch) {
@ -250,20 +343,6 @@ private fun PatchSection(patch: PatchUiState, onRetry: () -> Unit) {
} }
} }
@Composable
private fun ComparePatchSection(patch: PatchUiState, onLoad: () -> Unit) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
when (patch) {
PatchUiState.Loading -> CircularProgressIndicator()
PatchUiState.Failed -> {
Text(stringResource(R.string.history_patch_failed))
OutlinedButton(onClick = onLoad) { Text(stringResource(R.string.history_retry)) }
}
is PatchUiState.Loaded -> PatchText(patch.text, patch.isTruncated)
}
}
}
@Composable @Composable
private fun PatchText(text: String, isTruncated: Boolean) { private fun PatchText(text: String, isTruncated: Boolean) {
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {

View File

@ -12,7 +12,10 @@ import com.ubisam.ubgitea.data.history.GiteaHistoryApi
import com.ubisam.ubgitea.data.repositories.BearerTokenProvider import com.ubisam.ubgitea.data.repositories.BearerTokenProvider
import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi
import com.ubisam.ubgitea.domain.history.CommitDetail import com.ubisam.ubgitea.domain.history.CommitDetail
import com.ubisam.ubgitea.domain.history.DiffRow
import com.ubisam.ubgitea.domain.history.FileChange
import com.ubisam.ubgitea.domain.history.HistoryRepository import com.ubisam.ubgitea.domain.history.HistoryRepository
import com.ubisam.ubgitea.domain.history.UnifiedDiffParser
import com.ubisam.ubgitea.domain.model.CommitSummary import com.ubisam.ubgitea.domain.model.CommitSummary
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryPath import com.ubisam.ubgitea.domain.model.RepositoryPath
@ -56,10 +59,24 @@ sealed interface HistoryUiState {
class CommitDetailContent( class CommitDetailContent(
val target: HistoryTarget, val target: HistoryTarget,
val commit: CommitSummary, val commit: CommitSummary,
detail: CommitDetail, private val detail: CommitDetail,
val patch: PatchUiState, val patch: PatchUiState,
val selectedFile: SelectedFileDiff? = null,
) : HistoryUiState { ) : HistoryUiState {
val files = Collections.unmodifiableList(detail.files.toList()) val files = Collections.unmodifiableList(detail.files.toList())
// Selecting/clearing a file diff is a change *within* this screen, not a navigation to a
// new one (a wide/unfolded layout shows both the file list and the diff side by side at
// once), so it replaces this state in place rather than pushing a distinct UI state.
fun withSelectedFile(selectedFile: SelectedFileDiff?): CommitDetailContent =
CommitDetailContent(target, commit, detail, patch, selectedFile)
}
class SelectedFileDiff(
val file: FileChange,
rows: List<DiffRow>,
val isTruncated: Boolean,
) {
val rows = Collections.unmodifiableList(rows.toList())
} }
data class Error( data class Error(
val target: HistoryTarget, val target: HistoryTarget,
@ -67,25 +84,6 @@ sealed interface HistoryUiState {
val context: HistoryErrorContext, val context: HistoryErrorContext,
val failedCommit: CommitSummary? = null, val failedCommit: CommitSummary? = null,
) : HistoryUiState ) : HistoryUiState
data class CompareLoading(val target: HistoryTarget, val baseRef: RepositoryRef, val headRef: RepositoryRef) : HistoryUiState
class CompareResult(
val target: HistoryTarget,
val baseRef: RepositoryRef,
val headRef: RepositoryRef,
commits: List<CommitDetail>,
val page: Int,
val hasNextPage: Boolean,
val patch: PatchUiState,
) : HistoryUiState {
val commits: List<CommitDetail> = Collections.unmodifiableList(commits.toList())
}
data class CompareError(
val target: HistoryTarget,
val baseRef: RepositoryRef,
val headRef: RepositoryRef,
val type: HistoryError,
) : HistoryUiState
} }
enum class HistoryErrorContext { LIST, DETAIL } enum class HistoryErrorContext { LIST, DETAIL }
@ -147,6 +145,21 @@ class HistoryViewModel(
loadCommitPatch(current.target, current.commit) loadCommitPatch(current.target, current.commit)
} }
// Parsed locally from the commit's already-fetched combined patch — no extra network call.
// Replaces the current CommitDetailContent in place rather than navigating to a new state:
// a wide/unfolded layout shows the file list and the selected diff side by side at once.
fun openFileDiff(file: FileChange) {
val current = mutableState.value as? HistoryUiState.CommitDetailContent ?: return
val patch = current.patch as? PatchUiState.Loaded ?: return
val rows = UnifiedDiffParser.parseFile(patch.text, file.path)
mutableState.value = current.withSelectedFile(HistoryUiState.SelectedFileDiff(file, rows, patch.isTruncated))
}
fun clearFileSelection() {
val current = mutableState.value as? HistoryUiState.CommitDetailContent ?: return
mutableState.value = current.withSelectedFile(null)
}
fun backToList() { fun backToList() {
cancelPatchJob() cancelPatchJob()
operation?.cancel() operation?.cancel()
@ -154,48 +167,6 @@ class HistoryViewModel(
loadPage(target, RepositoryPath.Root, page = 1) loadPage(target, RepositoryPath.Root, page = 1)
} }
// The compare UI only collects a head ref; base is always the ref currently being viewed.
fun compareAgainstCurrentRef(headRef: RepositoryRef) {
val target = mutableState.value.currentTarget() ?: return
loadCompare(target, target.ref, headRef, page = 1)
}
fun compareNextPage() {
val current = mutableState.value as? HistoryUiState.CompareResult ?: return
if (!current.hasNextPage) return
loadCompare(current.target, current.baseRef, current.headRef, current.page + 1)
}
fun comparePreviousPage() {
val current = mutableState.value as? HistoryUiState.CompareResult ?: return
if (current.page <= 1) return
loadCompare(current.target, current.baseRef, current.headRef, current.page - 1)
}
fun retryCompare() {
val current = mutableState.value as? HistoryUiState.CompareError ?: return
loadCompare(current.target, current.baseRef, current.headRef, page = 1)
}
fun loadComparePatch() {
val current = mutableState.value as? HistoryUiState.CompareResult ?: return
cancelPatchJob()
replaceComparePatch(current, PatchUiState.Loading)
patchJob = viewModelScope.launch {
try {
val patch = history.getComparePatch(current.target.repository, current.baseRef, current.headRef)
(mutableState.value as? HistoryUiState.CompareResult)?.let {
replaceComparePatch(it, PatchUiState.Loaded(patch.text, patch.isTruncated))
}
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
(mutableState.value as? HistoryUiState.CompareResult)?.let {
replaceComparePatch(it, PatchUiState.Failed)
}
}
}
}
fun close() { fun close() {
operation?.cancel() operation?.cancel()
cancelPatchJob() cancelPatchJob()
@ -248,41 +219,6 @@ class HistoryViewModel(
} }
} }
private fun loadCompare(target: HistoryTarget, baseRef: RepositoryRef, headRef: RepositoryRef, page: Int) {
cancelPatchJob()
operation?.cancel()
mutableState.value = HistoryUiState.CompareLoading(target, baseRef, headRef)
operation = viewModelScope.launch {
try {
val result = history.compare(target.repository, baseRef, headRef, page, PAGE_SIZE)
mutableState.value = HistoryUiState.CompareResult(
target,
baseRef,
headRef,
result.items,
page,
result.nextPage != null,
PatchUiState.Loading,
)
} catch (failure: Throwable) {
if (failure is CancellationException) throw failure
mutableState.value = HistoryUiState.CompareError(target, baseRef, headRef, failure.toHistoryError())
}
}
}
private fun replaceComparePatch(current: HistoryUiState.CompareResult, patch: PatchUiState) {
mutableState.value = HistoryUiState.CompareResult(
current.target,
current.baseRef,
current.headRef,
current.commits,
current.page,
current.hasNextPage,
patch,
)
}
private fun cancelPatchJob() { private fun cancelPatchJob() {
patchJob?.cancel() patchJob?.cancel()
patchJob = null patchJob = null
@ -295,9 +231,6 @@ class HistoryViewModel(
is HistoryUiState.CommitDetailLoading -> target is HistoryUiState.CommitDetailLoading -> target
is HistoryUiState.CommitDetailContent -> target is HistoryUiState.CommitDetailContent -> target
is HistoryUiState.Error -> target is HistoryUiState.Error -> target
is HistoryUiState.CompareLoading -> target
is HistoryUiState.CompareResult -> target
is HistoryUiState.CompareError -> target
} }
class Factory(private val application: Application) : ViewModelProvider.Factory { class Factory(private val application: Application) : ViewModelProvider.Factory {

View File

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -26,6 +27,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
@ -33,6 +35,7 @@ import androidx.activity.compose.BackHandler
import com.ubisam.ubgitea.R import com.ubisam.ubgitea.R
import com.ubisam.ubgitea.domain.organizations.OrganizationSummary import com.ubisam.ubgitea.domain.organizations.OrganizationSummary
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.ui.formatInstant
@Composable @Composable
fun RepositoryListScreen( fun RepositoryListScreen(
@ -399,9 +402,11 @@ private fun ReferencesContent(
@Composable @Composable
private fun ReferenceRow(reference: ReferenceRowUiModel, onSelected: (String, ReferenceKind) -> Unit) { private fun ReferenceRow(reference: ReferenceRowUiModel, onSelected: (String, ReferenceKind) -> Unit) {
val contentColor = if (reference.isSelected) MaterialTheme.colorScheme.primary else Color.Unspecified
Column( Column(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(if (reference.isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent)
.selectable( .selectable(
selected = reference.isSelected, selected = reference.isSelected,
role = Role.RadioButton, role = Role.RadioButton,
@ -409,9 +414,15 @@ private fun ReferenceRow(reference: ReferenceRowUiModel, onSelected: (String, Re
) )
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
) { ) {
Text(reference.name, style = MaterialTheme.typography.titleMedium) Text(reference.name, style = MaterialTheme.typography.titleMedium, color = contentColor)
Text(reference.commitId.take(12), style = MaterialTheme.typography.bodySmall) Text(reference.commitId.take(12), style = MaterialTheme.typography.bodySmall, color = contentColor)
if (reference.isSelected) Text(stringResource(R.string.repositories_selected_reference)) reference.commitAtEpochMillis?.let {
Text(
stringResource(R.string.repositories_commit_at, formatInstant(it)),
style = MaterialTheme.typography.bodySmall,
color = contentColor,
)
}
if (reference.isProtected) Text(stringResource(R.string.repositories_protected_branch)) if (reference.isProtected) Text(stringResource(R.string.repositories_protected_branch))
} }
HorizontalDivider() HorizontalDivider()

View File

@ -125,6 +125,7 @@ data class ReferenceRowUiModel(
val kind: ReferenceKind, val kind: ReferenceKind,
val isProtected: Boolean, val isProtected: Boolean,
val isSelected: Boolean, val isSelected: Boolean,
val commitAtEpochMillis: Long?,
) )
enum class RepositoryListError { enum class RepositoryListError {

View File

@ -12,6 +12,7 @@ internal object RepositoryReferencePresenter {
kind = ReferenceKind.BRANCH, kind = ReferenceKind.BRANCH,
isProtected = branch.isProtected, isProtected = branch.isProtected,
isSelected = branch.name.value == defaultBranch, isSelected = branch.name.value == defaultBranch,
commitAtEpochMillis = branch.commitAtEpochMillis,
) )
} }
@ -23,6 +24,7 @@ internal object RepositoryReferencePresenter {
kind = ReferenceKind.TAG, kind = ReferenceKind.TAG,
isProtected = false, isProtected = false,
isSelected = false, isSelected = false,
commitAtEpochMillis = tag.commitAtEpochMillis,
) )
} }

View File

@ -0,0 +1,14 @@
package com.ubisam.ubgitea.ui
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
private val instantFormatter: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm (zzz)", Locale.KOREAN)
/** Formats an epoch-millis instant in the device's local time zone, with the zone shown explicitly. */
fun formatInstant(epochMillis: Long): String =
ZonedDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault()).format(instantFormatter)

View File

@ -7,13 +7,14 @@
<string name="history_previous_page">이전 페이지</string> <string name="history_previous_page">이전 페이지</string>
<string name="history_next_page">다음 페이지</string> <string name="history_next_page">다음 페이지</string>
<string name="history_page_number">%1$d페이지</string> <string name="history_page_number">%1$d페이지</string>
<string name="history_compare_head_label">비교할 브랜치/태그/커밋</string>
<string name="history_compare_action">비교</string>
<string name="history_compare_title">%1$s ↔ %2$s 비교</string>
<string name="history_compare_empty">두 기준 사이에 커밋 차이가 없습니다.</string>
<string name="history_renamed_from">이전 경로: %1$s</string> <string name="history_renamed_from">이전 경로: %1$s</string>
<string name="history_commit_by_at">%1$s · %2$s</string>
<string name="history_patch_failed">변경 내용을 불러오지 못했습니다.</string> <string name="history_patch_failed">변경 내용을 불러오지 못했습니다.</string>
<string name="history_patch_truncated">내용이 잘려서 표시됩니다.</string> <string name="history_patch_truncated">내용이 잘려서 표시됩니다.</string>
<string name="history_file_diff_before">기존</string>
<string name="history_file_diff_after">변경</string>
<string name="history_file_diff_unavailable">이 파일의 변경 내용을 표시할 수 없습니다.</string>
<string name="history_file_diff_select_prompt">변경된 파일을 선택하면 여기에 표시됩니다.</string>
<string name="history_status_added">추가됨</string> <string name="history_status_added">추가됨</string>
<string name="history_status_modified">수정됨</string> <string name="history_status_modified">수정됨</string>
<string name="history_status_deleted">삭제됨</string> <string name="history_status_deleted">삭제됨</string>

View File

@ -25,8 +25,8 @@
<string name="repositories_branches_empty">브랜치가 없습니다.</string> <string name="repositories_branches_empty">브랜치가 없습니다.</string>
<string name="repositories_tags_title">태그</string> <string name="repositories_tags_title">태그</string>
<string name="repositories_tags_empty">태그가 없습니다.</string> <string name="repositories_tags_empty">태그가 없습니다.</string>
<string name="repositories_selected_reference">선택됨</string>
<string name="repositories_protected_branch">보호된 브랜치</string> <string name="repositories_protected_branch">보호된 브랜치</string>
<string name="repositories_commit_at">커밋 시각: %1$s</string>
<string name="repositories_back">저장소 목록</string> <string name="repositories_back">저장소 목록</string>
<string name="repositories_refresh">새로고침</string> <string name="repositories_refresh">새로고침</string>
<string name="repositories_previous_page">이전 페이지</string> <string name="repositories_previous_page">이전 페이지</string>

View File

@ -7,7 +7,6 @@ import com.ubisam.ubgitea.core.network.HttpTransport
import com.ubisam.ubgitea.data.repositories.BearerTokenProvider import com.ubisam.ubgitea.data.repositories.BearerTokenProvider
import com.ubisam.ubgitea.domain.history.FileChangeStatus import com.ubisam.ubgitea.domain.history.FileChangeStatus
import com.ubisam.ubgitea.domain.model.RepositoryCoordinates import com.ubisam.ubgitea.domain.model.RepositoryCoordinates
import com.ubisam.ubgitea.domain.model.RepositoryRef
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@ -51,7 +50,7 @@ class GiteaHistoryApiTest {
assertEquals("old_d.kt", detail.files[3].previousPath) assertEquals("old_d.kt", detail.files[3].previousPath)
assertNull(detail.files[0].previousPath) assertNull(detail.files[0].previousPath)
val url = transport.urls.single().toHttpUrl() val url = transport.urls.single().toHttpUrl()
assertEquals(listOf("api", "v1", "repos", "team name", "mobile+#", "commits", "abcdef1234567890"), url.pathSegments) assertEquals(listOf("api", "v1", "repos", "team name", "mobile+#", "git", "commits", "abcdef1234567890"), url.pathSegments)
} }
@Test @Test
@ -102,53 +101,6 @@ class GiteaHistoryApiTest {
assertTrue(patch.isTruncated) assertTrue(patch.isTruncated)
} }
@Test
fun `compare encodes base and head refs into one basehead segment even when a ref contains a slash`() = runTest {
val transport = QueueTransport(response("""{"total_commits":0,"commits":[]}"""))
api(transport).compare(repository, RepositoryRef.of("main"), RepositoryRef.of("refs/heads/feature/a+b"), 1, 20)
val url = transport.urls.single().toHttpUrl()
assertEquals(
listOf("api", "v1", "repos", "team name", "mobile+#", "compare", "main...refs/heads/feature/a+b"),
url.pathSegments,
)
}
@Test
fun `compare paginates its single response locally`() = runTest {
fun commitJson(sha: String) =
"""{"sha":"$sha","commit":{"message":"m","author":{"name":"d","date":"2026-08-25T10:20:30Z"}},"files":[]}"""
val transport = QueueTransport(
response("""{"total_commits":3,"commits":[${commitJson("a")},${commitJson("b")},${commitJson("c")}]}"""),
)
val firstPage = api(transport).compare(repository, RepositoryRef.of("main"), RepositoryRef.of("feature"), 1, 2)
assertEquals(listOf("a", "b"), firstPage.items.map { it.summary.id })
assertEquals(2, firstPage.nextPage)
val secondPageTransport = QueueTransport(
response("""{"total_commits":3,"commits":[${commitJson("a")},${commitJson("b")},${commitJson("c")}]}"""),
)
val secondPage = api(secondPageTransport).compare(repository, RepositoryRef.of("main"), RepositoryRef.of("feature"), 2, 2)
assertEquals(listOf("c"), secondPage.items.map { it.summary.id })
assertNull(secondPage.nextPage)
}
@Test
fun `compare patch requests the diff-suffixed compare url`() = runTest {
val transport = QueueTransport(response("diff text"))
val patch = api(transport).getComparePatch(repository, RepositoryRef.of("main"), RepositoryRef.of("feature"))
assertEquals("diff text", patch.text)
val url = transport.urls.single().toHttpUrl()
assertEquals(
listOf("api", "v1", "repos", "team name", "mobile+#", "compare", "main...feature.diff"),
url.pathSegments,
)
}
@Test @Test
fun `missing token fails closed with authentication expired`() = runTest { fun `missing token fails closed with authentication expired`() = runTest {
val missingToken = GiteaHistoryApi( val missingToken = GiteaHistoryApi(

View File

@ -0,0 +1,135 @@
package com.ubisam.ubgitea.domain.history
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class UnifiedDiffParserTest {
@Test
fun `context lines appear on both sides and additions removals pair up in order`() {
val patch = """
diff --git a/foo.kt b/foo.kt
index 1111111..2222222 100644
--- a/foo.kt
+++ b/foo.kt
@@ -1,3 +1,3 @@
unchanged top
-old line
+new line
unchanged bottom
""".trimIndent()
val rows = UnifiedDiffParser.parseFile(patch, "foo.kt")
assertEquals(3, rows.size)
assertEquals(DiffLine.Context("unchanged top"), rows[0].left)
assertEquals(rows[0].left, rows[0].right)
assertEquals(DiffLine.Removed("old line"), rows[1].left)
assertEquals(DiffLine.Added("new line"), rows[1].right)
assertEquals(DiffLine.Context("unchanged bottom"), rows[2].left)
}
@Test
fun `an unequal run of removed and added lines pads the shorter side with null`() {
val patch = """
diff --git a/foo.kt b/foo.kt
--- a/foo.kt
+++ b/foo.kt
@@ -1,2 +1,3 @@
-one
-two
+one
+two
+three
""".trimIndent()
val rows = UnifiedDiffParser.parseFile(patch, "foo.kt")
assertEquals(3, rows.size)
assertEquals(DiffLine.Removed("one"), rows[0].left)
assertEquals(DiffLine.Added("one"), rows[0].right)
assertEquals(DiffLine.Removed("two"), rows[1].left)
assertEquals(DiffLine.Added("two"), rows[1].right)
assertNull(rows[2].left)
assertEquals(DiffLine.Added("three"), rows[2].right)
}
@Test
fun `a gap row separates two hunks in the same file`() {
val patch = """
diff --git a/foo.kt b/foo.kt
--- a/foo.kt
+++ b/foo.kt
@@ -1,1 +1,1 @@
-a
+b
@@ -50,1 +50,1 @@
-c
+d
""".trimIndent()
val rows = UnifiedDiffParser.parseFile(patch, "foo.kt")
assertEquals(3, rows.size)
assertEquals(DiffLine.Removed("a"), rows[0].left)
assertEquals(DiffRow.Gap, rows[1])
assertEquals(DiffLine.Removed("c"), rows[2].left)
}
@Test
fun `only the requested file's section is extracted from a multi-file patch`() {
val patch = """
diff --git a/one.kt b/one.kt
--- a/one.kt
+++ b/one.kt
@@ -1,1 +1,1 @@
-one old
+one new
diff --git a/two.kt b/two.kt
--- a/two.kt
+++ b/two.kt
@@ -1,1 +1,1 @@
-two old
+two new
""".trimIndent()
val rows = UnifiedDiffParser.parseFile(patch, "two.kt")
assertEquals(1, rows.size)
assertEquals(DiffLine.Removed("two old"), rows[0].left)
assertEquals(DiffLine.Added("two new"), rows[0].right)
}
@Test
fun `an unknown path returns an empty diff instead of throwing`() {
val patch = """
diff --git a/foo.kt b/foo.kt
--- a/foo.kt
+++ b/foo.kt
@@ -1,1 +1,1 @@
-a
+b
""".trimIndent()
assertTrue(UnifiedDiffParser.parseFile(patch, "missing.kt").isEmpty())
}
@Test
fun `a deleted file is matched by its old path since the new side is dev null`() {
val patch = """
diff --git a/gone.kt b/gone.kt
deleted file mode 100644
--- a/gone.kt
+++ /dev/null
@@ -1,1 +0,0 @@
-bye
""".trimIndent()
val rows = UnifiedDiffParser.parseFile(patch, "gone.kt")
assertEquals(1, rows.size)
assertEquals(DiffLine.Removed("bye"), rows[0].left)
assertNull(rows[0].right)
}
}

View File

@ -110,6 +110,43 @@ class HistoryViewModelTest {
assertEquals(PatchUiState.Loaded("diff text", false), state.patch) assertEquals(PatchUiState.Loaded("diff text", false), state.patch)
} }
@Test
fun `selecting a file parses its side-by-side diff from the already-loaded patch`() = runTest(dispatcher.scheduler) {
val browser = FakeBrowser(commits = listOf(Page.of(listOf(commit("a")), null)))
val patchText = """
diff --git a/new.kt b/new.kt
--- a/new.kt
+++ b/new.kt
@@ -1,1 +1,1 @@
-old
+new
""".trimIndent()
val history = FakeHistory(
detail = CommitDetail(commit("a"), listOf(FileChange("new.kt", null, FileChangeStatus.MODIFIED))),
patch = BoundedPatch(patchText, isTruncated = true),
)
val viewModel = HistoryViewModel(browser, history)
viewModel.open(target)
advanceUntilIdle()
viewModel.openCommit((viewModel.state.value as HistoryUiState.CommitList).commits.single())
advanceUntilIdle()
val detail = viewModel.state.value as HistoryUiState.CommitDetailContent
viewModel.openFileDiff(detail.files.single())
val withDiff = viewModel.state.value as HistoryUiState.CommitDetailContent
val diff = withDiff.selectedFile
assertEquals("new.kt", diff?.file?.path)
assertEquals(1, diff?.rows?.size)
assertEquals("old", diff?.rows?.single()?.left?.text)
assertEquals("new", diff?.rows?.single()?.right?.text)
assertTrue(diff?.isTruncated == true)
viewModel.clearFileSelection()
val cleared = viewModel.state.value as HistoryUiState.CommitDetailContent
assertTrue(cleared.selectedFile == null)
}
@Test @Test
fun `a truncated patch is surfaced as truncated in state`() = runTest(dispatcher.scheduler) { fun `a truncated patch is surfaced as truncated in state`() = runTest(dispatcher.scheduler) {
val browser = FakeBrowser(commits = listOf(Page.of(listOf(commit("a")), null))) val browser = FakeBrowser(commits = listOf(Page.of(listOf(commit("a")), null)))
@ -187,47 +224,6 @@ class HistoryViewModelTest {
assertEquals(HistoryError.AUTHENTICATION_EXPIRED, error.type) assertEquals(HistoryError.AUTHENTICATION_EXPIRED, error.type)
} }
@Test
fun `compare against the current ref uses the open target's ref as base`() = runTest(dispatcher.scheduler) {
val browser = FakeBrowser(commits = listOf(Page.of(listOf(commit("a")), null)))
val history = FakeHistory()
val viewModel = HistoryViewModel(browser, history)
viewModel.open(target)
advanceUntilIdle()
viewModel.compareAgainstCurrentRef(RepositoryRef.of("feature"))
advanceUntilIdle()
assertEquals(RepositoryRef.of("main") to RepositoryRef.of("feature"), history.lastCompareRequest)
assertTrue(viewModel.state.value is HistoryUiState.CompareResult)
}
@Test
fun `compare pagination moves forward and back`() = runTest(dispatcher.scheduler) {
val browser = FakeBrowser(commits = listOf(Page.of(listOf(commit("a")), null)))
val history = FakeHistory(
compareResults = listOf(
Page.of(listOf(CommitDetail(commit("x"), emptyList())), nextPage = 2),
Page.of(listOf(CommitDetail(commit("y"), emptyList())), nextPage = null),
),
)
val viewModel = HistoryViewModel(browser, history)
viewModel.open(target)
advanceUntilIdle()
viewModel.compareAgainstCurrentRef(RepositoryRef.of("feature"))
advanceUntilIdle()
viewModel.compareNextPage()
advanceUntilIdle()
var state = viewModel.state.value as HistoryUiState.CompareResult
assertEquals(listOf("y"), state.commits.map { it.summary.id })
viewModel.comparePreviousPage()
advanceUntilIdle()
state = viewModel.state.value as HistoryUiState.CompareResult
assertEquals(listOf("x"), state.commits.map { it.summary.id })
}
private fun commit(id: String) = CommitSummary(id, id.take(7), "message $id", "author", 1_000L) private fun commit(id: String) = CommitSummary(id, id.take(7), "message $id", "author", 1_000L)
private class FakeBrowser( private class FakeBrowser(
@ -258,10 +254,7 @@ class HistoryViewModelTest {
var detailFailure: Throwable? = null, var detailFailure: Throwable? = null,
var patch: BoundedPatch = BoundedPatch("", false), var patch: BoundedPatch = BoundedPatch("", false),
var patchFailure: Throwable? = null, var patchFailure: Throwable? = null,
private val compareResults: List<Page<CommitDetail>> = listOf(Page.of(emptyList(), null)),
) : HistoryRepository { ) : HistoryRepository {
var lastCompareRequest: Pair<RepositoryRef, RepositoryRef>? = null
override suspend fun getCommitDetail(repository: RepositoryCoordinates, sha: String): CommitDetail { override suspend fun getCommitDetail(repository: RepositoryCoordinates, sha: String): CommitDetail {
detailFailure?.let { throw it } detailFailure?.let { throw it }
return detail ?: CommitDetail(CommitSummary(sha, sha.take(7), "m", "a", 1_000L), emptyList()) return detail ?: CommitDetail(CommitSummary(sha, sha.take(7), "m", "a", 1_000L), emptyList())
@ -271,18 +264,5 @@ class HistoryViewModelTest {
patchFailure?.let { throw it } patchFailure?.let { throw it }
return patch return patch
} }
override suspend fun compare(
repository: RepositoryCoordinates,
baseRef: RepositoryRef,
headRef: RepositoryRef,
page: Int,
pageSize: Int,
): Page<CommitDetail> {
lastCompareRequest = baseRef to headRef
return compareResults.getOrElse(page - 1) { Page.of(emptyList(), null) }
}
override suspend fun getComparePatch(repository: RepositoryCoordinates, baseRef: RepositoryRef, headRef: RepositoryRef): BoundedPatch = patch
} }
} }

View File

@ -551,7 +551,7 @@ class RepositoryListViewModelTest {
} }
override suspend fun listTags(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<TagSummary> = override suspend fun listTags(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<TagSummary> =
Page.of(listOf(TagSummary(RepositoryRef.of("v1"), "c")), null) Page.of(listOf(TagSummary(RepositoryRef.of("v1"), "c", null)), null)
override suspend fun listTree(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int, pageSize: Int): Page<TreeEntry> = override suspend fun listTree(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int, pageSize: Int): Page<TreeEntry> =
Page.of(emptyList(), null) Page.of(emptyList(), null)
override suspend fun getFile(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath): SourceFile = override suspend fun getFile(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath): SourceFile =

View File

@ -45,7 +45,7 @@ class RepositoryReferencePresenterTest {
"main", "main",
) )
val tags = RepositoryReferencePresenter.tags( val tags = RepositoryReferencePresenter.tags(
listOf(TagSummary(RepositoryRef.of("v1"), "b"), TagSummary(RepositoryRef.of("v2"), "c")), listOf(TagSummary(RepositoryRef.of("v1"), "b", null), TagSummary(RepositoryRef.of("v2"), "c", null)),
) )
val selected = RepositoryReferencePresenter.select(branches, tags, "v2", ReferenceKind.TAG) val selected = RepositoryReferencePresenter.select(branches, tags, "v2", ReferenceKind.TAG)
@ -56,7 +56,7 @@ class RepositoryReferencePresenterTest {
@Test @Test
fun `reference content takes immutable snapshots`() { fun `reference content takes immutable snapshots`() {
val branches = mutableListOf(ReferenceRowUiModel("main", "a", ReferenceKind.BRANCH, false, true)) val branches = mutableListOf(ReferenceRowUiModel("main", "a", ReferenceKind.BRANCH, false, true, 1_000L))
val state = RepositoryListUiState.ReferencesContent( val state = RepositoryListUiState.ReferencesContent(
"account", "account",
RepositoryRowUiModel(RepositoryCoordinates.of("owner", "repo"), null, true, "main", 1_000L), RepositoryRowUiModel(RepositoryCoordinates.of("owner", "repo"), null, true, "main", 1_000L),

View File

@ -346,3 +346,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- Known limitations: `CSOT-T8-MACRO_SA` (the other repository that showed the same symptom) was not individually re-confirmed by the human product owner, though it goes through the identical code path. - Known limitations: `CSOT-T8-MACRO_SA` (the other repository that showed the same symptom) was not individually re-confirmed by the human product owner, though it goes through the identical code path.
- Next recommended task: None outstanding on the proposed roadmap. TASK-016's two previously-blocked items (full authenticated-screen device regression, R8/ProGuard minification decision) are now more tractable, since a real device session with working wireless `adb` and the human product owner's active participation is established for the first time this session. - Next recommended task: None outstanding on the proposed roadmap. TASK-016's two previously-blocked items (full authenticated-screen device regression, R8/ProGuard minification decision) are now more tractable, since a real device session with working wireless `adb` and the human product owner's active participation is established for the first time this session.
- Next owner: Claude, continuing per the standing direction, or the human product owner if they'd rather drive further device testing directly. - Next owner: Claude, continuing per the standing direction, or the human product owner if they'd rather drive further device testing directly.
## 2026-08-27 — TASK-024: history screen redesign (branch tap, colored selection, commit time, file diff, Z-Fold split view, compare removal)
- Agent and role: Claude, implementing a connected series of live, iterative requests from the human product owner while they tested the app in real time on their own Galaxy Z Fold over the wireless `adb` connection paired in the previous session.
- Commit/diff: continued on `claude/task-015-notifications`; see `tasks/024-branch-tap-opens-history.md` for the full file list — spans `feature/repositories/`, `feature/history/`, `data/history/`, `domain/history/` (new `UnifiedDiffParser.kt`), `domain/model/RepositoryModels.kt`, `ui/DateTimeFormatting.kt` (new), and `MainActivity.kt`.
- Features implemented, in the order requested live: (1) tapping a branch/tag opens commit history instead of the file browser; (2) the selected reference row is color-coded instead of a "선택됨" text label; (3) branch/tag rows and commit rows show commit date/time with an explicit time zone; (4) tapping a changed file shows a parsed before/after diff (no extra network call — parsed client-side from the already-fetched combined patch); (5) the two diff panels scroll vertically in sync and share one horizontal scroll position; (6) diff lines are colored by text only (red/green), not background; (7) on a ≥600dp-wide screen (covers a Z Fold's inner screen unfolded) the file list and selected diff show side by side instead of the diff replacing the list; (8) the entire branch/tag compare feature was removed at the human product owner's request, since it wasn't going to be used.
- How it was implemented: Folded file-diff selection into `CommitDetailContent` as an in-place field (`selectedFile: SelectedFileDiff?`) rather than a separate navigational state, specifically so the wide-layout split view could show the list and the diff from one piece of state without duplicating any fetch logic. Along the way, found and fixed two real bugs via live device debugging: `getCommitDetail` was requesting `/repos/{o}/{r}/commits/{sha}` instead of `/repos/{o}/{r}/git/commits/{sha}` (the same path family the already-working `.diff` endpoint uses), which had been causing every commit tap to 404 with "커밋이나 기준을 찾을 수 없습니다" — found by checking the Gitea container's own request log first (ruling out a real server error) then bisecting with temporary debug-only logging. Separately, a horizontal-scroll bug reported live ("글자 한줄만 스크롤 되게 하지 말고") was fixed by replacing each diff line's independent `rememberScrollState()` with one shared `ScrollState` per panel.
- Files changed: see `tasks/024-branch-tap-opens-history.md`'s Scope section for the full list (13 modified files, 4 new files, 1 new test directory).
- Checks and results: `assembleDebug testDebugUnitTest lintDebug --rerun-tasks` BUILD SUCCESSFUL at every checkpoint through this work (verified repeatedly, not just once at the end), most recently after the compare removal — all unit tests passing, 0 lint errors. `git diff --check` and secret-pattern scan clean.
- Review findings and resolutions: None from a separate review pass — self-review happened continuously via live device testing throughout, which is how items 5 and 8's underlying bugs (commit-detail 404, single-line horizontal scroll) were actually caught, rather than found afterward.
- Known limitations: File browsing (`Route.CodeBrowser`) is now unreachable from the UI — the reference screen was its only entry point, and item 1 redirected that tap to history instead. This is disclosed in the task file as a known, explicit side effect, not an oversight; a follow-up task (requested live, not yet started) will reintroduce it as a "Files" tab alongside "History" on the branch/tag detail screen. One live-reported rendering glitch (two screens appearing to overlap momentarily) has not been independently reproduced or root-caused. A final, single closing device-confirmation pass covering every item above together has not yet been explicitly requested and received.
- Next recommended task: Several more live requests are already queued from the same testing session: a tabbed Files/History branch-detail screen (bringing `Route.CodeBrowser` back into reach), an image-file viewer in the code browser, numbered pagination with direct page-entry for the Issues/PR list (repository/history/notification lists keep prev/next, since Gitea doesn't provide a total count for those endpoints), swipeable 전체/즐겨찾기 tabs on the repository list, and showing each repository row's last-commit time.
- Next owner: Claude, continuing through the queued live requests above.

View File

@ -0,0 +1,67 @@
# TASK-024: History screen redesign (branch tap, colored selection, commit time, file diff, Z-Fold split view, compare removal)
Status: implemented; all required checks pass; device verification in progress (live, iterative)
Owner: Claude
Reviewer: Codex
## Outcome
A single connected round of changes to the branch/tag reference screen and the history screen, driven by the human product owner testing the app live on their own device (a Galaxy Z Fold, `SM_F966N`) over wireless `adb` and requesting adjustments in real time:
1. Tapping a branch or tag now opens that ref's commit history directly, not the file browser.
2. The selected branch/tag row is distinguished by color (background + text tint), not a "선택됨" text label.
3. Branch/tag rows and commit rows show the commit's date/time (with time zone) via a new shared `formatInstant` helper.
4. Tapping a changed file in a commit's file list shows a side-by-side (기존/변경, stacked vertically) diff for just that file, parsed client-side from the already-fetched combined patch — no new network call.
5. The diff panels' vertical scroll is synchronized between the two stacked panels, and all lines (both panels) share one horizontal scroll position so long lines scroll together rather than one line at a time.
6. Removed/added lines are colored (red/green text), not background-highlighted.
7. On a wide screen (≥600dp — Android's own "medium" breakpoint for list-detail layouts, which covers a Z Fold's inner screen unfolded), the commit detail's file list and the selected file's diff show side by side in one screen instead of the diff replacing the list.
8. Fixed a real bug found via live server-log inspection: `getCommitDetail` requested `/repos/{owner}/{repo}/commits/{sha}` (missing the `git/` prefix Gitea's raw-diff endpoints use), causing every commit tap to 404 with "커밋이나 기준을 찾을 수 없습니다."
9. Fixed a second real bug found via a temporary debug-logging pass on-device: Gitea returns the literal JSON `null` (not `[]`) for some empty list responses; `parseList` treated that as a parse failure. (This one shipped earlier as TASK-023; noted here because the same live-debugging session led to it.)
10. Removed the branch/tag comparison feature entirely (UI, ViewModel state, and the underlying `HistoryRepository.compare`/`getComparePatch` methods) at the human product owner's explicit request ("이 기능은 안 쓸 것 같으니까 그냥 지워줘도 될 것 같아").
## Scope
- Included: `feature/repositories/RepositoryListScreen.kt` (colored selection, commit time), `feature/repositories/RepositoryListViewModel.kt` / `RepositoryReferencePresenter.kt` (`ReferenceRowUiModel.commitAtEpochMillis`), `domain/model/RepositoryModels.kt` (`TagSummary.commitAtEpochMillis`), `data/repositories/GiteaRepositoryBrowserApi.kt` (tag `created` timestamp mapping), `feature/history/HistoryViewModel.kt` / `HistoryScreen.kt` (file diff selection folded into `CommitDetailContent`, compare removed), `data/history/GiteaHistoryApi.kt` (commit-detail URL fix, compare removed), `domain/history/HistoryRepository.kt` (compare removed), `domain/history/UnifiedDiffParser.kt` (new), `ui/DateTimeFormatting.kt` (new, shared), `MainActivity.kt` (routing + wiring).
- Excluded: File browsing (`Route.CodeBrowser`) remains unreachable from the UI — a known, disclosed side effect of item 1, not addressed here (see "Known side effect" below; a follow-up task will restore it as a tab).
## Known side effect (disclosed, not silently absorbed)
The repository reference screen was the *only* entry point that reached `Route.CodeBrowser` (file/source browsing). After item 1, file browsing is no longer reachable from the app's UI at all, even though the feature (TASK-006) still exists and works. This was an explicit, direct instruction from the human product owner. A follow-up task (requested live, not yet started as of this commit) will reintroduce it as a default "Files" tab alongside a "History" tab, swipeable, on the branch/tag detail screen.
## Acceptance criteria
- [x] Tapping a branch or tag row pushes `Route.History(owner, name, ref)` instead of `Route.CodeBrowser(...)`.
- [x] The selected reference row is distinguished by color; no "선택됨"/text-only indicator remains.
- [x] Branch/tag rows and commit rows display a formatted local date/time with explicit time zone.
- [x] Tapping a changed file shows a parsed, stacked before/after diff without an extra network call.
- [x] The two diff panels' vertical scroll positions stay in sync; all diff lines share one horizontal scroll position.
- [x] Removed/added diff lines are colored by text color only.
- [x] At ≥600dp width, the commit file list and selected file diff render side by side in one screen; below it, selecting a file replaces the list as its own back-stack level (`BackHandler` clears the selection rather than leaving the screen).
- [x] `getCommitDetail` requests `/repos/{owner}/{repo}/git/commits/{sha}` (matches the already-working `.diff` endpoint's path family).
- [x] The compare feature (UI, ViewModel states/methods, `HistoryRepository`/`GiteaHistoryApi` methods, associated strings and tests) is fully removed, not just hidden.
- [x] Build, unit tests, and lint pass after every step (verified repeatedly through this session, not just at the end).
- [ ] Full, final device confirmation of every item above in one pass — verification has been continuous and iterative live on-device throughout this work, with several intermediate bugs already caught and fixed (see Evidence), but a single closing confirmation pass has not yet been explicitly requested and received from the human product owner as of this commit.
## Required checks
```text
./gradlew assembleDebug
./gradlew testDebugUnitTest
./gradlew lintDebug
```
## Evidence
- `domain/history/UnifiedDiffParser.kt` (new): splits one file's section out of a combined unified-diff patch (by `diff --git` boundaries, matched via `+++`/`---` path lines) and lays it out as `DiffRow(left, right)` pairs — consecutive removed/added runs pair up by index, a lone `DiffRow.Gap` marks the boundary between two hunks. 6 unit tests in `UnifiedDiffParserTest.kt` (context pairing, unequal removed/added run padding, hunk gaps, multi-file section extraction, unknown-path returns empty rather than throwing, a deleted file matched by its old path since the new side is `/dev/null`).
- `ui/DateTimeFormatting.kt` (new): single shared `formatInstant(epochMillis)` using `ZoneId.systemDefault()` and an explicit zone abbreviation in the formatted string, reused by both the repository reference screen and the history screen (previously `RepositoryListScreen.kt` had a private, near-identical copy — consolidated rather than duplicated further).
- `feature/history/HistoryViewModel.kt`: `HistoryUiState.CommitDetailContent` gained `selectedFile: SelectedFileDiff?` and a `withSelectedFile(...)` reconstruction method; the standalone `FileDiffContent` state was removed entirely — selecting/clearing a file is now an in-place update of the current screen's state, not a navigation to a new one, which is what makes the wide-layout side-by-side rendering possible without duplicating fetch logic.
- `feature/history/HistoryScreen.kt`: new `CommitDetailScreen` composable wraps the existing `CommitDetailView` (file list) and the new `FileDiffView` in a `BoxWithConstraints`, branching on `maxWidth >= 600.dp` (`WideLayoutBreakpoint`) to either show both side by side (`Row` + `VerticalDivider`) or show one full-screen with its own back handler, matching Android's own list-detail adaptive-layout breakpoint guidance.
- Synced scroll: two `LazyListState`s, cross-observed via `snapshotFlow { firstVisibleItemIndex to firstVisibleItemScrollOffset }.collect { ... scrollToItem(...) }` with an equality guard to prevent feedback loops (relies on both panels always having exactly the same row count, so index+offset mirroring keeps them showing the same rows without per-line height math). One shared `ScrollState` passed into every line in both panels for horizontal scroll, replacing the original per-line `rememberScrollState()` (which only scrolled one line at a time — a bug caught live by the human product owner: "글자 한줄만 스크롤 되게 하지 말고").
- Commit-detail URL bug: found by reproducing live (tap a commit → "커밋이나 기준을 찾을 수 없습니다"), confirming via the Gitea container's own request log (Container Manager → 로그, searched for the repository name) that some requests were genuine `200 OK`s misclassified as errors and others were real 404s, then bisecting with temporary `BuildConfig.DEBUG`-gated logging (removed before commit) that printed the caught exception's class/message. Root cause: `getCommitDetail` used `repoUrl(repository, "commits", sha)` instead of `repoUrl(repository, "git", "commits", sha)`. Fixed by aligning to the same `git/commits/` family the already-working `.diff` endpoint uses. `GiteaHistoryApiTest`'s path-segment assertion updated to match.
- Compare removal: `domain/history/HistoryRepository.kt` (`compare`/`getComparePatch` methods removed), `data/history/GiteaHistoryApi.kt` (`compare`/`getComparePatch`/`compareUrl`/`CompareDto` removed, `validatePage`/`safeNextPage` removed as they had no remaining caller), `feature/history/HistoryViewModel.kt` (`CompareLoading`/`CompareResult`/`CompareError` states and all `compare*`/`loadCompare`/`replaceComparePatch` methods removed), `feature/history/HistoryScreen.kt` (`CompareInput`/`CompareCommitRow`/`ComparePatchSection` composables and their `when` branches removed), `MainActivity.kt` (compare callback wiring removed), `strings_history.xml` (compare-specific strings removed), matching tests removed from `GiteaHistoryApiTest.kt` and `HistoryViewModelTest.kt`.
- `./gradlew assembleDebug testDebugUnitTest lintDebug --rerun-tasks`: BUILD SUCCESSFUL at every checkpoint through this work, most recently after the compare removal — all unit tests passing, 0 lint errors.
- Live device verification (wireless `adb` to the human product owner's own Z Fold, paired earlier this session): confirmed color-coded selection and commit time render; confirmed the previously-404ing commit-detail screen now opens; confirmed the file diff view renders and colors correctly; confirmed the shared-horizontal-scroll fix; confirmed the Z-Fold split view request was received and implemented (not yet re-confirmed live after the compare-removal rebuild — that is the remaining item above). One rendering glitch was reported live (two screens appearing to overlap) during this iteration; it has not been independently reproduced or root-caused as of this commit and should be watched for in the next round of device testing.
## Review findings
None yet — pending Codex/human review.