From 5cc11d8420fb7026ddb199b1d139459d535419dc Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Mon, 28 Sep 2026 15:44:54 +0900 Subject: [PATCH] TASK-057: side pane cards, in-place folder tree, unfolded Home headline - The viewer's side pane is two cards: folder structure, and classes and functions. A button swaps them and the choice is kept. - Folders open in place; files inside them open from the tree. - Unfolded Home: the headline is a card and the columns are equal. Co-Authored-By: Claude Fable 5.1 --- .../java/com/ubisam/ubgitea/MainActivity.kt | 1 + .../ubgitea/core/settings/AppSettingsStore.kt | 12 + .../branchdetail/BranchDetailScreen.kt | 2 + .../feature/codebrowser/CodeBrowserScreen.kt | 5 +- .../codebrowser/CodeBrowserViewModel.kt | 51 +++- .../feature/codebrowser/CodeViewerScreen.kt | 267 +++++++++++++++--- .../ubgitea/feature/codebrowser/FileTree.kt | 67 +++++ .../ubisam/ubgitea/feature/home/HomeScreen.kt | 15 +- .../main/res/drawable/ic_ub_swap_vertical.xml | 21 ++ .../main/res/values/strings_codebrowser.xml | 10 +- .../feature/codebrowser/FileTreeTest.kt | 68 +++++ docs/UBGitea_기술소개.pptx | Bin 725845 -> 725853 bytes docs/development-log.md | 14 + .../057-side-pane-redesign-and-folder-tree.md | 80 ++++++ 14 files changed, 562 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/FileTree.kt create mode 100644 app/src/main/res/drawable/ic_ub_swap_vertical.xml create mode 100644 app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/FileTreeTest.kt create mode 100644 tasks/057-side-pane-redesign-and-folder-tree.md diff --git a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt index 821c44f..af5c207 100644 --- a/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt +++ b/app/src/main/java/com/ubisam/ubgitea/MainActivity.kt @@ -400,6 +400,7 @@ class MainActivity : ComponentActivity() { onSetCodeWordWrap = settingsViewModel::setCodeWordWrapEnabled, onSetCodeFontSize = settingsViewModel::setCodeFontSizeSp, onOpenSibling = codeBrowserViewModel::openSibling, + onToggleFolder = codeBrowserViewModel::toggleFolder, onLoadCounterpart = codeBrowserViewModel::loadCounterpart, historyState = historyState, onCommitSelected = historyViewModel::openCommit, diff --git a/app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt b/app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt index a736e2c..cfb9ced 100644 --- a/app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt +++ b/app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt @@ -37,6 +37,11 @@ interface AppSettingsStore { fun themeMode(): ThemeMode = ThemeMode.SYSTEM fun setThemeMode(mode: ThemeMode) = Unit + // Which half of the viewer's side pane is on top (TASK-057): the folder tree by default, the + // classes and functions when the reader has swapped them. + fun isOutlineAboveFolders(): Boolean = false + fun setOutlineAboveFolders(above: Boolean) = Unit + companion object { const val DEFAULT_CODE_FONT_SIZE_SP = 13 val CODE_FONT_SIZES_SP = listOf(11, 12, 13, 14, 16) @@ -88,7 +93,14 @@ class SharedPreferencesAppSettingsStore(context: Context) : AppSettingsStore { preferences.edit().putString(KEY_THEME_MODE, mode.name).apply() } + override fun isOutlineAboveFolders(): Boolean = preferences.getBoolean(KEY_OUTLINE_ABOVE_FOLDERS, false) + + override fun setOutlineAboveFolders(above: Boolean) { + preferences.edit().putBoolean(KEY_OUTLINE_ABOVE_FOLDERS, above).apply() + } + private companion object { + const val KEY_OUTLINE_ABOVE_FOLDERS = "code_outline_above_folders" const val KEY_HOME_SOURCE = "home_source" const val KEY_THEME_MODE = "theme_mode" const val KEY_CODE_WORD_WRAP = "code_word_wrap_enabled" diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/branchdetail/BranchDetailScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/branchdetail/BranchDetailScreen.kt index 752f692..812a516 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/branchdetail/BranchDetailScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/branchdetail/BranchDetailScreen.kt @@ -55,6 +55,7 @@ fun BranchDetailScreen( onSetCodeWordWrap: (Boolean) -> Unit, onSetCodeFontSize: (Int) -> Unit, onOpenSibling: (RepositoryPath, String?) -> Unit, + onToggleFolder: (RepositoryPath) -> Unit, onLoadCounterpart: () -> Unit, historyState: HistoryUiState, onCommitSelected: (CommitSummary) -> Unit, @@ -86,6 +87,7 @@ fun BranchDetailScreen( onSetWordWrap = onSetCodeWordWrap, onSetFontSize = onSetCodeFontSize, onOpenSibling = onOpenSibling, + onToggleFolder = onToggleFolder, onOpenEntry = onEntrySelected, onLoadCounterpart = onLoadCounterpart, modifier = modifier, diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt index 87a320a..fff3d6d 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt @@ -159,14 +159,15 @@ fun FileViewerScreen( onSetWordWrap: (Boolean) -> Unit, onSetFontSize: (Int) -> Unit, onOpenSibling: (RepositoryPath, String?) -> Unit, + onToggleFolder: (RepositoryPath) -> Unit, onOpenEntry: (TreeEntry) -> Unit, onLoadCounterpart: () -> Unit, modifier: Modifier = Modifier, ) { if (state is CodeBrowserUiState.File && state.content is ContentClassification.Text) { CodeViewerScreen( - state, state.content, preferences, onBack, onSetWordWrap, onSetFontSize, onOpenSibling, onOpenEntry, - onLoadCounterpart, modifier, + state, state.content, preferences, onBack, onSetWordWrap, onSetFontSize, onOpenSibling, onToggleFolder, + onOpenEntry, onLoadCounterpart, modifier, ) return } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt index 3f8fb73..990b126 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt @@ -93,6 +93,9 @@ sealed interface CodeBrowserUiState { // A symbol to bring into view once the file is shown: set when the file was reached by // following a symbol from its counterpart. val focusSymbol: String? = null, + // The folder tree beside the code. It starts as the directory the file was opened from and + // keeps its open folders while the reader moves between files in it. + val tree: FileTree = FileTree(CodeBrowserNavigation.parent(path), siblingEntries), ) : CodeBrowserUiState data class UnsupportedEntry( val target: CodeBrowserTarget, @@ -125,6 +128,7 @@ class CodeBrowserViewModel( val state: StateFlow = mutableState.asStateFlow() private var operation: Job? = null private var counterpartOperation: Job? = null + private val folderOperations = mutableMapOf() fun open(target: CodeBrowserTarget) = loadDirectory(target, RepositoryPath.Root) @@ -149,14 +153,47 @@ class CodeBrowserViewModel( if (index == -1) return val newIndex = index + delta if (newIndex !in siblings.indices) return - loadFile(current.target, siblings[newIndex], current.siblingEntries) + loadFile(current.target, siblings[newIndex], current.siblingEntries, tree = current.tree) } - /** Opens another file of the directory the current file came from, optionally at a symbol. */ + /** + * Opens another file of the folder tree -- of the current file's directory, or of a folder + * opened in the tree -- optionally at a symbol. + */ fun openSibling(path: RepositoryPath, focusSymbol: String? = null) { val current = mutableState.value as? CodeBrowserUiState.File ?: return if (path == current.path) return - loadFile(current.target, path, current.siblingEntries, focusSymbol) + val directory = current.tree.entriesOf(CodeBrowserNavigation.parent(path)) ?: current.siblingEntries + loadFile(current.target, path, directory, focusSymbol, current.tree) + } + + /** Opens a folder of the tree beside the code in place, or closes it if it is open. */ + fun toggleFolder(folder: RepositoryPath) { + val current = mutableState.value as? CodeBrowserUiState.File ?: return + folderOperations.remove(folder)?.cancel() + if (folder in current.tree.folders) { + mutableState.value = current.copy(tree = current.tree.without(folder)) + return + } + mutableState.value = current.copy(tree = current.tree.with(folder, FolderUiState.Loading)) + folderOperations[folder] = viewModelScope.launch { + val result = try { + val entries = withContext(defaultDispatcher) { + loadAllPages { page -> browser.listTree(current.target.repository, current.target.ref, folder, page, PAGE_SIZE) } + .distinctBy(TreeEntry::path) + .sortedWith(LISTING_ORDER) + } + FolderUiState.Loaded(entries) + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + FolderUiState.Failed + } + // Only if the folder is still waiting: it may have been closed, or the file left. + val latest = mutableState.value as? CodeBrowserUiState.File ?: return@launch + if (latest.tree.folders[folder] == FolderUiState.Loading) { + mutableState.value = latest.copy(tree = latest.tree.with(folder, result)) + } + } } /** Fetches the current file's counterpart so both can be shown side by side. */ @@ -207,6 +244,8 @@ class CodeBrowserViewModel( operation?.cancel() operation = null counterpartOperation?.cancel() + folderOperations.values.forEach(Job::cancel) + folderOperations.clear() mutableState.value = CodeBrowserUiState.Idle } @@ -234,7 +273,7 @@ class CodeBrowserViewModel( } } }.distinctBy(TreeEntry::path) - .sortedWith(compareBy { it.type != TreeEntryType.DIRECTORY }.thenBy { it.name.lowercase() }) + .sortedWith(LISTING_ORDER) } mutableState.value = CodeBrowserUiState.Directory(target, path, entries) } catch (failure: Throwable) { @@ -247,6 +286,7 @@ class CodeBrowserViewModel( path: RepositoryPath, siblingEntries: List = emptyList(), focusSymbol: String? = null, + tree: FileTree? = null, ) = startOperation { counterpartOperation?.cancel() mutableState.value = CodeBrowserUiState.Loading(target, path, isFile = true) @@ -264,6 +304,8 @@ class CodeBrowserViewModel( siblingEntries = siblingEntries, counterpartPath = counterpartOf(path, siblingFiles), focusSymbol = focusSymbol, + // A folder still being fetched would never hear back in the new state. + tree = tree?.settled() ?: FileTree(CodeBrowserNavigation.parent(path), siblingEntries), ) } catch (failure: Throwable) { handleFailure(target, path, failure, isFile = true) @@ -332,6 +374,7 @@ class CodeBrowserViewModel( private companion object { const val PAGE_SIZE = 100 const val MAX_PAGES = 100 + val LISTING_ORDER = compareBy { it.type != TreeEntryType.DIRECTORY }.thenBy { it.name.lowercase() } } } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt index f567188..acbd407 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt @@ -1,5 +1,6 @@ package com.ubisam.ubgitea.feature.codebrowser +import android.app.Application import androidx.activity.compose.BackHandler import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -70,6 +71,8 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.input.pointer.PointerEventPass @@ -94,6 +97,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.ubisam.ubgitea.R +import com.ubisam.ubgitea.core.settings.AppSettingsSession import com.ubisam.ubgitea.core.settings.AppSettingsStore import com.ubisam.ubgitea.domain.model.RepositoryPath import com.ubisam.ubgitea.domain.model.TreeEntry @@ -141,7 +145,8 @@ private enum class ViewerLayout { } private val CompactHeightBreakpoint = 480.dp -private val SidePaneWidth = 268.dp +private val SidePaneWidth = 288.dp +private val TreeIndent = 14.dp private val NarrowSidePaneWidth = 220.dp private val NarrowWideBreakpoint = 700.dp @@ -160,11 +165,20 @@ fun CodeViewerScreen( onSetWordWrap: (Boolean) -> Unit, onSetFontSize: (Int) -> Unit, onOpenSibling: (RepositoryPath, String?) -> Unit, + onToggleFolder: (RepositoryPath) -> Unit, onOpenEntry: (TreeEntry) -> Unit, onLoadCounterpart: () -> Unit, modifier: Modifier = Modifier, ) { val document = rememberCodeDocument(state.path, text.renderedText) + // Which half of the side pane is on top is the reader's choice and is kept. It is read and + // written here rather than passed down through every screen above: nothing else uses it. + val settings = AppSettingsSession.store(LocalContext.current.applicationContext as Application) + var outlineFirst by remember { mutableStateOf(settings.isOutlineAboveFolders()) } + val swapPanes = { + outlineFirst = !outlineFirst + settings.setOutlineAboveFolders(outlineFirst) + } val listState = remember(state.path) { LazyListState() } val scope = rememberCoroutineScope() val firstVisibleLine by rememberFirstVisibleLine(listState) @@ -279,7 +293,8 @@ fun CodeViewerScreen( drawerContent = { if (!sidePaneIsShown) { ModalDrawerSheet( - drawerContainerColor = MaterialTheme.colorScheme.surface, + drawerContainerColor = MaterialTheme.colorScheme.background, + drawerShape = RoundedCornerShape(topEnd = 28.dp, bottomEnd = 28.dp), modifier = Modifier.width(drawerWidth), ) { SidePane( @@ -293,6 +308,9 @@ fun CodeViewerScreen( onOpenSibling(it, null) }, onOpenEntry = onOpenEntry, + onToggleFolder = onToggleFolder, + outlineFirst = outlineFirst, + onSwap = swapPanes, onSymbolSelected = { scope.launch { drawerState.close() } goToSymbol(it) @@ -364,6 +382,9 @@ fun CodeViewerScreen( onToggleCollapsed = toggleCollapsed, onOpenFile = { onOpenSibling(it, null) }, onOpenEntry = onOpenEntry, + onToggleFolder = onToggleFolder, + outlineFirst = outlineFirst, + onSwap = swapPanes, onSymbolSelected = { goToSymbol(it) }, modifier = Modifier.width(sidePaneWidth).fillMaxHeight(), ) @@ -971,31 +992,99 @@ private fun SidePane( collapsed: Set, onToggleCollapsed: (CodeSymbol) -> Unit, onOpenFile: (RepositoryPath) -> Unit, + onToggleFolder: (RepositoryPath) -> Unit, onOpenEntry: (TreeEntry) -> Unit, onSymbolSelected: (CodeSymbol) -> Unit, + outlineFirst: Boolean, + onSwap: () -> Unit, modifier: Modifier = Modifier, ) { - val tree = remember(document.symbols, collapsed) { outlineTree(document.symbols, collapsed) } - val current = currentIn(tree, document.symbols, currentLine) - LazyColumn(modifier.background(MaterialTheme.colorScheme.surface), contentPadding = PaddingValues(vertical = 8.dp)) { - if (state.siblingEntries.isNotEmpty()) { - item(key = "files-title") { SidePaneTitle(stringResource(R.string.codeviewer_files_title)) } - items(state.siblingEntries, key = { "file:" + it.path.value }) { entry -> - val isFile = entry.type == TreeEntryType.FILE - val isCurrent = entry.path == state.path - SidePaneRow( - icon = if (entry.type == TreeEntryType.DIRECTORY) R.drawable.ic_ub_folder else R.drawable.ic_ub_file, - text = entry.name, - isCurrent = isCurrent, - accent = entry.type == TreeEntryType.DIRECTORY, - onClick = { if (isFile) onOpenFile(entry.path) else onOpenEntry(entry) }, - ) + val outline = remember(document.symbols, collapsed) { outlineTree(document.symbols, collapsed) } + val rows = remember(state.tree) { state.tree.rows() } + val folders: @Composable (Modifier) -> Unit = { FolderCard(state, rows, onOpenFile, onToggleFolder, onOpenEntry, it) } + val symbols: @Composable (Modifier) -> Unit = { + SymbolCard(document, outline, currentLine, collapsed, onToggleCollapsed, onSymbolSelected, it) + } + Column(modifier.background(MaterialTheme.colorScheme.background).padding(horizontal = 10.dp, vertical = 10.dp)) { + val half = Modifier.weight(1f).fillMaxWidth() + when { + // A file that was not opened from a listing has no folder to show. + rows.isEmpty() -> symbols(half) + else -> { + if (outlineFirst) symbols(half) else folders(half) + SwapButton(onSwap) + if (outlineFirst) folders(half) else symbols(half) } - item(key = "divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp), color = MaterialTheme.colorScheme.outlineVariant) } } - if (tree.isNotEmpty()) { - item(key = "outline-title") { SidePaneTitle(stringResource(R.string.codeviewer_outline_title)) } - items(tree, key = { "symbol:" + it.line + ":" + it.name }) { symbol -> + } +} + +@Composable +private fun FolderCard( + state: CodeBrowserUiState.File, + rows: List, + onOpenFile: (RepositoryPath) -> Unit, + onToggleFolder: (RepositoryPath) -> Unit, + onOpenEntry: (TreeEntry) -> Unit, + modifier: Modifier, +) { + PaneCard( + icon = R.drawable.ic_ub_folder, + title = stringResource(R.string.codeviewer_files_title), + caption = state.tree.root.value.ifEmpty { stringResource(R.string.codebrowser_root) }, + modifier = modifier, + ) { + val listState = rememberLazyListState() + // Open on the file being read, not on the top of a long folder. + LaunchedEffect(state.path) { + val index = rows.indexOfFirst { it is FileTreeRow.Entry && it.entry.path == state.path } + if (index > 0) listState.scrollToItem((index - 2).coerceAtLeast(0)) + } + LazyColumn(state = listState, contentPadding = PaddingValues(vertical = 6.dp)) { + items(rows, key = { if (it is FileTreeRow.Entry) "e:" + it.entry.path.value else "n:" + (it as FileTreeRow.Note).folder.value }) { row -> + when (row) { + is FileTreeRow.Entry -> FolderTreeRow(row, isCurrent = row.entry.path == state.path) { + when (row.entry.type) { + TreeEntryType.DIRECTORY -> onToggleFolder(row.entry.path) + TreeEntryType.FILE -> onOpenFile(row.entry.path) + else -> onOpenEntry(row.entry) + } + } + is FileTreeRow.Note -> FolderNoteRow(row) + } + } + } + } +} + +@Composable +private fun SymbolCard( + document: CodeDocument, + outline: List, + currentLine: Int, + collapsed: Set, + onToggleCollapsed: (CodeSymbol) -> Unit, + onSymbolSelected: (CodeSymbol) -> Unit, + modifier: Modifier, +) { + val current = currentIn(outline, document.symbols, currentLine) + PaneCard( + icon = R.drawable.ic_ub_list, + title = stringResource(R.string.codeviewer_symbols_title), + caption = document.name, + modifier = modifier, + ) { + if (outline.isEmpty()) { + Text( + stringResource(R.string.codeviewer_symbols_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(14.dp), + ) + return@PaneCard + } + LazyColumn(contentPadding = PaddingValues(vertical = 6.dp)) { + items(outline, key = { "symbol:" + it.line + ":" + it.name }) { symbol -> SymbolRow( symbol = symbol, isCurrent = symbol == current, @@ -1009,41 +1098,139 @@ private fun SidePane( } } +/** One half of the side pane: a white card on the page color, named at the top. */ @Composable -private fun SidePaneTitle(text: String) { - Text( - text, - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp).semantics { heading() }, - ) +private fun PaneCard( + @DrawableRes icon: Int, + title: String, + caption: String, + modifier: Modifier, + content: @Composable () -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(18.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = modifier, + ) { + Column { + Row( + Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 10.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + Modifier.size(28.dp).background(MaterialTheme.colorScheme.primaryContainer, CircleShape), + contentAlignment = Alignment.Center, + ) { + UBIcon(icon, null, size = 16.dp, tint = MaterialTheme.colorScheme.primary) + } + Text( + title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + modifier = Modifier.semantics { heading() }, + ) + Text( + caption, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.StartEllipsis, + modifier = Modifier.weight(1f), + ) + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + content() + } + } } @Composable -private fun SidePaneRow(@DrawableRes icon: Int, text: String, isCurrent: Boolean, accent: Boolean, onClick: () -> Unit) { +private fun SwapButton(onSwap: () -> Unit) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Surface( + onClick = onSwap, + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Row( + Modifier.padding(horizontal = 14.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + UBIcon(R.drawable.ic_ub_swap_vertical, null, size = 16.dp) + Text(stringResource(R.string.codeviewer_pane_swap), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + } + } + } +} + +@Composable +private fun FolderTreeRow(row: FileTreeRow.Entry, isCurrent: Boolean, onClick: () -> Unit) { + val isFolder = row.entry.type == TreeEntryType.DIRECTORY + val folderState = if (!isFolder) null else stringResource(if (row.isOpen) R.string.codeviewer_folder_open else R.string.codeviewer_folder_closed) Row( Modifier.fillMaxWidth() - .background(if (isCurrent) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface) + .padding(horizontal = 6.dp) + .clip(RoundedCornerShape(10.dp)) + .background(if (isCurrent) MaterialTheme.colorScheme.primaryContainer else Color.Transparent) .clickable(role = Role.Button, onClick = onClick) - .semantics { selected = isCurrent } + .semantics { + selected = isCurrent + if (folderState != null) stateDescription = folderState + } .heightIn(min = 44.dp) - .padding(horizontal = 14.dp), + .padding(start = 6.dp + TreeIndent * row.depth, end = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - UBIcon(icon, null, size = 18.dp, tint = if (accent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) + if (isFolder) { + UBIcon( + if (row.isOpen) R.drawable.ic_ub_chevron_down else R.drawable.ic_ub_chevron_right, + null, + size = 16.dp, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Spacer(Modifier.size(16.dp)) + } + UBIcon( + if (isFolder) R.drawable.ic_ub_folder else R.drawable.ic_ub_file, + null, + size = 18.dp, + tint = if (isFolder || isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) Text( - text, + row.entry.name, fontFamily = FontFamily.Monospace, fontSize = 13.sp, - fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium, + fontWeight = if (isCurrent || isFolder) FontWeight.Bold else FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } +@Composable +private fun FolderNoteRow(row: FileTreeRow.Note) { + Text( + stringResource( + when (row.note) { + FolderNote.LOADING -> R.string.codeviewer_folder_loading + FolderNote.FAILED -> R.string.codeviewer_folder_failed + FolderNote.EMPTY -> R.string.codeviewer_folder_empty + }, + ), + style = MaterialTheme.typography.bodySmall, + color = if (row.note == FolderNote.FAILED) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 34.dp + TreeIndent * row.depth, end = 12.dp, top = 8.dp, bottom = 8.dp), + ) +} + /** * The row to mark as "you are here": the innermost enclosing symbol that is on screen in the tree. * Inside a folded class that is the class itself, since its methods are hidden. @@ -1073,11 +1260,13 @@ private fun SymbolRow( val base: Dp = if (compact) 14.dp else 20.dp Row( Modifier.fillMaxWidth() - .background(if (isCurrent) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface) + // In the side pane a row is a rounded pill inside its card; in the sheet it spans it. + .then(if (compact) Modifier.padding(horizontal = 6.dp).clip(RoundedCornerShape(10.dp)) else Modifier) + .background(if (isCurrent) MaterialTheme.colorScheme.primaryContainer else Color.Transparent) .clickable(role = Role.Button, onClick = onClick) .semantics { selected = isCurrent } .heightIn(min = if (compact) 44.dp else 48.dp) - .padding(start = (base - 8.dp) + step * symbol.depth, end = if (compact) 4.dp else base), + .padding(start = (if (compact) 0.dp else base - 8.dp) + step * symbol.depth, end = if (compact) 4.dp else base), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/FileTree.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/FileTree.kt new file mode 100644 index 0000000..c3562ef --- /dev/null +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/FileTree.kt @@ -0,0 +1,67 @@ +package com.ubisam.ubgitea.feature.codebrowser + +import com.ubisam.ubgitea.domain.model.RepositoryPath +import com.ubisam.ubgitea.domain.model.TreeEntry +import com.ubisam.ubgitea.domain.model.TreeEntryType + +/** What is known about a folder that has been opened in the viewer's folder tree. */ +sealed interface FolderUiState { + data object Loading : FolderUiState + data class Loaded(val entries: List) : FolderUiState + data object Failed : FolderUiState +} + +/** + * The folder tree beside the code (TASK-057): the directory the file was opened from, and under it + * whichever folders have been opened since. A folder is in [folders] exactly while it is open. + */ +data class FileTree( + val root: RepositoryPath, + val entries: List, + val folders: Map = emptyMap(), +) { + /** The listing of [directory], if this tree holds it. */ + fun entriesOf(directory: RepositoryPath): List? = + if (directory == root) entries else (folders[directory] as? FolderUiState.Loaded)?.entries + + fun with(folder: RepositoryPath, state: FolderUiState): FileTree = copy(folders = folders + (folder to state)) + + /** Closes [folder], and with it every folder that was open inside it. */ + fun without(folder: RepositoryPath): FileTree = + copy(folders = folders.filterKeys { it != folder && !it.value.startsWith(folder.value + "/") }) + + /** Without the folders still being fetched: what can be carried over to another file. */ + fun settled(): FileTree = copy(folders = folders.filterValues { it != FolderUiState.Loading }) +} + +enum class FolderNote { LOADING, FAILED, EMPTY } + +sealed interface FileTreeRow { + val depth: Int + + data class Entry(val entry: TreeEntry, override val depth: Int, val isOpen: Boolean) : FileTreeRow + + /** Stands where an open folder's contents would be, when there are none to show. */ + data class Note(val folder: RepositoryPath, override val depth: Int, val note: FolderNote) : FileTreeRow +} + +/** The tree as the rows of a list, top to bottom, each open folder followed by what is in it. */ +fun FileTree.rows(): List = buildList { addRows(this@rows, entries, depth = 0) } + +private fun MutableList.addRows(tree: FileTree, entries: List, depth: Int) { + for (entry in entries) { + val folder = if (entry.type == TreeEntryType.DIRECTORY) tree.folders[entry.path] else null + add(FileTreeRow.Entry(entry, depth, isOpen = folder != null)) + when (folder) { + null -> Unit + FolderUiState.Loading -> add(FileTreeRow.Note(entry.path, depth + 1, FolderNote.LOADING)) + FolderUiState.Failed -> add(FileTreeRow.Note(entry.path, depth + 1, FolderNote.FAILED)) + is FolderUiState.Loaded -> + if (folder.entries.isEmpty()) add(FileTreeRow.Note(entry.path, depth + 1, FolderNote.EMPTY)) + // A depth limit so that a listing that names itself cannot recurse without end. + else if (depth < MAX_TREE_DEPTH) addRows(tree, folder.entries, depth + 1) + } + } +} + +private const val MAX_TREE_DEPTH = 32 diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt index a893889..075b744 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt @@ -156,13 +156,14 @@ private fun HomeContent( // Unfolded: the overview stays put on the left while the timeline scrolls on the right, // so the whole picture is on screen at once. Row(Modifier.fillMaxSize()) { - LazyColumn(Modifier.weight(0.46f).fillMaxHeight(), contentPadding = PaddingValues(bottom = 24.dp)) { + LazyColumn(Modifier.weight(0.5f).fillMaxHeight(), contentPadding = PaddingValues(bottom = 24.dp)) { overview(state, vertical = true, onNavigate, onSelectSource, showToday) } LazyColumn( - Modifier.weight(0.54f).fillMaxHeight(), + Modifier.weight(0.5f).fillMaxHeight(), state = timelineState, - contentPadding = PaddingValues(bottom = 24.dp), + // Starts level with the headline card on the left. + contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp), ) { timeline(state, onNavigate, onSelectFilter, onSetTodayOnly, onLoadMore) } @@ -184,10 +185,14 @@ private fun LazyListScope.overview( onShowToday: (ActivityCategory) -> Unit, ) { item(key = "headline") { + // On a phone the headline hangs from the top bar, edge to edge. Unfolded it has only the + // left column to hang from, and a white block that stops at the middle of the screen + // reads as a different design from the timeline beside it -- so there it is a card on the + // page like every other block in both columns. Surface( color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp), - modifier = Modifier.fillMaxWidth(), + shape = if (vertical) RoundedCornerShape(24.dp) else RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp), + modifier = if (vertical) Modifier.fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = 16.dp) else Modifier.fillMaxWidth(), ) { Column(Modifier.padding(bottom = 20.dp)) { TabTitle( diff --git a/app/src/main/res/drawable/ic_ub_swap_vertical.xml b/app/src/main/res/drawable/ic_ub_swap_vertical.xml new file mode 100644 index 0000000..f15cde2 --- /dev/null +++ b/app/src/main/res/drawable/ic_ub_swap_vertical.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/app/src/main/res/values/strings_codebrowser.xml b/app/src/main/res/values/strings_codebrowser.xml index 7dba76c..55f75e1 100644 --- a/app/src/main/res/values/strings_codebrowser.xml +++ b/app/src/main/res/values/strings_codebrowser.xml @@ -66,7 +66,15 @@ 타입 메서드 필드 - 파일 + 폴더 구조 + 클래스 · 함수 + 이 파일에는 표시할 클래스나 함수가 없습니다. + 위아래 바꾸기 + 펼쳐짐 + 접힘 + 불러오는 중… + 불러오지 못했습니다. 폴더를 다시 눌러 주세요. + 비어 있음 짝 파일 선언 구현 diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/FileTreeTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/FileTreeTest.kt new file mode 100644 index 0000000..5445914 --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/FileTreeTest.kt @@ -0,0 +1,68 @@ +package com.ubisam.ubgitea.feature.codebrowser + +import com.ubisam.ubgitea.domain.model.RepositoryPath +import com.ubisam.ubgitea.domain.model.TreeEntry +import com.ubisam.ubgitea.domain.model.TreeEntryType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class FileTreeTest { + private fun dir(path: String) = TreeEntry(path.substringAfterLast('/'), RepositoryPath.of(path), TreeEntryType.DIRECTORY, null) + private fun file(path: String) = TreeEntry(path.substringAfterLast('/'), RepositoryPath.of(path), TreeEntryType.FILE, 1L) + + private val tree = FileTree(RepositoryPath.of("src"), listOf(dir("src/core"), dir("src/ui"), file("src/Main.cpp"))) + + private fun describe(tree: FileTree) = tree.rows().map { + when (it) { + is FileTreeRow.Entry -> "${it.depth}:${it.entry.name}" + if (it.isOpen) "+" else "" + is FileTreeRow.Note -> "${it.depth}:<${it.note}>" + } + } + + @Test + fun `a closed tree is its entries`() { + assertEquals(listOf("0:core", "0:ui", "0:Main.cpp"), describe(tree)) + } + + @Test + fun `an open folder is followed by what is in it, one level in`() { + val open = tree + .with(RepositoryPath.of("src/core"), FolderUiState.Loaded(listOf(dir("src/core/net"), file("src/core/A.h")))) + .with(RepositoryPath.of("src/core/net"), FolderUiState.Loaded(listOf(file("src/core/net/B.h")))) + + assertEquals(listOf("0:core+", "1:net+", "2:B.h", "1:A.h", "0:ui", "0:Main.cpp"), describe(open)) + } + + @Test + fun `a folder with nothing to show says why`() { + val open = tree + .with(RepositoryPath.of("src/core"), FolderUiState.Loading) + .with(RepositoryPath.of("src/ui"), FolderUiState.Loaded(emptyList())) + + assertEquals(listOf("0:core+", "1:", "0:ui+", "1:", "0:Main.cpp"), describe(open)) + assertEquals(listOf("0:core+", "1:", "0:ui", "0:Main.cpp"), describe(tree.with(RepositoryPath.of("src/core"), FolderUiState.Failed))) + } + + @Test + fun `closing a folder closes the folders inside it but not one that only shares its prefix`() { + val open = tree + .with(RepositoryPath.of("src/core"), FolderUiState.Loaded(listOf(dir("src/core/net")))) + .with(RepositoryPath.of("src/core/net"), FolderUiState.Loaded(emptyList())) + .with(RepositoryPath.of("src/core2"), FolderUiState.Loaded(emptyList())) + + assertEquals(setOf(RepositoryPath.of("src/core2")), open.without(RepositoryPath.of("src/core")).folders.keys) + } + + @Test + fun `entries are found for the root and for loaded folders only`() { + val open = tree + .with(RepositoryPath.of("src/core"), FolderUiState.Loaded(listOf(file("src/core/A.h")))) + .with(RepositoryPath.of("src/ui"), FolderUiState.Loading) + + assertEquals(3, open.entriesOf(RepositoryPath.of("src"))?.size) + assertEquals(1, open.entriesOf(RepositoryPath.of("src/core"))?.size) + assertNull(open.entriesOf(RepositoryPath.of("src/ui"))) + assertEquals(setOf(RepositoryPath.of("src/core")), open.settled().folders.keys) + } +} diff --git a/docs/UBGitea_기술소개.pptx b/docs/UBGitea_기술소개.pptx index 6650630218de3803f032484e7e63cce9dd27dd1f..e3b577ff0cb21c14e5a8c7bcdce22965684fa3a1 100644 GIT binary patch delta 3377 zcmZWs4OCTC7Jm2M_jBJlJbVxAq4H$Vj7*<`3Zj4^&eBpaWJXiT7-rO>GNm-iG?bDw z6_Fl#M#ub3Q)97cL31fXbeU@=M^aS$$sxri*Ggal{NW$w?!7NrYgp^Oea|`H{=RRY zbKW_ZnnT{&LtejGiHez-;BQA%f!|PB|BiSJzUPMO-@c`?yCB~Q?R}3!J6>tu+xA}Va^j;Ofj=dE6`ru35u8)QplWK7f; zUu5hosU;JSv2R% zUeVOXSn0B(H6T?wt*GK@9A7_nRIx}tBMmJbb^jI)xmy2a1<3c^E|hQXZCQxWy>r!Y zgldjO5@NOH#S((G<4Yt&Yt>7otmpJ}ZTcCBk*lobyL!=;i0@(+(Y92IE6r}@7OkpU z`lE!@l`}STbg9k0DBS=XT2zgcHwOosEKz$%w_$`#y63$z>3%d$Uq*nJM9JwX2)|l1 z@*f0`S#Q8YJ}}_*`3AhFK(6`|Xc5i`VOm=2#@?o0nmnF``e^Zrc%^4}Lltj*A1$Mc1c(D>pHeH`QIgD&J z{#~J}s>>CsYImhVReg3+33(Cns)w8X(B_R1<|2EghCO7eb~j z&1z%@rF6bcnN!lOM&^lODQ7TKblEv89n@1IiRuh%7-Y6*SQC&U?ra~$O@8?&qImP$ zJ_^NtdL@Y`)mTY<^cCw&%zP)tM)R>L&Q?WUrvyw7BE?KUeU3|FY<+8<&AC;~oN9yL zy0&bp?dy$L@chQg3|tf~?Y!*=+W$K{-Mh#~<^`Kk$}{hV*ogS3gEKApn(Z8}rNR^9n;7H zy|v+^a&KtkmaiJV*cIBiV}t%FtUppF_C%_uvAXOHJAb0;@}pFvIyIv9Ijm01idHS! zp=kAQG!2UY!!R3SOm+k;&x`kbV2QjpX!JQzUZDLUn^O{%$#L6Z% z9%4fa^N7G^2FJ9BXl9cjE^B772FN4+*~|tRgP!dzYzRDGzpH1(w=lQ?A$hDzGlvk=vGf+n^b&}%0&Ss?WDb{3A=Zw1w8B0}PBA$PDC$a=DavT{0D z612~CkYP_Jv99Q337C2%-5DZM+I8WJoh$;o@PwCj`pxx#S^w&uC02dt4`x0FyK0w@ zzbH}^y=$M@(9BJCeo6#Cn~A_{A+1acX;oUV5aF=$xv+{aSZVyImCr=h@>}QE6YC>3 zgVnZD%_7!?He%gtGi;S7mt7QKYrAT=!s`ZIC952Nt2ru3eW#LCh@GswY9}kl>@?n= z8Kl~K`T98zwXR?WE8Zi>dWspY@EXC%a&dC?3^xe2-afjC2#Pr$17+56(hBz@tqp$U z<*Xlh33eDufR_TY<~n$+1KtXMxei2?gGb{e;UNqTmA^!(zPh~wgnk(7?<4-)56AI9 z2D?gTv?>b9%pS4TpSy4j&ty=L;>8Jn++?E5pL@)+C#xo3%3!zutVBufZ|ZxA7nA#O zkCJ)-bb>@yKMrrtA|E<44yvNgtG}jy(~ra3x2V;7<>(@D(w`3%gPlBR2o&(}G&h;t z_^(0VS5mPm{#>jEi6u_%2Lx|Bby~+c(kjsj&@vwJSk9qpLs4<@035@k9?Fs5xJtU8 zURAj`W)Nn&xJTM3@?5kRDxsM!{_Ua_8yLVnwv0~Il$p6goNTv*h|~ZwlA(_%odfK` xInX`|*?tG51YYQ#wC|3|1fQV3Dlje+Hv)Lr|A7Yl1~gR+3*;lMo+g;w{{cuQm=*v4 delta 3357 zcmZ8k3s4nD7JW1GK1288^T2wjpn|^`d?E;l0-~5zS5c9uQHUamF=FYWaW`rr{<0dQ z2_im7yTm92iN;WB#L5^G0=rq)T33u1jlYT?G`rd5xywgI{yX>H!75|b%$@E&_ndRP zo9^dNpSrGm>ax#DR?N%<|4O6s?E_`~9Weyn^Fs8uJ6m?<=lMaqkHXiu=o#V>@;IbV+Z!59Is|-S2K#s32^!2L*ok<0i=m1RP3c z+SDMEX=;#2x*|C%)@+*}1i*{M1u{AaFDl9xx0~gm6?;k#Sh4HtxFoY^%#&ReOOD(I zm5RybmA}QI?W0B<6)ESWffXYj)!~q%RD9gJxiKS)==mKJkG%AJdd220EV^r3meWb~|Ebjc+Aag@HI06!ih zXQm?jTKVv^1drQp!29no;PrV1yenU>Sp<1CjWR=m>&-d_338V9K82176F3vyjdHB! z7cIXfxx;;}VsD2$0P?=-kfjXx_$FO33SH)Z`W6*BK+;QWU7!>Z;i{ID!$_!|U98}G zXor_5L1fo)C~+{^eek71)zzL-sJi`C3RU;TpOyYgL9KR}*^YK^hnZ_&*|ejP<^UOY z%Ciy2(vrflD7abq3;`*k+}w@%dKP3$GFR7(R0&Grnzb4WAulX z*U}(q-~aR^G_7dx+vW(EmpIGjo$z5%hwTmIsmSHtA z15?}I#!PYCOP}N^nbwaey?Su!IZ6*)YNhnnENc>`C!Fs|zr%n0Hwmx3U^RpluQP99 zrby)ag0DPpH5Oc0uUg@R(88};XCTprI2%pP`w6xh(w*o(HIQ_3U$Bwx%1O4r?!-FN z9#mz~#AjTz-J=a()b89z7FJ!d85U-~)89rUldkI|IE^D(I=1t4lZmI(G)}u|E9fO^ zTNIZ#K1ZIc&1$fn_mW4&x zK5wfy6s?|tdB~1Ysr;N+m3BsQqDng>bAoE7W8>t~MR`#3Ky6p4dY@`O(xk;g%|o@~ zGPQ&z?%wQ)XQ5`7_GYDJnqlF?8|vl$(6gddwc?ikL%W&}hq-3!K15gpgA-n4 zG_V+mg$*nM;)-S#EbcY1zR>7yV6hNaHR+vm8d)eb);6;Er^t(qghYheM2KSmpVGwO zGK1W45cSR%AnKgK!mpXZX->pbn<-6~9{sel+UP{C?q=c|_K+n2;`N7YBE%yP*?5Tc z56S1?M=Sw1AY4x(>k;*qJYva!`tKt)0b;~simM-!vo9VKOOw%#Y+*A1x4DJQhj^of zB|+@>giVLI{0U1&6})dUJD5{ke!}8l$ghYhw|Zoy>F2h&P_-+0AWiBzBFpr(GAQHKCofa@xsORXf@8?jV8m4iY%gVF=(w zf&z|CL*V|Yd1FXmQ70P-c6N7C)-RpJI=qWbN8y0Fw?8LnVVB`$Tu#A{yJf%jmxZl?kv?Siz}Oygjf~N#ay0-WyozOo`*P?vyyLK#jz4 z1%hNwD-h}V?r-E|t4z>pnNLLD|EfK4S`>5c6d?+a#IBT6oxMbos&}naIPUfmh0lZP z+Z2u)D$&ew>2u8_bkj`Z(H4XCmwr1I6KjFRU=4ddXacd`vlvB9esybaao4O1MOr!j zv1D5fg74Rr*Ac-5D~|+i$wpc!Hqtt5BQG8sd5Kp!tpwf_$XccnYn^Ja9tg{Skys~s z8Mg4oz+&^g3|n|tAi-@fA_!)rmB~o!7$Yy8jJ&+WX}pN@890TjtNVphu@<|*iZ=nW z&bAZlZo6R%?*Uwc2D@PkuK~0*-ka9o4R1cr8-DUVJ^95IZ;t1Vj}H%4(qmNj-QwP2 zk`H&nd@Y2|tXNg#pEL)Hy*}K5&V2v0XBp195}T-sREdX*4T&ljDL&jj0O+gBvfjM( zs>uY86Mu~Nyrzp#Up@p`@DvBf@{6US*pK%Sn|!$whwv~5=SpUoD(<@EAfcb?RuSjR z?Fhh=9VS8QC~98UP$5R(a>XT^UbUiRZd5H$M8p15?`WP&HO#~enBIN(RK zRqLl$zrWTnpx*{opIOP0+{@JS{|hm|p(B2G&2X^D!Qm&qSf}@z-?^ch+{M1Z?ucy` z@s)$yLt$cOo_&7Tx01;OkLwxP`K_wB(8PL+E(gc6Kh&QGOPM0kpUkX+X0yokr*%K4 fH