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:
parent
fa87a0b32f
commit
5cc11d8420
@ -400,6 +400,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
onSetCodeWordWrap = settingsViewModel::setCodeWordWrapEnabled,
|
onSetCodeWordWrap = settingsViewModel::setCodeWordWrapEnabled,
|
||||||
onSetCodeFontSize = settingsViewModel::setCodeFontSizeSp,
|
onSetCodeFontSize = settingsViewModel::setCodeFontSizeSp,
|
||||||
onOpenSibling = codeBrowserViewModel::openSibling,
|
onOpenSibling = codeBrowserViewModel::openSibling,
|
||||||
|
onToggleFolder = codeBrowserViewModel::toggleFolder,
|
||||||
onLoadCounterpart = codeBrowserViewModel::loadCounterpart,
|
onLoadCounterpart = codeBrowserViewModel::loadCounterpart,
|
||||||
historyState = historyState,
|
historyState = historyState,
|
||||||
onCommitSelected = historyViewModel::openCommit,
|
onCommitSelected = historyViewModel::openCommit,
|
||||||
|
|||||||
@ -37,6 +37,11 @@ interface AppSettingsStore {
|
|||||||
fun themeMode(): ThemeMode = ThemeMode.SYSTEM
|
fun themeMode(): ThemeMode = ThemeMode.SYSTEM
|
||||||
fun setThemeMode(mode: ThemeMode) = Unit
|
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 {
|
companion object {
|
||||||
const val DEFAULT_CODE_FONT_SIZE_SP = 13
|
const val DEFAULT_CODE_FONT_SIZE_SP = 13
|
||||||
val CODE_FONT_SIZES_SP = listOf(11, 12, 13, 14, 16)
|
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()
|
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 {
|
private companion object {
|
||||||
|
const val KEY_OUTLINE_ABOVE_FOLDERS = "code_outline_above_folders"
|
||||||
const val KEY_HOME_SOURCE = "home_source"
|
const val KEY_HOME_SOURCE = "home_source"
|
||||||
const val KEY_THEME_MODE = "theme_mode"
|
const val KEY_THEME_MODE = "theme_mode"
|
||||||
const val KEY_CODE_WORD_WRAP = "code_word_wrap_enabled"
|
const val KEY_CODE_WORD_WRAP = "code_word_wrap_enabled"
|
||||||
|
|||||||
@ -55,6 +55,7 @@ fun BranchDetailScreen(
|
|||||||
onSetCodeWordWrap: (Boolean) -> Unit,
|
onSetCodeWordWrap: (Boolean) -> Unit,
|
||||||
onSetCodeFontSize: (Int) -> Unit,
|
onSetCodeFontSize: (Int) -> Unit,
|
||||||
onOpenSibling: (RepositoryPath, String?) -> Unit,
|
onOpenSibling: (RepositoryPath, String?) -> Unit,
|
||||||
|
onToggleFolder: (RepositoryPath) -> Unit,
|
||||||
onLoadCounterpart: () -> Unit,
|
onLoadCounterpart: () -> Unit,
|
||||||
historyState: HistoryUiState,
|
historyState: HistoryUiState,
|
||||||
onCommitSelected: (CommitSummary) -> Unit,
|
onCommitSelected: (CommitSummary) -> Unit,
|
||||||
@ -86,6 +87,7 @@ fun BranchDetailScreen(
|
|||||||
onSetWordWrap = onSetCodeWordWrap,
|
onSetWordWrap = onSetCodeWordWrap,
|
||||||
onSetFontSize = onSetCodeFontSize,
|
onSetFontSize = onSetCodeFontSize,
|
||||||
onOpenSibling = onOpenSibling,
|
onOpenSibling = onOpenSibling,
|
||||||
|
onToggleFolder = onToggleFolder,
|
||||||
onOpenEntry = onEntrySelected,
|
onOpenEntry = onEntrySelected,
|
||||||
onLoadCounterpart = onLoadCounterpart,
|
onLoadCounterpart = onLoadCounterpart,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|||||||
@ -159,14 +159,15 @@ fun FileViewerScreen(
|
|||||||
onSetWordWrap: (Boolean) -> Unit,
|
onSetWordWrap: (Boolean) -> Unit,
|
||||||
onSetFontSize: (Int) -> Unit,
|
onSetFontSize: (Int) -> Unit,
|
||||||
onOpenSibling: (RepositoryPath, String?) -> Unit,
|
onOpenSibling: (RepositoryPath, String?) -> Unit,
|
||||||
|
onToggleFolder: (RepositoryPath) -> Unit,
|
||||||
onOpenEntry: (TreeEntry) -> Unit,
|
onOpenEntry: (TreeEntry) -> Unit,
|
||||||
onLoadCounterpart: () -> Unit,
|
onLoadCounterpart: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (state is CodeBrowserUiState.File && state.content is ContentClassification.Text) {
|
if (state is CodeBrowserUiState.File && state.content is ContentClassification.Text) {
|
||||||
CodeViewerScreen(
|
CodeViewerScreen(
|
||||||
state, state.content, preferences, onBack, onSetWordWrap, onSetFontSize, onOpenSibling, onOpenEntry,
|
state, state.content, preferences, onBack, onSetWordWrap, onSetFontSize, onOpenSibling, onToggleFolder,
|
||||||
onLoadCounterpart, modifier,
|
onOpenEntry, onLoadCounterpart, modifier,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
// A symbol to bring into view once the file is shown: set when the file was reached by
|
||||||
// following a symbol from its counterpart.
|
// following a symbol from its counterpart.
|
||||||
val focusSymbol: String? = null,
|
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
|
) : CodeBrowserUiState
|
||||||
data class UnsupportedEntry(
|
data class UnsupportedEntry(
|
||||||
val target: CodeBrowserTarget,
|
val target: CodeBrowserTarget,
|
||||||
@ -125,6 +128,7 @@ class CodeBrowserViewModel(
|
|||||||
val state: StateFlow<CodeBrowserUiState> = mutableState.asStateFlow()
|
val state: StateFlow<CodeBrowserUiState> = mutableState.asStateFlow()
|
||||||
private var operation: Job? = null
|
private var operation: Job? = null
|
||||||
private var counterpartOperation: Job? = null
|
private var counterpartOperation: Job? = null
|
||||||
|
private val folderOperations = mutableMapOf<RepositoryPath, Job>()
|
||||||
|
|
||||||
fun open(target: CodeBrowserTarget) = loadDirectory(target, RepositoryPath.Root)
|
fun open(target: CodeBrowserTarget) = loadDirectory(target, RepositoryPath.Root)
|
||||||
|
|
||||||
@ -149,14 +153,47 @@ class CodeBrowserViewModel(
|
|||||||
if (index == -1) return
|
if (index == -1) return
|
||||||
val newIndex = index + delta
|
val newIndex = index + delta
|
||||||
if (newIndex !in siblings.indices) return
|
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) {
|
fun openSibling(path: RepositoryPath, focusSymbol: String? = null) {
|
||||||
val current = mutableState.value as? CodeBrowserUiState.File ?: return
|
val current = mutableState.value as? CodeBrowserUiState.File ?: return
|
||||||
if (path == current.path) 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. */
|
/** Fetches the current file's counterpart so both can be shown side by side. */
|
||||||
@ -207,6 +244,8 @@ class CodeBrowserViewModel(
|
|||||||
operation?.cancel()
|
operation?.cancel()
|
||||||
operation = null
|
operation = null
|
||||||
counterpartOperation?.cancel()
|
counterpartOperation?.cancel()
|
||||||
|
folderOperations.values.forEach(Job::cancel)
|
||||||
|
folderOperations.clear()
|
||||||
mutableState.value = CodeBrowserUiState.Idle
|
mutableState.value = CodeBrowserUiState.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,7 +273,7 @@ class CodeBrowserViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.distinctBy(TreeEntry::path)
|
}.distinctBy(TreeEntry::path)
|
||||||
.sortedWith(compareBy<TreeEntry> { it.type != TreeEntryType.DIRECTORY }.thenBy { it.name.lowercase() })
|
.sortedWith(LISTING_ORDER)
|
||||||
}
|
}
|
||||||
mutableState.value = CodeBrowserUiState.Directory(target, path, entries)
|
mutableState.value = CodeBrowserUiState.Directory(target, path, entries)
|
||||||
} catch (failure: Throwable) {
|
} catch (failure: Throwable) {
|
||||||
@ -247,6 +286,7 @@ class CodeBrowserViewModel(
|
|||||||
path: RepositoryPath,
|
path: RepositoryPath,
|
||||||
siblingEntries: List<TreeEntry> = emptyList(),
|
siblingEntries: List<TreeEntry> = emptyList(),
|
||||||
focusSymbol: String? = null,
|
focusSymbol: String? = null,
|
||||||
|
tree: FileTree? = null,
|
||||||
) = startOperation {
|
) = startOperation {
|
||||||
counterpartOperation?.cancel()
|
counterpartOperation?.cancel()
|
||||||
mutableState.value = CodeBrowserUiState.Loading(target, path, isFile = true)
|
mutableState.value = CodeBrowserUiState.Loading(target, path, isFile = true)
|
||||||
@ -264,6 +304,8 @@ class CodeBrowserViewModel(
|
|||||||
siblingEntries = siblingEntries,
|
siblingEntries = siblingEntries,
|
||||||
counterpartPath = counterpartOf(path, siblingFiles),
|
counterpartPath = counterpartOf(path, siblingFiles),
|
||||||
focusSymbol = focusSymbol,
|
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) {
|
} catch (failure: Throwable) {
|
||||||
handleFailure(target, path, failure, isFile = true)
|
handleFailure(target, path, failure, isFile = true)
|
||||||
@ -332,6 +374,7 @@ class CodeBrowserViewModel(
|
|||||||
private companion object {
|
private companion object {
|
||||||
const val PAGE_SIZE = 100
|
const val PAGE_SIZE = 100
|
||||||
const val MAX_PAGES = 100
|
const val MAX_PAGES = 100
|
||||||
|
val LISTING_ORDER = compareBy<TreeEntry> { it.type != TreeEntryType.DIRECTORY }.thenBy { it.name.lowercase() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.ubisam.ubgitea.feature.codebrowser
|
package com.ubisam.ubgitea.feature.codebrowser
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
@ -70,6 +71,8 @@ import androidx.compose.runtime.snapshotFlow
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.ubisam.ubgitea.R
|
import com.ubisam.ubgitea.R
|
||||||
|
import com.ubisam.ubgitea.core.settings.AppSettingsSession
|
||||||
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
import com.ubisam.ubgitea.core.settings.AppSettingsStore
|
||||||
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
import com.ubisam.ubgitea.domain.model.RepositoryPath
|
||||||
import com.ubisam.ubgitea.domain.model.TreeEntry
|
import com.ubisam.ubgitea.domain.model.TreeEntry
|
||||||
@ -141,7 +145,8 @@ private enum class ViewerLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val CompactHeightBreakpoint = 480.dp
|
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 NarrowSidePaneWidth = 220.dp
|
||||||
private val NarrowWideBreakpoint = 700.dp
|
private val NarrowWideBreakpoint = 700.dp
|
||||||
|
|
||||||
@ -160,11 +165,20 @@ fun CodeViewerScreen(
|
|||||||
onSetWordWrap: (Boolean) -> Unit,
|
onSetWordWrap: (Boolean) -> Unit,
|
||||||
onSetFontSize: (Int) -> Unit,
|
onSetFontSize: (Int) -> Unit,
|
||||||
onOpenSibling: (RepositoryPath, String?) -> Unit,
|
onOpenSibling: (RepositoryPath, String?) -> Unit,
|
||||||
|
onToggleFolder: (RepositoryPath) -> Unit,
|
||||||
onOpenEntry: (TreeEntry) -> Unit,
|
onOpenEntry: (TreeEntry) -> Unit,
|
||||||
onLoadCounterpart: () -> Unit,
|
onLoadCounterpart: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val document = rememberCodeDocument(state.path, text.renderedText)
|
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 listState = remember(state.path) { LazyListState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val firstVisibleLine by rememberFirstVisibleLine(listState)
|
val firstVisibleLine by rememberFirstVisibleLine(listState)
|
||||||
@ -279,7 +293,8 @@ fun CodeViewerScreen(
|
|||||||
drawerContent = {
|
drawerContent = {
|
||||||
if (!sidePaneIsShown) {
|
if (!sidePaneIsShown) {
|
||||||
ModalDrawerSheet(
|
ModalDrawerSheet(
|
||||||
drawerContainerColor = MaterialTheme.colorScheme.surface,
|
drawerContainerColor = MaterialTheme.colorScheme.background,
|
||||||
|
drawerShape = RoundedCornerShape(topEnd = 28.dp, bottomEnd = 28.dp),
|
||||||
modifier = Modifier.width(drawerWidth),
|
modifier = Modifier.width(drawerWidth),
|
||||||
) {
|
) {
|
||||||
SidePane(
|
SidePane(
|
||||||
@ -293,6 +308,9 @@ fun CodeViewerScreen(
|
|||||||
onOpenSibling(it, null)
|
onOpenSibling(it, null)
|
||||||
},
|
},
|
||||||
onOpenEntry = onOpenEntry,
|
onOpenEntry = onOpenEntry,
|
||||||
|
onToggleFolder = onToggleFolder,
|
||||||
|
outlineFirst = outlineFirst,
|
||||||
|
onSwap = swapPanes,
|
||||||
onSymbolSelected = {
|
onSymbolSelected = {
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
goToSymbol(it)
|
goToSymbol(it)
|
||||||
@ -364,6 +382,9 @@ fun CodeViewerScreen(
|
|||||||
onToggleCollapsed = toggleCollapsed,
|
onToggleCollapsed = toggleCollapsed,
|
||||||
onOpenFile = { onOpenSibling(it, null) },
|
onOpenFile = { onOpenSibling(it, null) },
|
||||||
onOpenEntry = onOpenEntry,
|
onOpenEntry = onOpenEntry,
|
||||||
|
onToggleFolder = onToggleFolder,
|
||||||
|
outlineFirst = outlineFirst,
|
||||||
|
onSwap = swapPanes,
|
||||||
onSymbolSelected = { goToSymbol(it) },
|
onSymbolSelected = { goToSymbol(it) },
|
||||||
modifier = Modifier.width(sidePaneWidth).fillMaxHeight(),
|
modifier = Modifier.width(sidePaneWidth).fillMaxHeight(),
|
||||||
)
|
)
|
||||||
@ -971,31 +992,99 @@ private fun SidePane(
|
|||||||
collapsed: Set<Int>,
|
collapsed: Set<Int>,
|
||||||
onToggleCollapsed: (CodeSymbol) -> Unit,
|
onToggleCollapsed: (CodeSymbol) -> Unit,
|
||||||
onOpenFile: (RepositoryPath) -> Unit,
|
onOpenFile: (RepositoryPath) -> Unit,
|
||||||
|
onToggleFolder: (RepositoryPath) -> Unit,
|
||||||
onOpenEntry: (TreeEntry) -> Unit,
|
onOpenEntry: (TreeEntry) -> Unit,
|
||||||
onSymbolSelected: (CodeSymbol) -> Unit,
|
onSymbolSelected: (CodeSymbol) -> Unit,
|
||||||
|
outlineFirst: Boolean,
|
||||||
|
onSwap: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val tree = remember(document.symbols, collapsed) { outlineTree(document.symbols, collapsed) }
|
val outline = remember(document.symbols, collapsed) { outlineTree(document.symbols, collapsed) }
|
||||||
val current = currentIn(tree, document.symbols, currentLine)
|
val rows = remember(state.tree) { state.tree.rows() }
|
||||||
LazyColumn(modifier.background(MaterialTheme.colorScheme.surface), contentPadding = PaddingValues(vertical = 8.dp)) {
|
val folders: @Composable (Modifier) -> Unit = { FolderCard(state, rows, onOpenFile, onToggleFolder, onOpenEntry, it) }
|
||||||
if (state.siblingEntries.isNotEmpty()) {
|
val symbols: @Composable (Modifier) -> Unit = {
|
||||||
item(key = "files-title") { SidePaneTitle(stringResource(R.string.codeviewer_files_title)) }
|
SymbolCard(document, outline, currentLine, collapsed, onToggleCollapsed, onSymbolSelected, it)
|
||||||
items(state.siblingEntries, key = { "file:" + it.path.value }) { entry ->
|
}
|
||||||
val isFile = entry.type == TreeEntryType.FILE
|
Column(modifier.background(MaterialTheme.colorScheme.background).padding(horizontal = 10.dp, vertical = 10.dp)) {
|
||||||
val isCurrent = entry.path == state.path
|
val half = Modifier.weight(1f).fillMaxWidth()
|
||||||
SidePaneRow(
|
when {
|
||||||
icon = if (entry.type == TreeEntryType.DIRECTORY) R.drawable.ic_ub_folder else R.drawable.ic_ub_file,
|
// A file that was not opened from a listing has no folder to show.
|
||||||
text = entry.name,
|
rows.isEmpty() -> symbols(half)
|
||||||
isCurrent = isCurrent,
|
else -> {
|
||||||
accent = entry.type == TreeEntryType.DIRECTORY,
|
if (outlineFirst) symbols(half) else folders(half)
|
||||||
onClick = { if (isFile) onOpenFile(entry.path) else onOpenEntry(entry) },
|
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<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
|
||||||
|
}
|
||||||
|
LazyColumn(contentPadding = PaddingValues(vertical = 6.dp)) {
|
||||||
|
items(outline, key = { "symbol:" + it.line + ":" + it.name }) { symbol ->
|
||||||
SymbolRow(
|
SymbolRow(
|
||||||
symbol = symbol,
|
symbol = symbol,
|
||||||
isCurrent = symbol == current,
|
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
|
@Composable
|
||||||
private fun SidePaneTitle(text: String) {
|
private fun PaneCard(
|
||||||
Text(
|
@DrawableRes icon: Int,
|
||||||
text,
|
title: String,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
caption: String,
|
||||||
fontWeight = FontWeight.Bold,
|
modifier: Modifier,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
content: @Composable () -> Unit,
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp).semantics { heading() },
|
) {
|
||||||
)
|
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
|
@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(
|
Row(
|
||||||
Modifier.fillMaxWidth()
|
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)
|
.clickable(role = Role.Button, onClick = onClick)
|
||||||
.semantics { selected = isCurrent }
|
.semantics {
|
||||||
|
selected = isCurrent
|
||||||
|
if (folderState != null) stateDescription = folderState
|
||||||
|
}
|
||||||
.heightIn(min = 44.dp)
|
.heightIn(min = 44.dp)
|
||||||
.padding(horizontal = 14.dp),
|
.padding(start = 6.dp + TreeIndent * row.depth, end = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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(
|
||||||
text,
|
row.entry.name,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = if (isCurrent) FontWeight.Bold else FontWeight.Medium,
|
fontWeight = if (isCurrent || isFolder) FontWeight.Bold else FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
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.
|
* 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.
|
* 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
|
val base: Dp = if (compact) 14.dp else 20.dp
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth()
|
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)
|
.clickable(role = Role.Button, onClick = onClick)
|
||||||
.semantics { selected = isCurrent }
|
.semantics { selected = isCurrent }
|
||||||
.heightIn(min = if (compact) 44.dp else 48.dp)
|
.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,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -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
|
||||||
@ -156,13 +156,14 @@ private fun HomeContent(
|
|||||||
// Unfolded: the overview stays put on the left while the timeline scrolls on the right,
|
// Unfolded: the overview stays put on the left while the timeline scrolls on the right,
|
||||||
// so the whole picture is on screen at once.
|
// so the whole picture is on screen at once.
|
||||||
Row(Modifier.fillMaxSize()) {
|
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)
|
overview(state, vertical = true, onNavigate, onSelectSource, showToday)
|
||||||
}
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
Modifier.weight(0.54f).fillMaxHeight(),
|
Modifier.weight(0.5f).fillMaxHeight(),
|
||||||
state = timelineState,
|
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)
|
timeline(state, onNavigate, onSelectFilter, onSetTodayOnly, onLoadMore)
|
||||||
}
|
}
|
||||||
@ -184,10 +185,14 @@ private fun LazyListScope.overview(
|
|||||||
onShowToday: (ActivityCategory) -> Unit,
|
onShowToday: (ActivityCategory) -> Unit,
|
||||||
) {
|
) {
|
||||||
item(key = "headline") {
|
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(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
shape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
shape = if (vertical) RoundedCornerShape(24.dp) else RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = if (vertical) Modifier.fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = 16.dp) else Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(bottom = 20.dp)) {
|
Column(Modifier.padding(bottom = 20.dp)) {
|
||||||
TabTitle(
|
TabTitle(
|
||||||
|
|||||||
21
app/src/main/res/drawable/ic_ub_swap_vertical.xml
Normal file
21
app/src/main/res/drawable/ic_ub_swap_vertical.xml
Normal 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>
|
||||||
@ -66,7 +66,15 @@
|
|||||||
<string name="codeviewer_symbol_type">타입</string>
|
<string name="codeviewer_symbol_type">타입</string>
|
||||||
<string name="codeviewer_symbol_method">메서드</string>
|
<string name="codeviewer_symbol_method">메서드</string>
|
||||||
<string name="codeviewer_symbol_field">필드</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_label">짝 파일</string>
|
||||||
<string name="codeviewer_pair_header">선언</string>
|
<string name="codeviewer_pair_header">선언</string>
|
||||||
<string name="codeviewer_pair_source">구현</string>
|
<string name="codeviewer_pair_source">구현</string>
|
||||||
|
|||||||
@ -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.
@ -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
|
- 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.
|
device by the implementer; endpoint timings were taken from a PC.
|
||||||
- Next owner: the product owner, to confirm on the device.
|
- 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.
|
||||||
|
|||||||
80
tasks/057-side-pane-redesign-and-folder-tree.md
Normal file
80
tasks/057-side-pane-redesign-and-folder-tree.md
Normal 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.
|
||||||
Loading…
x
Reference in New Issue
Block a user