feat: browse repository source by ref
This commit is contained in:
parent
bbbff77a5e
commit
16c46a5c54
@ -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)
|
||||
}
|
||||
|
||||
@ -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("/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
29
app/src/main/res/values/strings_codebrowser.xml
Normal file
29
app/src/main/res/values/strings_codebrowser.xml
Normal 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>
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user