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:
parent
7da04a77ae
commit
4b3c11d849
@ -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,
|
||||||
|
|||||||
@ -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>?,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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?,
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
67
tasks/024-branch-tap-opens-history.md
Normal file
67
tasks/024-branch-tap-opens-history.md
Normal 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.
|
||||||
Loading…
x
Reference in New Issue
Block a user