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 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-09-28 15:44:54 +09:00
parent fa87a0b32f
commit 5cc11d8420
14 changed files with 562 additions and 51 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<CodeBrowserUiState> = mutableState.asStateFlow()
private var operation: Job? = null
private var counterpartOperation: Job? = null
private val folderOperations = mutableMapOf<RepositoryPath, Job>()
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<TreeEntry> { 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<TreeEntry> = 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<TreeEntry> { it.type != TreeEntryType.DIRECTORY }.thenBy { it.name.lowercase() }
}
}

View File

@ -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<Int>,
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)
}
}
}
}
@Composable
private fun FolderCard(
state: CodeBrowserUiState.File,
rows: List<FileTreeRow>,
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<CodeSymbol>,
currentLine: Int,
collapsed: Set<Int>,
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
}
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 ->
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() },
)
}
@Composable
private fun SidePaneRow(@DrawableRes icon: Int, text: String, isCurrent: Boolean, accent: Boolean, onClick: () -> Unit) {
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()
.background(if (isCurrent) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface)
.clickable(role = Role.Button, onClick = onClick)
.semantics { selected = isCurrent }
.heightIn(min = 44.dp)
.padding(horizontal = 14.dp),
Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 10.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
UBIcon(icon, null, size = 18.dp, tint = if (accent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
Box(
Modifier.size(28.dp).background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
contentAlignment = Alignment.Center,
) {
UBIcon(icon, null, size = 16.dp, tint = MaterialTheme.colorScheme.primary)
}
Text(
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 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()
.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
if (folderState != null) stateDescription = folderState
}
.heightIn(min = 44.dp)
.padding(start = 6.dp + TreeIndent * row.depth, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
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(
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),
) {

View File

@ -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<TreeEntry>) : 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<TreeEntry>,
val folders: Map<RepositoryPath, FolderUiState> = emptyMap(),
) {
/** The listing of [directory], if this tree holds it. */
fun entriesOf(directory: RepositoryPath): List<TreeEntry>? =
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<FileTreeRow> = buildList { addRows(this@rows, entries, depth = 0) }
private fun MutableList<FileTreeRow>.addRows(tree: FileTree, entries: List<TreeEntry>, 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

View File

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

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#00000000"
android:strokeColor="#FF000000"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.8"
android:pathData="M8,19V5M4,9l4,-4 4,4" />
<path
android:fillColor="#00000000"
android:strokeColor="#FF000000"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:strokeWidth="1.8"
android:pathData="M16,5v14M12,15l4,4 4,-4" />
</vector>

View File

@ -66,7 +66,15 @@
<string name="codeviewer_symbol_type">타입</string>
<string name="codeviewer_symbol_method">메서드</string>
<string name="codeviewer_symbol_field">필드</string>
<string name="codeviewer_files_title">파일</string>
<string name="codeviewer_files_title">폴더 구조</string>
<string name="codeviewer_symbols_title">클래스 · 함수</string>
<string name="codeviewer_symbols_empty">이 파일에는 표시할 클래스나 함수가 없습니다.</string>
<string name="codeviewer_pane_swap">위아래 바꾸기</string>
<string name="codeviewer_folder_open">펼쳐짐</string>
<string name="codeviewer_folder_closed">접힘</string>
<string name="codeviewer_folder_loading">불러오는 중…</string>
<string name="codeviewer_folder_failed">불러오지 못했습니다. 폴더를 다시 눌러 주세요.</string>
<string name="codeviewer_folder_empty">비어 있음</string>
<string name="codeviewer_pair_label">짝 파일</string>
<string name="codeviewer_pair_header">선언</string>
<string name="codeviewer_pair_source">구현</string>

View File

@ -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:<LOADING>", "0:ui+", "1:<EMPTY>", "0:Main.cpp"), describe(open))
assertEquals(listOf("0:core+", "1:<FAILED>", "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)
}
}

Binary file not shown.

View File

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

View File

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