feat: browse repository source by ref

This commit is contained in:
최민영 2026-08-26 10:35:20 +09:00
parent bbbff77a5e
commit 16c46a5c54
10 changed files with 744 additions and 26 deletions

View File

@ -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)
}

View File

@ -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<RepositoryPath> {
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("/"))
}
}
}

View File

@ -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
}

View File

@ -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<TreeEntry>,
) : CodeBrowserUiState {
val entries: List<TreeEntry> = 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>(CodeBrowserUiState.Idle)
val state: StateFlow<CodeBrowserUiState> = 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<TreeEntry> { 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 <T> loadAllPages(loader: suspend (Int) -> com.ubisam.ubgitea.domain.model.Page<T>): List<T> {
val result = mutableListOf<T>()
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 <T : ViewModel> create(modelClass: Class<T>): 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
}

View File

@ -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) }
}
}
}

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="codebrowser_back">뒤로</string>
<string name="codebrowser_ref">%1$s · %2$s</string>
<string name="codebrowser_ref_branch">브랜치</string>
<string name="codebrowser_ref_tag">태그</string>
<string name="codebrowser_ref_commit">커밋</string>
<string name="codebrowser_root">최상위</string>
<string name="codebrowser_directory_empty">이 폴더는 비어 있습니다.</string>
<string name="codebrowser_entry_directory">폴더</string>
<string name="codebrowser_entry_file">파일</string>
<string name="codebrowser_entry_symlink">심볼릭 링크</string>
<string name="codebrowser_entry_submodule">Git 서브모듈</string>
<string name="codebrowser_submodule">서브모듈 내용은 이 앱에서 직접 열 수 없습니다.</string>
<string name="codebrowser_symlink">심볼릭 링크 대상은 이 앱에서 직접 열 수 없습니다.</string>
<string name="codebrowser_copy">코드 복사</string>
<string name="codebrowser_text_truncated">안전한 표시를 위해 일부만 보여줍니다.</string>
<string name="codebrowser_binary">바이너리 파일이어서 텍스트로 표시하지 않습니다. (%1$d bytes)</string>
<string name="codebrowser_unsupported_encoding">UTF-8로 안전하게 읽을 수 없는 파일입니다. (%1$d bytes)</string>
<string name="codebrowser_oversized">모바일에서 안전하게 표시하기에 파일이 너무 큽니다. (%1$d bytes)</string>
<string name="codebrowser_lfs">Git LFS로 관리되는 파일입니다. 원본 크기: %1$d bytes</string>
<string name="codebrowser_offline">네트워크에 연결할 수 없습니다.</string>
<string name="codebrowser_auth_expired">로그인이 만료되었습니다.</string>
<string name="codebrowser_not_found">해당 경로를 찾을 수 없습니다.</string>
<string name="codebrowser_permission_denied">이 내용을 볼 권한이 없습니다.</string>
<string name="codebrowser_server_error">서버 응답을 처리하지 못했습니다.</string>
<string name="codebrowser_retry">다시 시도</string>
<string name="codebrowser_sign_in_again">다시 로그인</string>
</resources>

View File

@ -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)
}
}

View File

@ -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<String>()
var fileRequests = 0
override suspend fun listRepositories(page: Int, pageSize: Int): Page<RepositorySummary> = Page.of(emptyList(), null)
override suspend fun listBranches(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<BranchSummary> = Page.of(emptyList(), null)
override suspend fun listTags(repository: RepositoryCoordinates, page: Int, pageSize: Int): Page<TagSummary> = Page.of(emptyList(), null)
override suspend fun listTree(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int, pageSize: Int): Page<TreeEntry> {
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<CommitSummary> = Page.of(emptyList(), null)
}
}

View File

@ -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.

View File

@ -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.