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.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.RepositoryListScreen
|
||||||
import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel
|
import com.ubisam.ubgitea.feature.repositories.RepositoryListViewModel
|
||||||
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
import com.ubisam.ubgitea.ui.theme.UBGiteaTheme
|
||||||
@ -19,24 +30,65 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val viewModel: RepositoryListViewModel by viewModels {
|
private val viewModel: RepositoryListViewModel by viewModels {
|
||||||
RepositoryListViewModel.Factory(application)
|
RepositoryListViewModel.Factory(application)
|
||||||
}
|
}
|
||||||
|
private val codeBrowserViewModel: CodeBrowserViewModel by viewModels {
|
||||||
|
CodeBrowserViewModel.Factory(application)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
val codeBrowserState by codeBrowserViewModel.state.collectAsState()
|
||||||
|
var browsingCode by rememberSaveable {
|
||||||
|
mutableStateOf(codeBrowserState !is CodeBrowserUiState.Idle)
|
||||||
|
}
|
||||||
UBGiteaTheme {
|
UBGiteaTheme {
|
||||||
RepositoryListScreen(
|
if (browsingCode) {
|
||||||
state = state,
|
CodeBrowserScreen(
|
||||||
onSignIn = viewModel::signIn,
|
state = codeBrowserState,
|
||||||
onCancelSignIn = viewModel::cancelSignIn,
|
onEntrySelected = codeBrowserViewModel::openEntry,
|
||||||
onRetry = viewModel::retry,
|
onBreadcrumbSelected = codeBrowserViewModel::openBreadcrumb,
|
||||||
onLogout = viewModel::logout,
|
onRetry = codeBrowserViewModel::retry,
|
||||||
onRepositorySelected = viewModel::selectRepository,
|
onBack = {
|
||||||
onReferenceSelected = viewModel::selectReference,
|
if (!codeBrowserViewModel.navigateUp()) {
|
||||||
onReferencesRefresh = viewModel::refreshReferences,
|
codeBrowserViewModel.close()
|
||||||
onBackToRepositories = viewModel::backToRepositories,
|
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) {
|
LaunchedEffect(viewModel) {
|
||||||
viewModel.browserRequests.collect(::openSystemBrowser)
|
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,
|
onRetry: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
onRepositorySelected: (RepositoryRowUiModel) -> Unit,
|
onRepositorySelected: (RepositoryRowUiModel) -> Unit,
|
||||||
onReferenceSelected: (String, ReferenceKind) -> Unit,
|
onReferenceSelected: (RepositoryRowUiModel, String, ReferenceKind) -> Unit,
|
||||||
onReferencesRefresh: () -> Unit,
|
onReferencesRefresh: () -> Unit,
|
||||||
onBackToRepositories: () -> Unit,
|
onBackToRepositories: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@ -201,7 +201,7 @@ private fun RepositoryContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ReferencesContent(
|
private fun ReferencesContent(
|
||||||
state: RepositoryListUiState.ReferencesContent,
|
state: RepositoryListUiState.ReferencesContent,
|
||||||
onReferenceSelected: (String, ReferenceKind) -> Unit,
|
onReferenceSelected: (RepositoryRowUiModel, String, ReferenceKind) -> Unit,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
@ -238,7 +238,7 @@ private fun ReferencesContent(
|
|||||||
item { Text(stringResource(R.string.repositories_branches_empty), Modifier.padding(horizontal = 16.dp)) }
|
item { Text(stringResource(R.string.repositories_branches_empty), Modifier.padding(horizontal = 16.dp)) }
|
||||||
}
|
}
|
||||||
items(state.branches, key = { "branch:${it.name}" }) { reference ->
|
items(state.branches, key = { "branch:${it.name}" }) { reference ->
|
||||||
ReferenceRow(reference, onReferenceSelected)
|
ReferenceRow(reference) { name, kind -> onReferenceSelected(state.repository, name, kind) }
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
@ -251,7 +251,7 @@ private fun ReferencesContent(
|
|||||||
item { Text(stringResource(R.string.repositories_tags_empty), Modifier.padding(horizontal = 16.dp)) }
|
item { Text(stringResource(R.string.repositories_tags_empty), Modifier.padding(horizontal = 16.dp)) }
|
||||||
}
|
}
|
||||||
items(state.tags, key = { "tag:${it.name}" }) { reference ->
|
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.
|
- 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 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.
|
- 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
|
# TASK-006: Browse trees and source code by ref
|
||||||
|
|
||||||
Status: planned — depends on TASK-005 and TASK-006A
|
Status: implemented and locally verified — independent review/device check deferred
|
||||||
Owner: Claude
|
Owner: Codex
|
||||||
Reviewer: Codex
|
Reviewer: Claude (deferred until independent review is re-enabled)
|
||||||
|
|
||||||
## Outcome
|
## 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/test/java/com/ubisam/ubgitea/feature/codebrowser/`
|
||||||
- `app/src/main/res/values/strings_codebrowser.xml`
|
- `app/src/main/res/values/strings_codebrowser.xml`
|
||||||
- TASK-006 evidence only
|
- 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
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Repository, selected ref, and current path remain visible.
|
- [x] Repository, selected ref, and current path remain visible.
|
||||||
- [ ] Directory navigation is deterministic and safely handles encoded names.
|
- [x] Directory navigation is deterministic and safely handles encoded names.
|
||||||
- [ ] Text files render with bounded memory use and copy support.
|
- [x] Text files render with bounded memory use and copy support.
|
||||||
- [ ] Binary, Git LFS, submodule, unsupported encoding, and oversized-file states have explicit fallbacks.
|
- [x] 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.
|
- [x] 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.
|
- [x] Tests cover ref preservation, path navigation, content classification, and size limits.
|
||||||
- [ ] Resource names use the `codebrowser_` prefix.
|
- [x] Resource names use the `codebrowser_` prefix.
|
||||||
- [ ] Evidence records the exact reviewed TASK-005 contract-freeze commit.
|
- [ ] 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