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 6650630..e3b577f 100644 Binary files a/docs/UBGitea_기술소개.pptx and b/docs/UBGitea_기술소개.pptx differ diff --git a/docs/development-log.md b/docs/development-log.md index 4ce5eec..e37ec35 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -1367,3 +1367,17 @@ This is the append-only implementation and handoff record shared by Codex and Cl - Live verification: installed on the SM-F966N. Nothing in this task has been seen working on the device by the implementer; endpoint timings were taken from a PC. - Next owner: the product owner, to confirm on the device. + +## 2026-09-28 (continued) — TASK-057: side pane redesign, folder tree, unfolded Home + +- Request: the human product owner confirmed the faster listing, then asked for the pane drawn + from the left edge to be redesigned (it was a plain rectangle), split into folder structure and + classes/functions with the two halves swappable, and reported that on the unfolded Home the + left and right halves did not look like one design. +- What changed: see `tasks/057-side-pane-redesign-and-folder-tree.md`. +- Why the unfolded Home looked split: the headline was drawn edge to edge, hanging from the top + bar, which is right on a phone; unfolded, it covered only the left column. +- Checks and results: the required checks pass; 432 unit tests, 0 failures; lint 0 errors. +- Live verification: installed on the SM-F966N. None of it has been seen on a screen by the + implementer. +- Next owner: the product owner, to confirm on the device. diff --git a/tasks/057-side-pane-redesign-and-folder-tree.md b/tasks/057-side-pane-redesign-and-folder-tree.md new file mode 100644 index 0000000..d307c92 --- /dev/null +++ b/tasks/057-side-pane-redesign-and-folder-tree.md @@ -0,0 +1,80 @@ +# TASK-057: Side pane redesign, folder tree, and the unfolded Home + +Status: implemented; required checks pass; installed on the product owner's Z Fold7, awaiting their confirmation +Owner: Claude +Reviewer: human product owner + +## Outcome + +Third round of requests from the human product owner after using the app on their Galaxy Z Fold7 +(SM-F966N), 2026-09-28. They confirmed that directory listing (TASK-056) is now much faster. + +## Scope + +- Included: + 1. The pane drawn out from the left edge of the code viewer is no longer a plain rectangle: the + drawer has rounded outer corners and the page color, and holds two white cards. + 2. The pane is split in half: the folder structure, and the open file's classes and functions. + Choosing a class or function goes to it. + 3. A "위아래 바꾸기" button between the halves swaps them. The choice is kept across launches. + 4. The folder half is a tree: a folder opens in place, one level at a time, and a file inside it + opens from there. Before, choosing a folder left the viewer for the directory list. + 5. Home, unfolded: the headline block is a card like the rest, and the two columns are equal. + Before, it was a white block hanging from the top bar over the left column only, so the left + and right halves of the screen looked like two designs. +- Excluded: + - Dragging the boundary between the two halves; they are equal. + - Showing folders above the directory the file was opened from. The tree starts there. + +## Writable paths + +- `app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/` +- `app/src/main/java/com/ubisam/ubgitea/feature/home/HomeScreen.kt` +- `app/src/main/java/com/ubisam/ubgitea/feature/branchdetail/BranchDetailScreen.kt` +- `app/src/main/java/com/ubisam/ubgitea/core/settings/AppSettingsStore.kt` +- `app/src/main/java/com/ubisam/ubgitea/MainActivity.kt` +- `app/src/main/res/` +- `app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/` +- `tasks/`, `docs/` + +## Acceptance criteria + +- [x] The same pane is used in the drawer (phone, landscape, side-by-side pair) and beside the code + on a wide window. +- [x] A folder shows that it is loading, that it is empty, or that it failed; choosing a failed + folder twice tries again. +- [x] Closing a folder closes the folders open inside it. +- [x] Open folders are kept while moving between files of the tree. +- [x] A file opened from a folder of the tree finds its `.h`/`.cpp` counterpart in that folder. +- [x] A file with no classes or functions says so instead of showing an empty card. +- [x] Unit tests cover the tree's rows, closing, and lookup (`FileTreeTest`). + +## Security and privacy + +No additional concerns. The only thing stored is which half is on top, a boolean in the existing +settings preferences. + +## Required checks + +```text +./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug +``` + +## Evidence + +- Required checks pass: 432 unit tests, 0 failures; lint 0 errors. +- Installed on the SM-F966N over wireless `adb`. +- Not seen by the implementer on any screen: the drawer's new look, the tree, the swap, and the + unfolded Home. The Home change was made from reading the layout code, not from a screenshot. + +## Known limitations + +- `toggleFolder` in the view model has no unit test of its own; the tree it edits has. +- On the unfolded Home the three summary tiles are narrower than before, because the headline + card is now inset. A long label may wrap to two lines. +- The preference for which half is on top is read in the viewer itself rather than passed down + from `MainActivity` like the other viewer preferences. + +## Review findings + +None yet.