diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index 95f9ba6..eef7893 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -11,6 +11,17 @@ import androidx.activity.viewModels import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable +import com.ubisam.ubgitea.domain.model.RepositoryCoordinates +import com.ubisam.ubgitea.domain.model.RepositoryRef +import com.ubisam.ubgitea.feature.codebrowser.CodeBrowserScreen +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.repositories.ReferenceKind import com.ubisam.ubgitea.feature.repositories.RepositoryListScreen import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel import com.ubisam.ubgitea.ui.theme.UBGiteaTheme @@ -19,24 +30,65 @@ class MainActivity : ComponentActivity() { private val viewModel: RepositoryListViewModel by viewModels { RepositoryListViewModel.Factory(application) } + private val codeBrowserViewModel: CodeBrowserViewModel by viewModels { + CodeBrowserViewModel.Factory(application) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { val state by viewModel.state.collectAsState() + val codeBrowserState by codeBrowserViewModel.state.collectAsState() + var browsingCode by rememberSaveable { + mutableStateOf(codeBrowserState !is CodeBrowserUiState.Idle) + } UBGiteaTheme { - RepositoryListScreen( - state = state, - onSignIn = viewModel::signIn, - onCancelSignIn = viewModel::cancelSignIn, - onRetry = viewModel::retry, - onLogout = viewModel::logout, - onRepositorySelected = viewModel::selectRepository, - onReferenceSelected = viewModel::selectReference, - onReferencesRefresh = viewModel::refreshReferences, - onBackToRepositories = viewModel::backToRepositories, - ) + if (browsingCode) { + CodeBrowserScreen( + state = codeBrowserState, + onEntrySelected = codeBrowserViewModel::openEntry, + onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb, + onRetry = codeBrowserViewModel::retry, + onBack = { + if (!codeBrowserViewModel.navigateUp()) { + codeBrowserViewModel.close() + browsingCode = false + } + }, + onAuthenticationExpired = { + codeBrowserViewModel.close() + browsingCode = false + viewModel.logout() + }, + ) + } else { + RepositoryListScreen( + state = state, + onSignIn = viewModel::signIn, + onCancelSignIn = viewModel::cancelSignIn, + onRetry = viewModel::retry, + onLogout = viewModel::logout, + onRepositorySelected = viewModel::selectRepository, + onReferenceSelected = { repository, name, kind -> + viewModel.selectReference(name, kind) + codeBrowserViewModel.open( + CodeBrowserTarget( + repository = RepositoryCoordinates.of(repository.owner, repository.name), + ref = RepositoryRef.of(name), + kind = if (kind == ReferenceKind.BRANCH) { + CodeReferenceKind.BRANCH + } else { + CodeReferenceKind.TAG + }, + ), + ) + browsingCode = true + }, + onReferencesRefresh = viewModel::refreshReferences, + onBackToRepositories = viewModel::backToRepositories, + ) + } LaunchedEffect(viewModel) { viewModel.browserRequests.collect(::openSystemBrowser) } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserNavigation.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserNavigation.kt new file mode 100644 index 0000000..6659aa7 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserNavigation.kt @@ -0,0 +1,17 @@ +package com.ubisam.ubgitea.feature.codebrowser + +import com.ubisam.ubgitea.domain.model.RepositoryPath + +internal object CodeBrowserNavigation { + fun parent(path: RepositoryPath): RepositoryPath = + path.value.substringBeforeLast('/', missingDelimiterValue = "") + .let(RepositoryPath::of) + + fun breadcrumbs(path: RepositoryPath): List { + if (path.isRoot) return listOf(RepositoryPath.Root) + val segments = path.value.split('/') + return listOf(RepositoryPath.Root) + segments.indices.map { index -> + RepositoryPath.of(segments.take(index + 1).joinToString("/")) + } + } +} 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 new file mode 100644 index 0000000..c90dac0 --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt @@ -0,0 +1,218 @@ +package com.ubisam.ubgitea.feature.codebrowser + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +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.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.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.ubisam.ubgitea.R +import com.ubisam.ubgitea.domain.model.RepositoryPath +import com.ubisam.ubgitea.domain.model.TreeEntry +import com.ubisam.ubgitea.domain.model.TreeEntryType +import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification + +@Composable +fun CodeBrowserScreen( + state: CodeBrowserUiState, + onEntrySelected: (TreeEntry) -> Unit, + onBreadcrumbSelected: (RepositoryPath) -> Unit, + onRetry: () -> Unit, + onBack: () -> Unit, + onAuthenticationExpired: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(onBack = onBack) + Scaffold(modifier = modifier.fillMaxSize()) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + when (state) { + CodeBrowserUiState.Idle -> Unit + is CodeBrowserUiState.Loading -> { + BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected, onBack) + CenterMessage { CircularProgressIndicator() } + } + is CodeBrowserUiState.Directory -> { + BrowserHeader(state.target, state.path, false, onBreadcrumbSelected, onBack) + if (state.entries.isEmpty()) { + CenterMessage { Text(stringResource(R.string.codebrowser_directory_empty)) } + } else { + LazyColumn(Modifier.weight(1f)) { + items(state.entries, key = { it.path.value }) { entry -> + EntryRow(entry, onEntrySelected) + } + } + } + } + is CodeBrowserUiState.File -> { + BrowserHeader(state.target, state.path, true, onBreadcrumbSelected, onBack) + FileContent(state.content) + } + is CodeBrowserUiState.UnsupportedEntry -> { + BrowserHeader(state.target, state.path, true, onBreadcrumbSelected, onBack) + CenterMessage { + Text( + stringResource( + if (state.type == TreeEntryType.SUBMODULE) R.string.codebrowser_submodule + else R.string.codebrowser_symlink, + ), + ) + } + } + is CodeBrowserUiState.Error -> { + BrowserHeader(state.target, state.path, state.isFile, onBreadcrumbSelected, onBack) + CenterMessage { + Text(stringResource(state.type.messageResource)) + if (state.type == CodeBrowserError.AUTHENTICATION_EXPIRED) { + Button(onClick = onAuthenticationExpired) { + Text(stringResource(R.string.codebrowser_sign_in_again)) + } + } else { + Button(onClick = onRetry) { Text(stringResource(R.string.codebrowser_retry)) } + } + } + } + } + } + } +} + +@Composable +private fun BrowserHeader( + target: CodeBrowserTarget, + path: RepositoryPath, + leafIsFile: Boolean, + onBreadcrumbSelected: (RepositoryPath) -> Unit, + onBack: () -> 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)) { + Text("${target.repository.owner}/${target.repository.name}", style = MaterialTheme.typography.titleLarge) + val refKind = when (target.kind) { + CodeReferenceKind.BRANCH -> stringResource(R.string.codebrowser_ref_branch) + CodeReferenceKind.TAG -> stringResource(R.string.codebrowser_ref_tag) + CodeReferenceKind.COMMIT -> stringResource(R.string.codebrowser_ref_commit) + } + Text(stringResource(R.string.codebrowser_ref, refKind, target.ref.value)) + } + } + Row(Modifier.horizontalScroll(rememberScrollState()).padding(top = 12.dp)) { + val breadcrumbs = CodeBrowserNavigation.breadcrumbs(path) + breadcrumbs.forEachIndexed { index, crumb -> + if (index > 0) Text(" / ", modifier = Modifier.padding(horizontal = 2.dp)) + val canNavigate = !(leafIsFile && index == breadcrumbs.lastIndex) + Text( + crumb.value.substringAfterLast('/').ifEmpty { stringResource(R.string.codebrowser_root) }, + color = if (canNavigate) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + modifier = if (canNavigate) { + Modifier.clickable(role = Role.Button) { onBreadcrumbSelected(crumb) } + } else { + Modifier + }, + ) + } + } + } + HorizontalDivider() +} + +@Composable +private fun EntryRow(entry: TreeEntry, onSelected: (TreeEntry) -> Unit) { + val kind = when (entry.type) { + TreeEntryType.DIRECTORY -> stringResource(R.string.codebrowser_entry_directory) + TreeEntryType.FILE -> stringResource(R.string.codebrowser_entry_file) + TreeEntryType.SYMLINK -> stringResource(R.string.codebrowser_entry_symlink) + TreeEntryType.SUBMODULE -> stringResource(R.string.codebrowser_entry_submodule) + } + Column( + Modifier.fillMaxWidth().clickable(role = Role.Button) { onSelected(entry) } + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text(entry.name, style = MaterialTheme.typography.titleMedium) + Text(kind, style = MaterialTheme.typography.bodySmall) + } + HorizontalDivider() +} + +@Composable +@Suppress("DEPRECATION") +private fun FileContent(content: ContentClassification) { + when (content) { + is ContentClassification.Text -> { + val clipboard = LocalClipboardManager.current + Column(Modifier.fillMaxSize().padding(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedButton(onClick = { clipboard.setText(AnnotatedString(content.renderedText)) }) { + Text(stringResource(R.string.codebrowser_copy)) + } + if (content.isTruncated) Text(stringResource(R.string.codebrowser_text_truncated)) + } + SelectionContainer { + Text( + content.renderedText, + fontFamily = FontFamily.Monospace, + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) + .horizontalScroll(rememberScrollState()).padding(top = 12.dp), + ) + } + } + } + is ContentClassification.Binary -> Fallback(R.string.codebrowser_binary, content.sizeBytes) + is ContentClassification.UnsupportedEncoding -> Fallback(R.string.codebrowser_unsupported_encoding, content.sizeBytes) + is ContentClassification.Oversized -> Fallback(R.string.codebrowser_oversized, content.sizeBytes) + is ContentClassification.LfsPointer -> CenterMessage { + Text(stringResource(R.string.codebrowser_lfs, content.objectSizeBytes)) + Text(content.objectId, fontFamily = FontFamily.Monospace) + } + } +} + +@Composable +private fun Fallback(message: Int, sizeBytes: Int) = CenterMessage { + Text(stringResource(message, sizeBytes)) +} + +@Composable +private fun CenterMessage(content: @Composable ColumnScope.() -> Unit) { + Column( + Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + content = content, + ) +} + +private val CodeBrowserError.messageResource: Int + get() = when (this) { + CodeBrowserError.OFFLINE -> R.string.codebrowser_offline + CodeBrowserError.AUTHENTICATION_EXPIRED -> R.string.codebrowser_auth_expired + CodeBrowserError.NOT_FOUND -> R.string.codebrowser_not_found + CodeBrowserError.PERMISSION_DENIED -> R.string.codebrowser_permission_denied + CodeBrowserError.SERVER -> R.string.codebrowser_server_error + } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt new file mode 100644 index 0000000..7d783bf --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt @@ -0,0 +1,217 @@ +package com.ubisam.ubgitea.feature.codebrowser + +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.auth.protocol.UBGiteaOAuthDefaults +import com.ubisam.ubgitea.core.network.ApiFailure +import com.ubisam.ubgitea.core.network.OkHttpTransport +import com.ubisam.ubgitea.data.repositories.BearerTokenProvider +import com.ubisam.ubgitea.data.repositories.GiteaRepositoryBrowserApi +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.TreeEntry +import com.ubisam.ubgitea.domain.model.TreeEntryType +import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser +import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification +import com.ubisam.ubgitea.feature.codebrowser.content.SourceContentClassifier +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 CodeBrowserTarget( + val repository: RepositoryCoordinates, + val ref: RepositoryRef, + val kind: CodeReferenceKind, +) + +enum class CodeReferenceKind { BRANCH, TAG, COMMIT } + +enum class CodeBrowserError { OFFLINE, AUTHENTICATION_EXPIRED, NOT_FOUND, PERMISSION_DENIED, SERVER } + +sealed interface CodeBrowserUiState { + data object Idle : CodeBrowserUiState + data class Loading( + val target: CodeBrowserTarget, + val path: RepositoryPath, + val isFile: Boolean, + ) : CodeBrowserUiState + class Directory( + val target: CodeBrowserTarget, + val path: RepositoryPath, + entries: List, + ) : CodeBrowserUiState { + val entries: List = Collections.unmodifiableList(entries.toList()) + } + data class File( + val target: CodeBrowserTarget, + val path: RepositoryPath, + val content: ContentClassification, + ) : CodeBrowserUiState + data class UnsupportedEntry( + val target: CodeBrowserTarget, + val path: RepositoryPath, + val type: TreeEntryType, + ) : CodeBrowserUiState + data class Error( + val target: CodeBrowserTarget, + val path: RepositoryPath, + val type: CodeBrowserError, + val isFile: Boolean, + ) : CodeBrowserUiState +} + +class CodeBrowserViewModel( + private val browser: GiteaRepositoryBrowser, + private val classifier: SourceContentClassifier = SourceContentClassifier( + maxInputBytes = 1_000_000, + maxRenderedCharacters = 200_000, + maxRenderedLines = 10_000, + ), +) : ViewModel() { + private val mutableState = MutableStateFlow(CodeBrowserUiState.Idle) + val state: StateFlow = mutableState.asStateFlow() + private var operation: Job? = null + + fun open(target: CodeBrowserTarget) = loadDirectory(target, RepositoryPath.Root) + + fun openEntry(entry: TreeEntry) { + val target = currentTarget() ?: return + when (entry.type) { + TreeEntryType.DIRECTORY -> loadDirectory(target, entry.path) + TreeEntryType.FILE -> loadFile(target, entry.path) + TreeEntryType.SYMLINK, TreeEntryType.SUBMODULE -> { + operation?.cancel() + mutableState.value = CodeBrowserUiState.UnsupportedEntry(target, entry.path, entry.type) + } + } + } + + fun openBreadcrumb(path: RepositoryPath) { + val target = currentTarget() ?: return + loadDirectory(target, path) + } + + fun retry() { + val current = mutableState.value as? CodeBrowserUiState.Error ?: return + if (current.isFile) loadFile(current.target, current.path) + else loadDirectory(current.target, current.path) + } + + fun navigateUp(): Boolean { + val current = mutableState.value + val target = currentTarget() ?: return false + val path = currentPath() + if (path.isRoot) return false + loadDirectory(target, CodeBrowserNavigation.parent(path)) + return true + } + + fun close() { + operation?.cancel() + operation = null + mutableState.value = CodeBrowserUiState.Idle + } + + private fun loadDirectory(target: CodeBrowserTarget, path: RepositoryPath) = startOperation { + mutableState.value = CodeBrowserUiState.Loading(target, path, isFile = false) + try { + val entries = loadAllPages { page -> + browser.listTree(target.repository, target.ref, path, page, PAGE_SIZE) + }.distinctBy(TreeEntry::path) + .sortedWith(compareBy { it.type != TreeEntryType.DIRECTORY }.thenBy { it.name.lowercase() }) + mutableState.value = CodeBrowserUiState.Directory(target, path, entries) + } catch (failure: Throwable) { + handleFailure(target, path, failure, isFile = false) + } + } + + private fun loadFile(target: CodeBrowserTarget, path: RepositoryPath) = startOperation { + mutableState.value = CodeBrowserUiState.Loading(target, path, isFile = true) + try { + val file = browser.getFile(target.repository, target.ref, path) + mutableState.value = CodeBrowserUiState.File(target, path, classifier.classify(file.content)) + } catch (failure: Throwable) { + handleFailure(target, path, failure, isFile = true) + } + } + + private fun handleFailure( + target: CodeBrowserTarget, + path: RepositoryPath, + failure: Throwable, + isFile: Boolean, + ) { + if (failure is CancellationException) throw failure + mutableState.value = CodeBrowserUiState.Error(target, path, failure.toCodeBrowserError(), isFile) + } + + private fun startOperation(block: suspend () -> Unit) { + operation?.cancel() + operation = viewModelScope.launch { block() } + } + + private suspend fun loadAllPages(loader: suspend (Int) -> com.ubisam.ubgitea.domain.model.Page): List { + val result = mutableListOf() + var page = 1 + repeat(MAX_PAGES) { + val response = loader(page) + result += response.items + page = response.nextPage ?: return result.toList() + } + throw ApiFailure.InvalidResponse() + } + + private fun currentTarget(): CodeBrowserTarget? = when (val current = mutableState.value) { + CodeBrowserUiState.Idle -> null + is CodeBrowserUiState.Loading -> current.target + is CodeBrowserUiState.Directory -> current.target + is CodeBrowserUiState.File -> current.target + is CodeBrowserUiState.UnsupportedEntry -> current.target + is CodeBrowserUiState.Error -> current.target + } + + private fun currentPath(): RepositoryPath = when (val current = mutableState.value) { + CodeBrowserUiState.Idle -> RepositoryPath.Root + is CodeBrowserUiState.Loading -> current.path + is CodeBrowserUiState.Directory -> current.path + is CodeBrowserUiState.File -> current.path + is CodeBrowserUiState.UnsupportedEntry -> current.path + is CodeBrowserUiState.Error -> current.path + } + + class Factory(private val application: Application) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + require(modelClass.isAssignableFrom(CodeBrowserViewModel::class.java)) + val tokenVault = AndroidKeystoreTokenVault(application) + @Suppress("UNCHECKED_CAST") + return CodeBrowserViewModel( + GiteaRepositoryBrowserApi( + UBGiteaOAuthDefaults.config, + OkHttpTransport(), + BearerTokenProvider { tokenVault.read()?.accessToken.orEmpty() }, + ), + ) as T + } + } + + private companion object { + const val PAGE_SIZE = 100 + const val MAX_PAGES = 100 + } +} + +internal fun Throwable.toCodeBrowserError(): CodeBrowserError = when (this) { + is ApiFailure.AuthenticationExpired -> CodeBrowserError.AUTHENTICATION_EXPIRED + is ApiFailure.PermissionDenied -> CodeBrowserError.PERMISSION_DENIED + is ApiFailure.NotFound -> CodeBrowserError.NOT_FOUND + is ApiFailure.Network, is java.io.IOException -> CodeBrowserError.OFFLINE + else -> CodeBrowserError.SERVER +} diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt index 4317562..ecad88e 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt @@ -35,7 +35,7 @@ fun RepositoryListScreen( onRetry: () -> Unit, onLogout: () -> Unit, onRepositorySelected: (RepositoryRowUiModel) -> Unit, - onReferenceSelected: (String, ReferenceKind) -> Unit, + onReferenceSelected: (RepositoryRowUiModel, String, ReferenceKind) -> Unit, onReferencesRefresh: () -> Unit, onBackToRepositories: () -> Unit, modifier: Modifier = Modifier, @@ -201,7 +201,7 @@ private fun RepositoryContent( @Composable private fun ReferencesContent( state: RepositoryListUiState.ReferencesContent, - onReferenceSelected: (String, ReferenceKind) -> Unit, + onReferenceSelected: (RepositoryRowUiModel, String, ReferenceKind) -> Unit, onRefresh: () -> Unit, onBack: () -> Unit, onLogout: () -> Unit, @@ -238,7 +238,7 @@ private fun ReferencesContent( item { Text(stringResource(R.string.repositories_branches_empty), Modifier.padding(horizontal = 16.dp)) } } items(state.branches, key = { "branch:${it.name}" }) { reference -> - ReferenceRow(reference, onReferenceSelected) + ReferenceRow(reference) { name, kind -> onReferenceSelected(state.repository, name, kind) } } item { Text( @@ -251,7 +251,7 @@ private fun ReferencesContent( item { Text(stringResource(R.string.repositories_tags_empty), Modifier.padding(horizontal = 16.dp)) } } items(state.tags, key = { "tag:${it.name}" }) { reference -> - ReferenceRow(reference, onReferenceSelected) + ReferenceRow(reference) { name, kind -> onReferenceSelected(state.repository, name, kind) } } } } diff --git a/app/src/main/res/values/strings_codebrowser.xml b/app/src/main/res/values/strings_codebrowser.xml new file mode 100644 index 0000000..61abe75 --- /dev/null +++ b/app/src/main/res/values/strings_codebrowser.xml @@ -0,0 +1,29 @@ + + + 뒤로 + %1$s · %2$s + 브랜치 + 태그 + 커밋 + 최상위 + 이 폴더는 비어 있습니다. + 폴더 + 파일 + 심볼릭 링크 + Git 서브모듈 + 서브모듈 내용은 이 앱에서 직접 열 수 없습니다. + 심볼릭 링크 대상은 이 앱에서 직접 열 수 없습니다. + 코드 복사 + 안전한 표시를 위해 일부만 보여줍니다. + 바이너리 파일이어서 텍스트로 표시하지 않습니다. (%1$d bytes) + UTF-8로 안전하게 읽을 수 없는 파일입니다. (%1$d bytes) + 모바일에서 안전하게 표시하기에 파일이 너무 큽니다. (%1$d bytes) + Git LFS로 관리되는 파일입니다. 원본 크기: %1$d bytes + 네트워크에 연결할 수 없습니다. + 로그인이 만료되었습니다. + 해당 경로를 찾을 수 없습니다. + 이 내용을 볼 권한이 없습니다. + 서버 응답을 처리하지 못했습니다. + 다시 시도 + 다시 로그인 + diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserNavigationTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserNavigationTest.kt new file mode 100644 index 0000000..5d04470 --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserNavigationTest.kt @@ -0,0 +1,21 @@ +package com.ubisam.ubgitea.feature.codebrowser + +import com.ubisam.ubgitea.domain.model.RepositoryPath +import org.junit.Assert.assertEquals +import org.junit.Test + +class CodeBrowserNavigationTest { + @Test + fun `breadcrumbs preserve encoded and unicode names without decoding`() { + val breadcrumbs = CodeBrowserNavigation.breadcrumbs(RepositoryPath.of("src/a%2Fb/한글.kt")) + + assertEquals(listOf("", "src", "src/a%2Fb", "src/a%2Fb/한글.kt"), breadcrumbs.map { it.value }) + } + + @Test + fun `parent navigation reaches root deterministically`() { + assertEquals("a", CodeBrowserNavigation.parent(RepositoryPath.of("a/b")).value) + assertEquals("", CodeBrowserNavigation.parent(RepositoryPath.of("a")).value) + assertEquals("", CodeBrowserNavigation.parent(RepositoryPath.Root).value) + } +} diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModelTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModelTest.kt new file mode 100644 index 0000000..46aa2df --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModelTest.kt @@ -0,0 +1,139 @@ +package com.ubisam.ubgitea.feature.codebrowser + +import com.ubisam.ubgitea.core.network.ApiFailure +import com.ubisam.ubgitea.domain.model.BranchSummary +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.SourceContent +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.model.TreeEntryType +import com.ubisam.ubgitea.domain.repository.GiteaRepositoryBrowser +import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification +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 CodeBrowserViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val target = CodeBrowserTarget( + RepositoryCoordinates.of("owner", "repo"), + RepositoryRef.of("feature/a b"), + CodeReferenceKind.BRANCH, + ) + + @Before fun setMainDispatcher() = Dispatchers.setMain(dispatcher) + @After fun resetMainDispatcher() = Dispatchers.resetMain() + + @Test + fun `directory and file navigation preserve the explicit ref`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser() + val viewModel = CodeBrowserViewModel(browser) + viewModel.open(target) + advanceUntilIdle() + val root = viewModel.state.value as CodeBrowserUiState.Directory + + viewModel.openEntry(root.entries.single()) + advanceUntilIdle() + val directory = viewModel.state.value as CodeBrowserUiState.Directory + viewModel.openEntry(directory.entries.single()) + advanceUntilIdle() + + val file = viewModel.state.value as CodeBrowserUiState.File + assertEquals("feature/a b", file.target.ref.value) + assertEquals("src/한글.kt", file.path.value) + assertEquals("feature/a b", browser.refs.distinct().single()) + assertTrue(file.content is ContentClassification.Text) + } + + @Test + fun `back from file opens parent and root delegates exit`() = runTest(dispatcher.scheduler) { + val viewModel = CodeBrowserViewModel(FakeBrowser()) + viewModel.open(target) + advanceUntilIdle() + viewModel.openEntry((viewModel.state.value as CodeBrowserUiState.Directory).entries.single()) + advanceUntilIdle() + viewModel.openEntry((viewModel.state.value as CodeBrowserUiState.Directory).entries.single()) + advanceUntilIdle() + + assertTrue(viewModel.navigateUp()) + advanceUntilIdle() + assertEquals("src", (viewModel.state.value as CodeBrowserUiState.Directory).path.value) + assertTrue(viewModel.navigateUp()) + advanceUntilIdle() + assertFalse(viewModel.navigateUp()) + } + + @Test + fun `submodule has an explicit non-file state`() = runTest(dispatcher.scheduler) { + val viewModel = CodeBrowserViewModel(FakeBrowser(rootEntryType = TreeEntryType.SUBMODULE)) + viewModel.open(target) + advanceUntilIdle() + viewModel.openEntry((viewModel.state.value as CodeBrowserUiState.Directory).entries.single()) + + assertTrue(viewModel.state.value is CodeBrowserUiState.UnsupportedEntry) + } + + @Test + fun `not found failure is typed and retry preserves file operation`() = runTest(dispatcher.scheduler) { + val browser = FakeBrowser(fileFailure = ApiFailure.NotFound()) + val viewModel = CodeBrowserViewModel(browser) + viewModel.open(target) + advanceUntilIdle() + viewModel.openEntry((viewModel.state.value as CodeBrowserUiState.Directory).entries.single()) + advanceUntilIdle() + viewModel.openEntry((viewModel.state.value as CodeBrowserUiState.Directory).entries.single()) + advanceUntilIdle() + val error = viewModel.state.value as CodeBrowserUiState.Error + assertEquals(CodeBrowserError.NOT_FOUND, error.type) + assertTrue(error.isFile) + + viewModel.retry() + advanceUntilIdle() + assertEquals(2, browser.fileRequests) + } + + private class FakeBrowser( + private val rootEntryType: TreeEntryType = TreeEntryType.DIRECTORY, + private val fileFailure: Throwable? = null, + ) : GiteaRepositoryBrowser { + val refs = mutableListOf() + var fileRequests = 0 + + override suspend fun listRepositories(page: Int, pageSize: Int): Page = Page.of(emptyList(), null) + override suspend fun listBranches(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page = Page.of(emptyList(), null) + override suspend fun listTags(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page = Page.of(emptyList(), null) + override suspend fun listTree(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int, pageSize: Int): Page { + refs += ref.value + val entries = when (path.value) { + "" -> listOf(TreeEntry("src", RepositoryPath.of("src"), rootEntryType, null)) + "src" -> listOf(TreeEntry("한글.kt", RepositoryPath.of("src/한글.kt"), TreeEntryType.FILE, 6)) + else -> emptyList() + } + return Page.of(entries, null) + } + override suspend fun getFile(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath): SourceFile { + refs += ref.value + fileRequests++ + fileFailure?.let { throw it } + return SourceFile(path, ref, SourceContent.from("코드".encodeToByteArray()), "text/plain") + } + override suspend fun listCommits(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath?, page: Int, pageSize: Int): Page = Page.of(emptyList(), null) + } +} diff --git a/docs/development-log.md b/docs/development-log.md index fa9d169..4351910 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -136,3 +136,16 @@ This is the append-only implementation and handoff record shared by Codex and Cl - Known limitations: No device/emulator interaction test was available in this work cycle. The reviewed contract-freeze checkbox remains open until independent integration review. - Next recommended task: TASK-006 directory navigation and bounded source-file viewing using the TASK-004 API and reviewed TASK-006A classifier. - Next owner: Codex for continuous implementation; Claude may later perform the independent review when re-enabled by the product owner. + +## 2026-08-26 — TASK-006 + +- Agent and role: Codex, temporary feature implementer and integrator while Claude work is paused. +- Commit/diff: TASK-006 working diff based on TASK-005 commit `bbbff77`; exact implementation commit recorded after creation. +- Features implemented: Explicit branch/tag tree browsing, deterministic breadcrumbs and back navigation, directory/file selection, bounded selectable/copyable source text, and dedicated binary/LFS/unsupported-encoding/oversized/symlink/submodule fallbacks. +- How it was implemented: A feature-owned immutable ViewModel state machine passes repository coordinates, ref, and validated path to every TASK-004 read-only request. It consumes bounded pages serially, cancels replaced work, retains file-vs-directory retry intent, and delegates byte classification to the reviewed TASK-006A component. The Compose screen keeps repository/ref/path visible and virtualizes directory rows. +- Files changed: Code-browser ViewModel, navigation helper, Compose screen, Korean resources, focused tests, MainActivity feature wiring, selected-reference handoff, and TASK-006 evidence. +- Checks and results: Focused code-browser tests passed; repository-wide debug build, 102 unit tests across 23 suites, and Android lint passed; diff and focused sensitive-pattern/read-only scans passed. +- Review findings and resolutions: Self-review fixed root-loading back behavior, file retry incorrectly becoming a directory request, file breadcrumbs attempting directory loads, configuration-change screen loss, and non-virtualized directory rendering. Independent review is deferred. +- Known limitations: Physical-device interaction has not yet been rerun. TASK-005's reviewed contract-freeze criterion remains open pending later independent review. +- Next recommended task: TASK-009 immutable history/change/diff API contracts, followed by TASK-007 read-only history and change UI. +- Next owner: Codex for the continuous serial stream. diff --git a/tasks/006-code-browser.md b/tasks/006-code-browser.md index 2b426c3..4b0b8bb 100644 --- a/tasks/006-code-browser.md +++ b/tasks/006-code-browser.md @@ -1,8 +1,8 @@ # TASK-006: Browse trees and source code by ref -Status: planned — depends on TASK-005 and TASK-006A -Owner: Claude -Reviewer: Codex +Status: implemented and locally verified — independent review/device check deferred +Owner: Codex +Reviewer: Claude (deferred until independent review is re-enabled) ## Outcome @@ -14,16 +14,28 @@ A user can navigate directories and view source files at an explicit branch, tag - `app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/` - `app/src/main/res/values/strings_codebrowser.xml` - TASK-006 evidence only +- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt` (feature wiring only) +- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListScreen.kt` (selected-ref handoff only) +- `app/src/main/java/com/ubisam/ubgitea/feature/repositories/RepositoryListViewModel.kt` (selected-ref handoff only) -Shared domain, data, Gradle, manifest, and navigation changes must be requested from Codex instead of edited by Claude. +Shared domain, data, Gradle, and manifest contracts remain unchanged. Codex temporarily owns implementation because the product owner paused Claude work while retaining Claude as the future independent reviewer. ## Acceptance criteria -- [ ] Repository, selected ref, and current path remain visible. -- [ ] Directory navigation is deterministic and safely handles encoded names. -- [ ] Text files render with bounded memory use and copy support. -- [ ] Binary, Git LFS, submodule, unsupported encoding, and oversized-file states have explicit fallbacks. -- [ ] Loading, empty, offline, authentication-expired, not-found, and server-error states are localized and accessible. -- [ ] Tests cover ref preservation, path navigation, content classification, and size limits. -- [ ] Resource names use the `codebrowser_` prefix. +- [x] Repository, selected ref, and current path remain visible. +- [x] Directory navigation is deterministic and safely handles encoded names. +- [x] Text files render with bounded memory use and copy support. +- [x] Binary, Git LFS, submodule, unsupported encoding, and oversized-file states have explicit fallbacks. +- [x] Loading, empty, offline, authentication-expired, not-found, and server-error states are localized and accessible. +- [x] Tests cover ref preservation, path navigation, content classification, and size limits. +- [x] Resource names use the `codebrowser_` prefix. - [ ] Evidence records the exact reviewed TASK-005 contract-freeze commit. + +## Evidence + +- Implemented against TASK-005 local completion commit `bbbff77`. It remains a provisional integration base until the deferred independent review closes the contract-freeze criterion. +- Selecting a branch or tag opens its root tree. Every load carries immutable repository coordinates and an explicit ref; no navigation action substitutes the default branch. +- Breadcrumb and parent navigation operate on validated `RepositoryPath` values without decoding encoded-looking names. Directory rows use the server-returned validated path, and directory results are bounded, deduplicated, directory-first, and deterministic. +- Source viewing reuses the reviewed TASK-006A strict classifier with 1 MB input, 200,000-code-point, and 10,000-line display limits. Text is selectable and copyable; truncation, binary, LFS, unsupported UTF-8, oversized, symlink, and submodule states are explicit. +- Focused code-browser tests and repository-wide `assembleDebug testDebugUnitTest lintDebug` passed: 102 unit tests across 23 suites, zero failures. `git diff --check` and focused sensitive-pattern/read-only scans passed. +- Device interaction and independent review remain deferred while Codex owns the serial implementation stream.