diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index e3239a2..c644ba6 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -20,6 +20,10 @@ import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserTarget import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserUiState import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserViewModel import com.ubisam.ubgitea.feature.codebrowser.CodeReferenceKind +import com.ubisam.ubgitea.feature.history.HistoryScreen +import com.ubisam.ubgitea.feature.history.HistoryTarget +import com.ubisam.ubgitea.feature.history.HistoryUiState +import com.ubisam.ubgitea.feature.history.HistoryViewModel import com.ubisam.ubgitea.feature.repositories.ReferenceKind import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel @@ -32,6 +36,9 @@ class MainActivity : ComponentActivity() { private val codeBrowserViewModel: CodeBrowserViewModel by viewModels { CodeBrowserViewModel.Factory(application) } + private val historyViewModel: HistoryViewModel by viewModels { + HistoryViewModel.Factory(application) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -39,11 +46,42 @@ class MainActivity : ComponentActivity() { setContent { val state by viewModel.state.collectAsState() val codeBrowserState by codeBrowserViewModel.state.collectAsState() + val historyState by historyViewModel.state.collectAsState() var browsingCode by rememberSaveable { mutableStateOf(codeBrowserState !is CodeBrowserUiState.Idle) } + var browsingHistory by rememberSaveable { + mutableStateOf(historyState !is HistoryUiState.Idle) + } UBGiteaTheme { - if (browsingCode) { + if (browsingHistory) { + HistoryScreen( + state = historyState, + onCommitSelected = historyViewModel::openCommit, + onNextPage = historyViewModel::nextPage, + onPreviousPage = historyViewModel::previousPage, + onRetryList = historyViewModel::retryList, + onRetryDetail = historyViewModel::retryDetail, + onRetryPatch = historyViewModel::retryPatch, + onBackToList = historyViewModel::backToList, + onBack = { + historyViewModel.close() + browsingHistory = false + }, + onCompare = historyViewModel::compareAgainstCurrentRef, + onCompareNextPage = historyViewModel::compareNextPage, + onComparePreviousPage = historyViewModel::comparePreviousPage, + onRetryCompare = historyViewModel::retryCompare, + onLoadComparePatch = historyViewModel::loadComparePatch, + onAuthenticationExpired = { + historyViewModel.close() + codeBrowserViewModel.close() + browsingHistory = false + browsingCode = false + viewModel.logout() + }, + ) + } else if (browsingCode) { CodeBrowserScreen( state = codeBrowserState, onEntrySelected = codeBrowserViewModel::openEntry, @@ -60,6 +98,10 @@ class MainActivity : ComponentActivity() { browsingCode = false viewModel.logout() }, + onViewHistory = { target -> + historyViewModel.open(HistoryTarget(target.repository, target.ref)) + browsingHistory = true + }, ) } else { RepositoryListScreen( diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt index c90dac0..80e6459 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt @@ -45,6 +45,7 @@ fun CodeBrowserScreen( onRetry: () -> Unit, onBack: () -> Unit, onAuthenticationExpired: () -> Unit, + onViewHistory: (CodeBrowserTarget) -> Unit, modifier: Modifier = Modifier, ) { BackHandler(onBack = onBack) @@ -53,11 +54,11 @@ fun CodeBrowserScreen( when (state) { CodeBrowserUiState.Idle -> Unit is CodeBrowserUiState.Loading -> { - BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected, onBack) + BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected, onBack, onViewHistory) CenterMessage { CircularProgressIndicator() } } is CodeBrowserUiState.Directory -> { - BrowserHeader(state.target, state.path, false, onBreadcrumbSelected, onBack) + BrowserHeader(state.target, state.path, false, onBreadcrumbSelected, onBack, onViewHistory) if (state.entries.isEmpty()) { CenterMessage { Text(stringResource(R.string.codebrowser_directory_empty)) } } else { @@ -69,11 +70,11 @@ fun CodeBrowserScreen( } } is CodeBrowserUiState.File -> { - BrowserHeader(state.target, state.path, true, onBreadcrumbSelected, onBack) + BrowserHeader(state.target, state.path, true, onBreadcrumbSelected, onBack, onViewHistory) FileContent(state.content) } is CodeBrowserUiState.UnsupportedEntry -> { - BrowserHeader(state.target, state.path, true, onBreadcrumbSelected, onBack) + BrowserHeader(state.target, state.path, true, onBreadcrumbSelected, onBack, onViewHistory) CenterMessage { Text( stringResource( @@ -84,7 +85,7 @@ fun CodeBrowserScreen( } } is CodeBrowserUiState.Error -> { - BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected, onBack) + BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected, onBack, onViewHistory) CenterMessage { Text(stringResource(state.type.messageResource)) if (state.type == CodeBrowserError.AUTHENTICATION_EXPIRED) { @@ -108,11 +109,12 @@ private fun BrowserHeader( leafIsFile: Boolean, onBreadcrumbSelected: (RepositoryPath) -> Unit, onBack: () -> Unit, + onViewHistory: (CodeBrowserTarget) -> Unit, ) { Column(Modifier.fillMaxWidth().padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { OutlinedButton(onClick = onBack) { Text(stringResource(R.string.codebrowser_back)) } - Column(Modifier.padding(start = 12.dp)) { + Column(Modifier.padding(start = 12.dp).weight(1f)) { Text("${target.repository.owner}/${target.repository.name}", style = MaterialTheme.typography.titleLarge) val refKind = when (target.kind) { CodeReferenceKind.BRANCH -> stringResource(R.string.codebrowser_ref_branch) @@ -121,6 +123,7 @@ private fun BrowserHeader( } Text(stringResource(R.string.codebrowser_ref, refKind, target.ref.value)) } + OutlinedButton(onClick = { onViewHistory(target) }) { Text(stringResource(R.string.codebrowser_history)) } } Row(Modifier.horizontalScroll(rememberScrollState()).padding(top = 12.dp)) { val breadcrumbs = CodeBrowserNavigation.breadcrumbs(path) diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt new file mode 100644 index 0000000..04896a8 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryScreen.kt @@ -0,0 +1,323 @@ +package com.ubisam.ubgitea.feature.history + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.clickable +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.ubisam.ubgitea.R +import com.ubisam.ubgitea.domain.history.CommitDetail +import com.ubisam.ubgitea.domain.history.FileChange +import com.ubisam.ubgitea.domain.history.FileChangeStatus +import com.ubisam.ubgitea.domain.model.CommitSummary +import com.ubisam.ubgitea.domain.model.RepositoryRef + +@Composable +fun HistoryScreen( + state: HistoryUiState, + onCommitSelected: (CommitSummary) -> Unit, + onNextPage: () -> Unit, + onPreviousPage: () -> Unit, + onRetryList: () -> Unit, + onRetryDetail: () -> Unit, + onRetryPatch: () -> Unit, + onBackToList: () -> Unit, + onBack: () -> Unit, + onCompare: (RepositoryRef) -> Unit, + onCompareNextPage: () -> Unit, + onComparePreviousPage: () -> Unit, + onRetryCompare: () -> Unit, + onLoadComparePatch: () -> Unit, + onAuthenticationExpired: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold(modifier = modifier.fillMaxSize()) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + when (state) { + HistoryUiState.Idle -> Unit + is HistoryUiState.Loading -> { + BackHandler(onBack = onBack) + Header(state.target, onBack) + CenterMessage { CircularProgressIndicator() } + } + is HistoryUiState.CommitList -> { + BackHandler(onBack = onBack) + Header(state.target, onBack) + CompareInput(onCompare) + if (state.commits.isEmpty()) { + CenterMessage { Text(stringResource(R.string.history_empty)) } + } else { + LazyColumn(Modifier.weight(1f)) { + items(state.commits, key = CommitSummary::id) { commit -> + CommitRow(commit) { onCommitSelected(commit) } + } + } + } + PageFooter(state.page, state.page > 1, state.hasNextPage, onPreviousPage, onNextPage) + } + is HistoryUiState.CommitDetailLoading -> { + BackHandler(onBack = onBackToList) + Header(state.target, onBackToList) + CenterMessage { CircularProgressIndicator() } + } + is HistoryUiState.CommitDetailContent -> { + BackHandler(onBack = onBackToList) + Header(state.target, onBackToList) + CommitDetailView(state, onRetryPatch) + } + is HistoryUiState.Error -> { + BackHandler(onBack = onBack) + Header(state.target, onBack) + 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 = if (state.context == HistoryErrorContext.LIST) onRetryList else onRetryDetail) { + Text(stringResource(R.string.history_retry)) + } + } + } + } + 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)) } + } + } + } + } + } + } +} + +@Composable +private fun Header(target: HistoryTarget, onBack: () -> Unit) { + Column(Modifier.fillMaxWidth().padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedButton(onClick = onBack) { Text(stringResource(R.string.history_back)) } + Column(Modifier.padding(start = 12.dp)) { + Text("${target.repository.owner}/${target.repository.name}", style = MaterialTheme.typography.titleLarge) + Text(stringResource(R.string.history_ref, target.ref.value), style = MaterialTheme.typography.bodyMedium) + } + } + } + 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 +private fun CommitRow(commit: CommitSummary, onSelected: () -> Unit) { + Column( + Modifier.fillMaxWidth().clickable(role = Role.Button, onClick = onSelected) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text(commit.shortId, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) + Text(commit.message, style = MaterialTheme.typography.titleMedium, maxLines = 2) + Text(commit.authorName, style = MaterialTheme.typography.bodySmall) + } + HorizontalDivider() +} + +@Composable +private fun CompareCommitRow(detail: CommitDetail) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { + Text(detail.summary.shortId, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) + Text(detail.summary.message, style = MaterialTheme.typography.titleMedium, maxLines = 2) + detail.files.forEach { FileChangeRow(it) } + } + HorizontalDivider() +} + +@Composable +private fun CommitDetailView(state: HistoryUiState.CommitDetailContent, onRetryPatch: () -> Unit) { + LazyColumn(Modifier.fillMaxSize()) { + item { + Column(Modifier.fillMaxWidth().padding(16.dp)) { + Text(state.commit.id, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) + Text(state.commit.message, style = MaterialTheme.typography.titleMedium) + Text(state.commit.authorName, style = MaterialTheme.typography.bodySmall) + } + } + items(state.files, key = FileChange::path) { FileChangeRow(it, Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) } + item { HorizontalDivider(Modifier.padding(vertical = 12.dp)) } + item { PatchSection(state.patch, onRetryPatch) } + } +} + +@Composable +private fun FileChangeRow(file: FileChange, modifier: Modifier = Modifier) { + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(file.status.labelResource), style = MaterialTheme.typography.bodySmall) + Text(file.path, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + file.previousPath?.let { + Text( + stringResource(R.string.history_renamed_from, it), + style = MaterialTheme.typography.bodySmall, + modifier = modifier, + ) + } +} + +@Composable +private fun PatchSection(patch: PatchUiState, onRetry: () -> Unit) { + when (patch) { + PatchUiState.Loading -> CenterMessage { CircularProgressIndicator() } + is PatchUiState.Loaded -> PatchText(patch.text, patch.isTruncated) + PatchUiState.Failed -> CenterMessage { + Text(stringResource(R.string.history_patch_failed)) + Button(onClick = onRetry) { Text(stringResource(R.string.history_retry)) } + } + } +} + +@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 +private fun PatchText(text: String, isTruncated: Boolean) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + if (isTruncated) Text(stringResource(R.string.history_patch_truncated)) + SelectionContainer { + Text( + text, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.horizontalScroll(rememberScrollState()), + ) + } + } +} + +@Composable +private fun PageFooter(page: Int, hasPrevious: Boolean, hasNext: Boolean, onPrevious: () -> Unit, onNext: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = onPrevious, enabled = hasPrevious) { Text(stringResource(R.string.history_previous_page)) } + Text(stringResource(R.string.history_page_number, page)) + OutlinedButton(onClick = onNext, enabled = hasNext) { Text(stringResource(R.string.history_next_page)) } + } +} + +@Composable +private fun CenterMessage(content: @Composable ColumnScope.() -> Unit) { + Column( + Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + content = content, + ) +} + +private val FileChangeStatus.labelResource: Int + get() = when (this) { + FileChangeStatus.ADDED -> R.string.history_status_added + FileChangeStatus.MODIFIED -> R.string.history_status_modified + FileChangeStatus.DELETED -> R.string.history_status_deleted + FileChangeStatus.RENAMED -> R.string.history_status_renamed + FileChangeStatus.OTHER -> R.string.history_status_other + } + +private val HistoryError.messageResource: Int + get() = when (this) { + HistoryError.OFFLINE -> R.string.history_offline + HistoryError.AUTHENTICATION_EXPIRED -> R.string.history_auth_expired + HistoryError.PERMISSION_DENIED -> R.string.history_permission_denied + HistoryError.NOT_FOUND -> R.string.history_not_found + HistoryError.CONFLICT -> R.string.history_conflict + HistoryError.UNPROCESSABLE -> R.string.history_unprocessable + HistoryError.SERVER -> R.string.history_server_error + } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryViewModel.kt new file mode 100644 index 0000000..a5a9b4c --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/history/HistoryViewModel.kt @@ -0,0 +1,328 @@ +package com.ubisam.ubgitea.feature.history + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.ubisam.ubgitea.core.auth.android.AndroidKeystoreTokenVault +import com.ubisam.ubgitea.core.network.ApiFailure +import com.ubisam.ubgitea.core.network.OkHttpTransport +import com.ubisam.ubgitea.core.auth.protocol.UBGiteaOAuthDefaults +import com.ubisam.ubgitea.data.history.GiteaHistoryApi +import com.ubisam.ubgitea.data.repositories.BearerTokenProvider +import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi +import com.ubisam.ubgitea.domain.history.CommitDetail +import com.ubisam.ubgitea.domain.history.HistoryRepository +import com.ubisam.ubgitea.domain.model.CommitSummary +import com.ubisam.ubgitea.domain.model.RepositoryCoordinates +import com.ubisam.ubgitea.domain.model.RepositoryPath +import com.ubisam.ubgitea.domain.model.RepositoryRef +import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser +import java.io.IOException +import java.util.Collections +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class HistoryTarget( + val repository: RepositoryCoordinates, + val ref: RepositoryRef, +) + +enum class HistoryError { OFFLINE, AUTHENTICATION_EXPIRED, PERMISSION_DENIED, NOT_FOUND, CONFLICT, UNPROCESSABLE, SERVER } + +sealed interface PatchUiState { + data object Loading : PatchUiState + data class Loaded(val text: String, val isTruncated: Boolean) : PatchUiState + data object Failed : PatchUiState +} + +sealed interface HistoryUiState { + data object Idle : HistoryUiState + data class Loading(val target: HistoryTarget, val path: RepositoryPath) : HistoryUiState + class CommitList( + val target: HistoryTarget, + val path: RepositoryPath, + commits: List, + val page: Int, + val hasNextPage: Boolean, + ) : HistoryUiState { + val commits: List = Collections.unmodifiableList(commits.toList()) + } + data class CommitDetailLoading(val target: HistoryTarget, val commit: CommitSummary) : HistoryUiState + class CommitDetailContent( + val target: HistoryTarget, + val commit: CommitSummary, + detail: CommitDetail, + val patch: PatchUiState, + ) : HistoryUiState { + val files = Collections.unmodifiableList(detail.files.toList()) + } + data class Error( + val target: HistoryTarget, + val type: HistoryError, + val context: HistoryErrorContext, + val failedCommit: CommitSummary? = null, + ) : 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, + val page: Int, + val hasNextPage: Boolean, + val patch: PatchUiState, + ) : HistoryUiState { + val commits: List = 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 } + +class HistoryViewModel( + private val browser: GiteaRepositoryBrowser, + private val history: HistoryRepository, +) : ViewModel() { + private val mutableState = MutableStateFlow(HistoryUiState.Idle) + val state: StateFlow = mutableState.asStateFlow() + private var operation: Job? = null + private var patchJob: Job? = null + + fun open(target: HistoryTarget, path: RepositoryPath = RepositoryPath.Root) = loadPage(target, path, page = 1) + + fun nextPage() { + val current = mutableState.value as? HistoryUiState.CommitList ?: return + if (!current.hasNextPage) return + loadPage(current.target, current.path, current.page + 1) + } + + fun previousPage() { + val current = mutableState.value as? HistoryUiState.CommitList ?: return + if (current.page <= 1) return + loadPage(current.target, current.path, current.page - 1) + } + + fun retryList() { + val current = mutableState.value as? HistoryUiState.Error ?: return + if (current.context == HistoryErrorContext.LIST) loadPage(current.target, RepositoryPath.Root, page = 1) + } + + fun openCommit(commit: CommitSummary) { + val current = mutableState.value + val target = current.currentTarget() ?: return + cancelPatchJob() + mutableState.value = HistoryUiState.CommitDetailLoading(target, commit) + operation?.cancel() + operation = viewModelScope.launch { + try { + val detail = history.getCommitDetail(target.repository, commit.id) + mutableState.value = HistoryUiState.CommitDetailContent(target, commit, detail, PatchUiState.Loading) + loadCommitPatch(target, commit) + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + mutableState.value = HistoryUiState.Error(target, failure.toHistoryError(), HistoryErrorContext.DETAIL, commit) + } + } + } + + fun retryDetail() { + val current = mutableState.value as? HistoryUiState.Error ?: return + if (current.context != HistoryErrorContext.DETAIL) return + current.failedCommit?.let(::openCommit) ?: retryList() + } + + fun retryPatch() { + val current = mutableState.value as? HistoryUiState.CommitDetailContent ?: return + loadCommitPatch(current.target, current.commit) + } + + fun backToList() { + cancelPatchJob() + operation?.cancel() + val target = mutableState.value.currentTarget() ?: return + 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() { + operation?.cancel() + cancelPatchJob() + mutableState.value = HistoryUiState.Idle + } + + private fun loadPage(target: HistoryTarget, path: RepositoryPath, page: Int) { + cancelPatchJob() + operation?.cancel() + mutableState.value = HistoryUiState.Loading(target, path) + operation = viewModelScope.launch { + try { + val result = browser.listCommits(target.repository, target.ref, path.takeUnless(RepositoryPath::isRoot), page, PAGE_SIZE) + mutableState.value = HistoryUiState.CommitList(target, path, result.items, page, result.nextPage != null) + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + mutableState.value = HistoryUiState.Error(target, failure.toHistoryError(), HistoryErrorContext.LIST) + } + } + } + + private fun loadCommitPatch(target: HistoryTarget, commit: CommitSummary) { + patchJob?.cancel() + patchJob = viewModelScope.launch { + try { + val patch = history.getCommitPatch(target.repository, commit.id) + (mutableState.value as? HistoryUiState.CommitDetailContent)?.let { current -> + if (current.commit.id == commit.id) { + mutableState.value = HistoryUiState.CommitDetailContent( + current.target, + current.commit, + CommitDetail(current.commit, current.files), + PatchUiState.Loaded(patch.text, patch.isTruncated), + ) + } + } + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + (mutableState.value as? HistoryUiState.CommitDetailContent)?.let { current -> + if (current.commit.id == commit.id) { + mutableState.value = HistoryUiState.CommitDetailContent( + current.target, + current.commit, + CommitDetail(current.commit, current.files), + PatchUiState.Failed, + ) + } + } + } + } + } + + 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() { + patchJob?.cancel() + patchJob = null + } + + private fun HistoryUiState.currentTarget(): HistoryTarget? = when (this) { + HistoryUiState.Idle -> null + is HistoryUiState.Loading -> target + is HistoryUiState.CommitList -> target + is HistoryUiState.CommitDetailLoading -> target + is HistoryUiState.CommitDetailContent -> 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 { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + require(modelClass.isAssignableFrom(HistoryViewModel::class.java)) + val tokenVault = AndroidKeystoreTokenVault(application) + val tokenProvider = BearerTokenProvider { tokenVault.read()?.accessToken.orEmpty() } + val browser = GiteaRepositoryBrowserApi(UBGiteaOAuthDefaults.config, OkHttpTransport(), tokenProvider) + val history = GiteaHistoryApi(UBGiteaOAuthDefaults.config, OkHttpTransport(), tokenProvider) + return HistoryViewModel(browser, history) as T + } + } + + private companion object { + const val PAGE_SIZE = 20 + } +} + +internal fun Throwable.toHistoryError(): HistoryError = when (this) { + is ApiFailure.AuthenticationExpired -> HistoryError.AUTHENTICATION_EXPIRED + is ApiFailure.PermissionDenied -> HistoryError.PERMISSION_DENIED + is ApiFailure.NotFound -> HistoryError.NOT_FOUND + is ApiFailure.Conflict -> HistoryError.CONFLICT + is ApiFailure.Unprocessable -> HistoryError.UNPROCESSABLE + is ApiFailure.Network, is IOException -> HistoryError.OFFLINE + else -> HistoryError.SERVER +} diff --git a/app/src/main/res/values/strings_codebrowser.xml b/app/src/main/res/values/strings_codebrowser.xml index 61abe75..aebeced 100644 --- a/app/src/main/res/values/strings_codebrowser.xml +++ b/app/src/main/res/values/strings_codebrowser.xml @@ -26,4 +26,5 @@ 서버 응답을 처리하지 못했습니다. 다시 시도 다시 로그인 + 히스토리 diff --git a/app/src/main/res/values/strings_history.xml b/app/src/main/res/values/strings_history.xml new file mode 100644 index 0000000..5dc9e3f --- /dev/null +++ b/app/src/main/res/values/strings_history.xml @@ -0,0 +1,29 @@ + + 뒤로 + 기준: %1$s + 커밋 내역이 없습니다. + 다시 시도 + 다시 로그인 + 이전 페이지 + 다음 페이지 + %1$d페이지 + 비교할 브랜치/태그/커밋 + 비교 + %1$s ↔ %2$s 비교 + 두 기준 사이에 커밋 차이가 없습니다. + 이전 경로: %1$s + 변경 내용을 불러오지 못했습니다. + 내용이 잘려서 표시됩니다. + 추가됨 + 수정됨 + 삭제됨 + 이름 변경됨 + 기타 변경 + 서버에 연결할 수 없습니다. 네트워크를 확인해 주세요. + 로그인이 만료되었습니다. 다시 로그인해 주세요. + 이 저장소의 히스토리를 볼 권한이 없습니다. + 커밋이나 기준을 찾을 수 없습니다. + 저장소가 비어 있거나 비교할 수 없는 상태입니다. + 요청을 처리할 수 없습니다. + Gitea 서버에서 오류가 발생했습니다. + diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/history/HistoryViewModelTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/history/HistoryViewModelTest.kt new file mode 100644 index 0000000..753beaf --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/feature/history/HistoryViewModelTest.kt @@ -0,0 +1,288 @@ +package com.ubisam.ubgitea.feature.history + +import com.ubisam.ubgitea.core.network.ApiFailure +import com.ubisam.ubgitea.domain.history.BoundedPatch +import com.ubisam.ubgitea.domain.history.CommitDetail +import com.ubisam.ubgitea.domain.history.FileChange +import com.ubisam.ubgitea.domain.history.FileChangeStatus +import com.ubisam.ubgitea.domain.history.HistoryRepository +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.RepositoryPath +import com.ubisam.ubgitea.domain.model.RepositoryRef +import com.ubisam.ubgitea.domain.model.RepositorySummary +import com.ubisam.ubgitea.domain.model.SourceFile +import com.ubisam.ubgitea.domain.model.TagSummary +import com.ubisam.ubgitea.domain.model.TreeEntry +import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser +import com.ubisam.ubgitea.domain.model.BranchSummary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class HistoryViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val target = HistoryTarget(RepositoryCoordinates.of("owner", "repo"), RepositoryRef.of("main")) + + @Before + fun setMainDispatcher() = Dispatchers.setMain(dispatcher) + + @After + fun resetMainDispatcher() = Dispatchers.resetMain() + + @Test + fun `open loads the first page of commit history for the target ref`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser(commits = listOf(Page.of(listOf(commit("a"), commit("b")), nextPage = 2))) + val viewModel = HistoryViewModel(browser, FakeHistory()) + + viewModel.open(target) + advanceUntilIdle() + + val state = viewModel.state.value as HistoryUiState.CommitList + assertEquals(listOf("a", "b"), state.commits.map { it.id }) + assertEquals(1, state.page) + assertTrue(state.hasNextPage) + } + + @Test + fun `next and previous page navigate commit history`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser( + commits = listOf( + Page.of(listOf(commit("a")), nextPage = 2), + Page.of(listOf(commit("b")), nextPage = null), + ), + ) + val viewModel = HistoryViewModel(browser, FakeHistory()) + viewModel.open(target) + advanceUntilIdle() + + viewModel.nextPage() + advanceUntilIdle() + var state = viewModel.state.value as HistoryUiState.CommitList + assertEquals(2, state.page) + assertFalse(state.hasNextPage) + + viewModel.previousPage() + advanceUntilIdle() + state = viewModel.state.value as HistoryUiState.CommitList + assertEquals(1, state.page) + } + + @Test + fun `opening a commit loads its file changes then its patch`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser(commits = listOf(Page.of(listOf(commit("a")), null))) + val history = FakeHistory( + detail = CommitDetail( + commit("a"), + listOf( + FileChange("new.kt", null, FileChangeStatus.ADDED), + FileChange("gone.kt", null, FileChangeStatus.DELETED), + FileChange("moved.kt", "old.kt", FileChangeStatus.RENAMED), + ), + ), + patch = BoundedPatch("diff text", isTruncated = false), + ) + val viewModel = HistoryViewModel(browser, history) + viewModel.open(target) + advanceUntilIdle() + + viewModel.openCommit((viewModel.state.value as HistoryUiState.CommitList).commits.single()) + advanceUntilIdle() + + val state = viewModel.state.value as HistoryUiState.CommitDetailContent + assertEquals( + listOf(FileChangeStatus.ADDED, FileChangeStatus.DELETED, FileChangeStatus.RENAMED), + state.files.map { it.status }, + ) + assertEquals("old.kt", state.files.last().previousPath) + assertEquals(PatchUiState.Loaded("diff text", false), state.patch) + } + + @Test + 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 history = FakeHistory( + detail = CommitDetail(commit("a"), emptyList()), + patch = BoundedPatch("partial", isTruncated = true), + ) + val viewModel = HistoryViewModel(browser, history) + viewModel.open(target) + advanceUntilIdle() + viewModel.openCommit((viewModel.state.value as HistoryUiState.CommitList).commits.single()) + advanceUntilIdle() + + val state = viewModel.state.value as HistoryUiState.CommitDetailContent + assertEquals(PatchUiState.Loaded("partial", true), state.patch) + } + + @Test + fun `a failed patch fetch does not discard the already-loaded file list and can be retried`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser(commits = listOf(Page.of(listOf(commit("a")), null))) + val history = FakeHistory( + detail = CommitDetail(commit("a"), listOf(FileChange("x.kt", null, FileChangeStatus.MODIFIED))), + patchFailure = ApiFailure.Network(java.io.IOException()), + ) + val viewModel = HistoryViewModel(browser, history) + viewModel.open(target) + advanceUntilIdle() + viewModel.openCommit((viewModel.state.value as HistoryUiState.CommitList).commits.single()) + advanceUntilIdle() + + var state = viewModel.state.value as HistoryUiState.CommitDetailContent + assertEquals(PatchUiState.Failed, state.patch) + assertEquals(1, state.files.size) + + history.patchFailure = null + history.patch = BoundedPatch("recovered", false) + viewModel.retryPatch() + advanceUntilIdle() + + state = viewModel.state.value as HistoryUiState.CommitDetailContent + assertEquals(PatchUiState.Loaded("recovered", false), state.patch) + } + + @Test + fun `a failed commit detail fetch can be retried for the same commit`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser(commits = listOf(Page.of(listOf(commit("a")), null))) + val history = FakeHistory(detailFailure = ApiFailure.Network(java.io.IOException())) + val viewModel = HistoryViewModel(browser, history) + viewModel.open(target) + advanceUntilIdle() + val theCommit = (viewModel.state.value as HistoryUiState.CommitList).commits.single() + viewModel.openCommit(theCommit) + advanceUntilIdle() + + val error = viewModel.state.value as HistoryUiState.Error + assertEquals(HistoryErrorContext.DETAIL, error.context) + + history.detailFailure = null + history.detail = CommitDetail(theCommit, emptyList()) + viewModel.retryDetail() + advanceUntilIdle() + + assertTrue(viewModel.state.value is HistoryUiState.CommitDetailContent) + } + + @Test + fun `authentication expired maps to a typed error`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser(listFailure = ApiFailure.AuthenticationExpired()) + val viewModel = HistoryViewModel(browser, FakeHistory()) + + viewModel.open(target) + advanceUntilIdle() + + val error = viewModel.state.value as HistoryUiState.Error + 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 class FakeBrowser( + private val commits: List> = listOf(Page.of(emptyList(), null)), + private val listFailure: Throwable? = null, + ) : GiteaRepositoryBrowser { + private var callIndex = 0 + override suspend fun listCommits( + repository: RepositoryCoordinates, + ref: RepositoryRef, + path: RepositoryPath?, + page: Int, + pageSize: Int, + ): Page { + listFailure?.let { throw it } + return commits.getOrElse(page - 1) { Page.of(emptyList(), null) }.also { callIndex++ } + } + + override suspend fun listRepositories(page: Int, pageSize: Int): Page = error("Not used") + override suspend fun listBranches(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page = error("Not used") + override suspend fun listTags(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page = error("Not used") + override suspend fun listTree(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int, pageSize: Int): Page = error("Not used") + override suspend fun getFile(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath): SourceFile = error("Not used") + } + + private class FakeHistory( + var detail: CommitDetail? = null, + var detailFailure: Throwable? = null, + var patch: BoundedPatch = BoundedPatch("", false), + var patchFailure: Throwable? = null, + private val compareResults: List> = listOf(Page.of(emptyList(), null)), + ) : HistoryRepository { + var lastCompareRequest: Pair? = null + + override suspend fun getCommitDetail(repository: RepositoryCoordinates, sha: String): CommitDetail { + detailFailure?.let { throw it } + return detail ?: CommitDetail(CommitSummary(sha, sha.take(7), "m", "a", 1_000L), emptyList()) + } + + override suspend fun getCommitPatch(repository: RepositoryCoordinates, sha: String): BoundedPatch { + patchFailure?.let { throw it } + return patch + } + + override suspend fun compare( + repository: RepositoryCoordinates, + baseRef: RepositoryRef, + headRef: RepositoryRef, + page: Int, + pageSize: Int, + ): Page { + 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 + } +} diff --git a/docs/development-log.md b/docs/development-log.md index 76b7318..b63bd69 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -216,3 +216,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl - Known limitations: No physical device or live Gitea instance was used. Codex was independently attempting this same task in parallel (uncommitted); this entry and this implementation exist alongside that one and the two need to be reconciled by whoever reviews, not assumed to be the same. - Next recommended task: Codex (or whoever reviews) reconciles this implementation against its own parallel TASK-009 attempt, reviews, and records the contract-freeze commit so TASK-007 can begin. - Next owner: Codex, for reconciliation, review, and contract-freeze recording. + +## 2026-08-26 — TASK-007: commit history and change UI + +- Agent and role: Claude, feature implementer, per the human product owner's direction that Claude owns all subsequent tasks rather than Codex ("이후 다른 작업들도 코덱스 말고 너가 다 작업해줘"). +- Commit/diff: branch `claude/task-007-history-ui` from `3c17476` (this session's own TASK-009, not yet Codex-reviewed); new `feature/history/` package plus `MainActivity.kt` and `CodeBrowserScreen.kt` wiring. +- Features implemented: Paginated commit history for the currently browsed ref; commit detail with distinguished added/modified/deleted/renamed file status; bounded, explicitly-truncated patch text for a single commit or a ref comparison; a comparison flow that validates one entered ref against the currently open ref as base. +- How it was implemented: Reuses TASK-004's `listCommits` for history listing and this session's own TASK-009 `HistoryRepository` for detail/patch/compare — no shared/domain file touched. A commit's file list and its patch load as two independently-retryable steps rather than one blocking fetch. Entry point is a new "히스토리" button added to the existing code browser header, wired through a third `browsingHistory` screen flag in `MainActivity` layered above the existing `browsingCode` flag. +- Files changed: `feature/history/HistoryViewModel.kt`, `feature/history/HistoryScreen.kt`, `strings_history.xml`, `MainActivity.kt`, `feature/codebrowser/CodeBrowserScreen.kt`, `strings_codebrowser.xml`, `tasks/007-history-and-changes.md`. +- Checks and results: `assembleDebug testDebugUnitTest lintDebug --rerun-tasks` BUILD SUCCESSFUL — 150 unit tests across 26 suites, 0 failures/errors, 0 lint errors; `git diff --check` clean; sensitive-pattern scan clean. +- Review findings and resolutions: Self-review caught and fixed one real bug before writing tests: a failed commit-detail fetch discarded which commit had failed, so "retry" silently fell back to reloading the whole list instead of re-fetching the commit the user actually tried to open. The `Error` state now retains the failed `CommitSummary` so retry targets the same commit. +- Known limitations: TASK-009 has not been independently reviewed or contract-frozen — this task consumes it directly, with no cross-review from a second implementer, per explicit human direction. No physical device was available to verify the three-level back stack (repositories → code browser → history) interactively. Comparison always uses the open ref as base (no arbitrary base-ref picker); comparison patch is fetched once for the whole range, not per commit. +- Next recommended task: TASK-010 is already done (this session); TASK-008 (PR/issue UI) is next and consumes it the same way TASK-007 consumed TASK-009 — directly, without waiting for Codex review, per the same standing direction. +- Next owner: Claude, continuing to TASK-008. diff --git a/tasks/007-history-and-changes.md b/tasks/007-history-and-changes.md index 1ca768c..3748157 100644 --- a/tasks/007-history-and-changes.md +++ b/tasks/007-history-and-changes.md @@ -1,7 +1,7 @@ # TASK-007: View commit history and changes -Status: planned — depends on TASK-006 and Codex TASK-009 -Owner: Claude +Status: implemented; all required checks pass; pending Codex review +Owner: Claude (human product owner directed Claude to own all subsequent tasks, 2026-08-26) Reviewer: Codex ## Outcome @@ -13,17 +13,58 @@ A user can inspect commit history, changed files, per-commit patches, and compar - `app/src/main/java/com/ubisam/ubgitea/feature/history/` - `app/src/test/java/com/ubisam/ubgitea/feature/history/` - `app/src/main/res/values/strings_history.xml` +- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt` (history entry point wiring) +- `app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt` (history button only) +- `app/src/main/res/values/strings_codebrowser.xml` (one new string for the history button) - TASK-007 evidence only -Shared domain, data, Gradle, manifest, and navigation changes must be requested from Codex instead of edited by Claude. +The "request shared changes from Codex" restriction above no longer applies per the human product owner's 2026-08-26 direction that Claude owns all subsequent tasks; navigation/wiring changes were made directly and are called out explicitly rather than silently expanded. ## Acceptance criteria -- [ ] Commit history supports pagination and clearly identifies the active ref. -- [ ] Commit details show metadata and changed files without exposing private data in logs. -- [ ] Added, modified, renamed, and deleted files are distinguished. -- [ ] Large or truncated diffs are explicitly identified and bounded for mobile rendering. -- [ ] Ref comparisons validate and encode both refs independently. -- [ ] Tests cover change mapping, rename/delete behavior, pagination, truncation, and error states. -- [ ] Resource names use the `history_` prefix. -- [ ] Evidence records the exact reviewed TASK-009 contract-freeze commit. +- [x] Commit history supports pagination and clearly identifies the active ref — `HistoryScreen`'s header always shows `owner/repo` and the active ref; commit list pages with explicit previous/next controls (`GiteaRepositoryBrowser.listCommits`, TASK-004). +- [x] Commit details show metadata and changed files without exposing private data in logs — commit id/message/author/files are rendered in Compose state only; no `Log.*` call exists anywhere in the new code. +- [x] Added, modified, renamed, and deleted files are distinguished — reuses TASK-009's `FileChangeStatus`; each row shows a text label (not color alone), and a renamed file's previous path is shown only when Gitea actually supplies one. +- [x] Large or truncated diffs are explicitly identified and bounded for mobile rendering — reuses TASK-009's `BoundedPatch`; an explicit "잘려서 표시됩니다" notice appears whenever `isTruncated` is true, for both single-commit and comparison patches. +- [x] Ref comparisons validate and encode both refs independently — `RepositoryRef.of()` validates the entered head ref before use; encoding correctness itself is TASK-009's job and was already tested there (including a ref containing `/`). The compare UI always uses the currently open ref as base, so only the head ref needs entry/validation. +- [x] Tests cover change mapping, rename/delete behavior, pagination, truncation, and error states (9 ViewModel tests; see Evidence). +- [x] Resource names use the `history_` prefix — all new `HistoryScreen` strings are `history_*`; the one button added to the existing code browser stayed `codebrowser_*`, consistent with which screen it lives in. +- [ ] Evidence records the exact reviewed TASK-009 contract-freeze commit — TASK-009 (`3c17476`) has not yet been reviewed/frozen by Codex; this task consumes it directly since the human product owner directed Claude to proceed without waiting. Recorded as a known limitation below, not silently skipped. + +## Required checks + +```text +./gradlew assembleDebug +./gradlew testDebugUnitTest +./gradlew lintDebug +``` + +## Evidence + +Implemented on branch `claude/task-007-history-ui` from commit `3c17476` (this task's own TASK-009 implementation, not yet Codex-reviewed). + +- New: `feature/history/HistoryViewModel.kt`, `feature/history/HistoryScreen.kt`, `strings_history.xml`. Modified: `MainActivity.kt` (new `historyViewModel`, a `browsingHistory` screen flag layered above the existing `browsingCode` flag, and wiring for every `HistoryScreen` callback), `feature/codebrowser/CodeBrowserScreen.kt` (`BrowserHeader` gained a "히스토리" button and an `onViewHistory: (CodeBrowserTarget) -> Unit` parameter; every call site updated), `strings_codebrowser.xml` (one new string for that button). +- Design: `HistoryViewModel` reuses `GiteaRepositoryBrowser.listCommits` (TASK-004) for the paginated commit list and `HistoryRepository` (TASK-009, this session) for commit detail, patch text, and comparison — no shared/domain file was touched. Opening a commit shows its file list first (from `getCommitDetail`) and loads its patch as a second, independently retryable operation (`PatchUiState`), mirroring the per-row background-fetch pattern already used in TASK-017's repository list. Commit-detail failures now retain the failed `CommitSummary` so retry re-fetches the *same* commit rather than silently bouncing back to the list — a real gap caught and fixed during self-review before writing tests. Comparison always uses the currently open ref as base (`compareAgainstCurrentRef`), so the UI only needs to collect and validate one ref. +- Entry point: a "히스토리" button in the existing code browser's header opens history for the code browser's current repository/ref, satisfying "from code browsing without losing owner/repo/ref" without introducing a new top-level navigation concept. +- Authentication-expiry handling matches the rest of the app: both plain and comparison error states offer "다시 로그인" instead of a generic retry when the failure is `AUTHENTICATION_EXPIRED`, and it closes every open screen (history, code browser) before logging out. + +Test coverage (9 cases in `HistoryViewModelTest`): first-page load with active ref (1), next/previous pagination (1), commit detail loads files then patch (1), truncated patch surfaced in state (1), a failed patch fetch does not discard the already-loaded file list and is independently retryable (1), a failed commit-detail fetch is retried against the *same* commit (1), authentication-expired maps to a typed error (1), compare uses the open ref as base (1), compare pagination forward/back (1). + +Checks run from the new worktree (`local.properties` created locally, not committed): + +```text +./gradlew testDebugUnitTest --tests "com.ubisam.ubgitea.feature.history.HistoryViewModelTest" → BUILD SUCCESSFUL, 9/9 passed +./gradlew assembleDebug testDebugUnitTest lintDebug --rerun-tasks → BUILD SUCCESSFUL + - 150 unit tests across 26 suites, 0 failures/errors + - 0 lint errors +git diff --check → clean +sensitive-pattern scan of changed files → no matches +``` + +Known limitations: +- TASK-009's contract has not been independently reviewed or formally frozen (see unchecked acceptance box above) — this task was built directly against it at the human product owner's explicit direction, with no cross-review from a second implementer. +- No physical device was available; the new History screen, its code-browser entry point, and back-stack behavior across three screen levels (repositories → code browser → history) were not interactively verified. +- Comparison is one-directional from the currently open ref as base; picking an arbitrary base ref (not the currently open one) is not exposed in this UI, since the task's compare requirement is satisfied by validating and correctly encoding two refs, not by a full ref-picker UX. +- The per-commit patch in comparison mode is fetched once for the *entire* comparison (`getComparePatch`), not per commit in the list, to keep the comparison view bounded; a per-commit patch within a comparison is not exposed. + +No open P0-P3 findings from self-review against the acceptance criteria beyond the retry-target bug already found and fixed above. Requesting Codex read-only review; this review should also reconcile TASK-009's dual (Codex + Claude) implementation history noted in that task's own evidence.