TASK-056: edge-swipe outline, resizable pair, tree-based directory listing

- Code viewer: the outline opens with a swipe from the left edge and closes
  with a swipe left, with one pane or two.
- Side-by-side .h/.cpp: a draggable handle resizes the panes (20% to 80%).
- Directory listing reads Git tree objects instead of the contents endpoint,
  which looked up the last commit of every entry.
- A listing that needs more than one request shows percent and count.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-09-28 15:29:10 +09:00
parent 9046985ba4
commit fa87a0b32f
10 changed files with 557 additions and 14 deletions

View File

@ -30,6 +30,7 @@ import java.time.Instant
import java.time.format.DateTimeParseException import java.time.format.DateTimeParseException
import java.io.IOException import java.io.IOException
import java.util.Base64 import java.util.Base64
import java.util.concurrent.ConcurrentHashMap
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
@ -180,6 +181,15 @@ class GiteaRepositoryBrowserApi(
return response.toPage(items, page, pageSize) return response.toPage(items, page, pageSize)
} }
// A directory is listed from Git's own tree object (`/git/trees/{sha}`), not from the contents
// endpoint. The contents endpoint looks up the last commit of every entry it returns, so its
// cost grows with the number of files; measured against this project's Gitea, a 30-entry
// directory took about 330 ms from contents and about 40 ms from its tree (TASK-056).
//
// A tree is addressed by its id, and the id of a directory is only known from the listing of
// its parent. So the root is reached through the ref's commit, each listing remembers the ids
// of the directories in it, and a directory whose id is not known -- one that was not reached
// by walking down -- is still listed the old way.
override suspend fun listTree( override suspend fun listTree(
repository: RepositoryCoordinates, repository: RepositoryCoordinates,
ref: RepositoryRef, ref: RepositoryRef,
@ -188,6 +198,82 @@ class GiteaRepositoryBrowserApi(
pageSize: Int, pageSize: Int,
): Page<TreeEntry> { ): Page<TreeEntry> {
validatePage(page, pageSize) validatePage(page, pageSize)
val treeId = treeIdOf(repository, ref, path, page) ?: return listTreeFromContents(repository, ref, path, page, pageSize)
val url = repoUrl(repository, "git", "trees", treeId)
.newBuilder()
.addQueryParameter("page", page.toString())
.addQueryParameter("per_page", pageSize.toString())
.build()
val dto = parseObject<GitTreeDto>(get(url).body)
val entries = dto.tree.orEmpty().map { entry ->
mapGitEntry(entry, path).also { mapped ->
if (mapped.type == TreeEntryType.DIRECTORY) treeIds[TreeKey(repository, ref, mapped.path)] = entry.sha.required()
}
}
val next = if (dto.truncated == true) page.safeNextPage() else null
return Page.of(entries, next, dto.totalCount?.takeIf { it >= 0 })
}
private suspend fun treeIdOf(repository: RepositoryCoordinates, ref: RepositoryRef, path: RepositoryPath, page: Int): String? {
if (!path.isRoot) return treeIds[TreeKey(repository, ref, path)]
val root = TreeKey(repository, ref, RepositoryPath.Root)
// Later pages of one listing must come from the commit the first page came from.
if (page > 1) return treeIds[root]
val commit = headCommitOf(repository, ref) ?: return null
if (treeIds[root] != commit) {
// The ref has moved: what was remembered describes the commit it used to point at.
treeIds.keys.removeAll { it.repository == repository && it.ref == ref }
treeIds[root] = commit
}
return commit
}
// A ref may contain `/`, which is awkward in a path segment and harmless in a query, so the
// ref is resolved through the commit list (asked for one commit, with nothing computed) rather
// than put in the trees URL. Anything but a clean answer falls back to the contents endpoint,
// which then reports whatever is actually wrong with the ref.
private suspend fun headCommitOf(repository: RepositoryCoordinates, ref: RepositoryRef): String? {
val url = repoUrl(repository, "commits")
.newBuilder()
.addQueryParameter("sha", ref.value)
.addQueryParameter("limit", "1")
.addQueryParameter("stat", "false")
.addQueryParameter("files", "false")
.addQueryParameter("verification", "false")
.build()
return try {
parseList<CommitDto>(get(url).body).firstOrNull()?.sha?.takeIf(String::isNotBlank)
} catch (failure: ApiFailure) {
if (failure is ApiFailure.AuthenticationExpired || failure is ApiFailure.Network) throw failure
null
}
}
private fun mapGitEntry(dto: GitEntryDto, parent: RepositoryPath): TreeEntry {
val name = dto.path.required()
val type = when (dto.type) {
"tree" -> TreeEntryType.DIRECTORY
"blob" -> if (dto.mode == SYMLINK_MODE) TreeEntryType.SYMLINK else TreeEntryType.FILE
"commit" -> TreeEntryType.SUBMODULE
else -> throw ApiFailure.InvalidResponse()
}
val size = dto.size ?: 0L
if (size < 0) throw ApiFailure.InvalidResponse()
return TreeEntry(
name = name,
path = RepositoryPath.of(if (parent.isRoot) name else "${parent.value}/$name"),
type = type,
sizeBytes = if (type == TreeEntryType.DIRECTORY) null else size,
)
}
private suspend fun listTreeFromContents(
repository: RepositoryCoordinates,
ref: RepositoryRef,
path: RepositoryPath,
page: Int,
pageSize: Int,
): Page<TreeEntry> {
val url = repoUrl(repository, "contents", *path.segments()) val url = repoUrl(repository, "contents", *path.segments())
.newBuilder() .newBuilder()
.addQueryParameter("ref", ref.value) .addQueryParameter("ref", ref.value)
@ -400,6 +486,19 @@ class GiteaRepositoryBrowserApi(
private data class OrganizationDto(val name: String?, @SerializedName("full_name") val fullName: String?) private data class OrganizationDto(val name: String?, @SerializedName("full_name") val fullName: String?)
private data class OwnerDto(val login: String?) private data class OwnerDto(val login: String?)
private data class TreeKey(val repository: RepositoryCoordinates, val ref: RepositoryRef, val path: RepositoryPath)
// Tree ids by directory, as learned from listings. Ids name immutable objects, so an entry is
// never wrong about what it points at -- only possibly about whether the ref still leads there,
// which treeIdOf settles each time the root is listed.
private val treeIds = ConcurrentHashMap<TreeKey, String>()
private data class GitEntryDto(val path: String?, val mode: String?, val type: String?, val size: Long?, val sha: String?)
private data class GitTreeDto(
val tree: List<GitEntryDto>?,
val truncated: Boolean?,
@SerializedName("total_count") val totalCount: Int?,
)
private data class RepositoryDto( private data class RepositoryDto(
val owner: OwnerDto?, val owner: OwnerDto?,
val name: String?, val name: String?,
@ -431,4 +530,9 @@ class GiteaRepositoryBrowserApi(
private data class CommitDto(val sha: String?, val commit: CommitDataDto?) private data class CommitDto(val sha: String?, val commit: CommitDataDto?)
private data class CommitDataDto(val message: String?, val author: CommitAuthorDto?) private data class CommitDataDto(val message: String?, val author: CommitAuthorDto?)
private data class CommitAuthorDto(val name: String?, val date: String?) private data class CommitAuthorDto(val name: String?, val date: String?)
private companion object {
// Git's file mode for a symbolic link; a tree lists one as a blob.
const val SYMLINK_MODE = "120000"
}
} }

View File

@ -40,6 +40,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -68,6 +69,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@ -116,7 +119,8 @@ fun CodeBrowserScreen(
CodeBrowserUiState.Idle -> Unit CodeBrowserUiState.Idle -> Unit
is CodeBrowserUiState.Loading -> { is CodeBrowserUiState.Loading -> {
Breadcrumbs(state.path, onBreadcrumbSelected) Breadcrumbs(state.path, onBreadcrumbSelected)
CenteredLoading(null) val percent = state.percent
if (percent == null) CenteredLoading(null) else ListingProgress(state.loadedEntries ?: 0, state.totalEntries ?: 0, percent)
} }
is CodeBrowserUiState.Directory -> { is CodeBrowserUiState.Directory -> {
Breadcrumbs(state.path, onBreadcrumbSelected) Breadcrumbs(state.path, onBreadcrumbSelected)
@ -195,6 +199,20 @@ fun FileViewerScreen(
} }
} }
/** A real measure, unlike the commit list's estimate: entries received out of entries the server counted. */
@Composable
private fun ListingProgress(loaded: Int, total: Int, percent: Int) {
val description = stringResource(R.string.codebrowser_listing_progress, percent, loaded, total)
Column(
Modifier.fillMaxSize().padding(24.dp).semantics(mergeDescendants = true) { contentDescription = description },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.width(200.dp))
Text(description, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 12.dp))
}
}
@Composable @Composable
private fun BrowserError(type: CodeBrowserError, onRetry: () -> Unit, onAuthenticationExpired: () -> Unit) { private fun BrowserError(type: CodeBrowserError, onRetry: () -> Unit, onAuthenticationExpired: () -> Unit) {
val expired = type == CodeBrowserError.AUTHENTICATION_EXPIRED val expired = type == CodeBrowserError.AUTHENTICATION_EXPIRED

View File

@ -54,7 +54,19 @@ sealed interface CodeBrowserUiState {
val target: CodeBrowserTarget, val target: CodeBrowserTarget,
val path: RepositoryPath, val path: RepositoryPath,
val isFile: Boolean, val isFile: Boolean,
) : CodeBrowserUiState // How far a directory listing has got, once the server has said how many entries there
// are. Null before that, and always null for a file: a file arrives in one response whose
// length is not known ahead of it.
val loadedEntries: Int? = null,
val totalEntries: Int? = null,
) : CodeBrowserUiState {
/** 0..100, or null when there is nothing to measure against. */
val percent: Int?
get() {
val total = totalEntries?.takeIf { it > 0 } ?: return null
return ((loadedEntries ?: 0).coerceIn(0, total) * 100L / total).toInt()
}
}
class Directory( class Directory(
val target: CodeBrowserTarget, val target: CodeBrowserTarget,
val path: RepositoryPath, val path: RepositoryPath,
@ -211,8 +223,16 @@ class CodeBrowserViewModel(
mutableState.value = CodeBrowserUiState.Loading(target, path, isFile = false) mutableState.value = CodeBrowserUiState.Loading(target, path, isFile = false)
try { try {
val entries = withContext(defaultDispatcher) { val entries = withContext(defaultDispatcher) {
var loaded = 0
loadAllPages { page -> loadAllPages { page ->
browser.listTree(target.repository, target.ref, path, page, PAGE_SIZE) browser.listTree(target.repository, target.ref, path, page, PAGE_SIZE).also { result ->
loaded += result.items.size
val total = result.totalCount
// Only a listing that has more to fetch has progress worth showing.
if (total != null && result.nextPage != null) {
mutableState.value = CodeBrowserUiState.Loading(target, path, isFile = false, loaded, total)
}
}
}.distinctBy(TreeEntry::path) }.distinctBy(TreeEntry::path)
.sortedWith(compareBy<TreeEntry> { it.type != TreeEntryType.DIRECTORY }.thenBy { it.name.lowercase() }) .sortedWith(compareBy<TreeEntry> { it.type != TreeEntryType.DIRECTORY }.thenBy { it.name.lowercase() })
} }

View File

@ -6,6 +6,10 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -34,6 +38,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -41,19 +46,24 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.VerticalDivider import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
@ -62,7 +72,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
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.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
@ -107,6 +121,7 @@ import com.ubisam.ubgitea.ui.components.StatusBadge
import com.ubisam.ubgitea.ui.components.ActivityTone import com.ubisam.ubgitea.ui.components.ActivityTone
import com.ubisam.ubgitea.ui.components.UBIcon import com.ubisam.ubgitea.ui.components.UBIcon
import com.ubisam.ubgitea.ui.shell.WideLayoutBreakpoint import com.ubisam.ubgitea.ui.shell.WideLayoutBreakpoint
import kotlin.math.abs
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -170,11 +185,28 @@ fun CodeViewerScreen(
var focusedLine by remember(state.path) { mutableStateOf<Int?>(null) } var focusedLine by remember(state.path) { mutableStateOf<Int?>(null) }
var sideBySide by rememberSaveable { mutableStateOf(true) } var sideBySide by rememberSaveable { mutableStateOf(true) }
var syncPanes by rememberSaveable { mutableStateOf(true) } var syncPanes by rememberSaveable { mutableStateOf(true) }
// The share of the width the left pane of a header/source pair takes.
var splitFraction by rememberSaveable { mutableFloatStateOf(DEFAULT_SPLIT) }
val drawerState = rememberDrawerState(DrawerValue.Closed)
// The other half of a header/source pair, once a wide window has asked for it and it has
// arrived as text.
val counterpart = state.counterpartPath
val otherText = ((state.counterpart as? CounterpartUiState.Loaded)?.content as? ContentClassification.Text)?.renderedText
val otherDocument = if (counterpart != null && otherText != null) rememberCodeDocument(counterpart, otherText) else null
val otherState = remember(counterpart) { LazyListState() }
fun goToLine(line: Int) { fun goToLine(line: Int) {
focusedLine = line focusedLine = line
scope.launch { listState.scrollToItem(line.coerceIn(0, (document.lines.size - 1).coerceAtLeast(0))) } scope.launch { listState.scrollToItem(line.coerceIn(0, (document.lines.size - 1).coerceAtLeast(0))) }
} }
// Choosing a symbol goes to it in the open file and, when its counterpart is beside it, to the
// same symbol there.
fun goToSymbol(symbol: CodeSymbol) {
goToLine(symbol.line)
val match = otherDocument?.let { findSymbolNamed(it.symbols, symbol.name, symbol.kind) } ?: return
scope.launch { otherState.scrollToItem(match.line) }
}
fun goToMatch(index: Int) { fun goToMatch(index: Int) {
if (matches.isEmpty()) return if (matches.isEmpty()) return
currentMatch = index.mod(matches.size) currentMatch = index.mod(matches.size)
@ -194,6 +226,7 @@ fun CodeViewerScreen(
BackHandler { BackHandler {
when { when {
drawerState.isOpen -> scope.launch { drawerState.close() }
outlineOpen -> outlineOpen = false outlineOpen -> outlineOpen = false
searching -> { searching -> {
searching = false searching = false
@ -213,6 +246,10 @@ fun CodeViewerScreen(
val counterpartPath = state.counterpartPath val counterpartPath = state.counterpartPath
val showsPair = layout == ViewerLayout.WIDE && counterpartPath != null && sideBySide val showsPair = layout == ViewerLayout.WIDE && counterpartPath != null && sideBySide
if (showsPair) LaunchedEffect(state.path, counterpartPath) { onLoadCounterpart() } if (showsPair) LaunchedEffect(state.path, counterpartPath) { onLoadCounterpart() }
// Where the files and the outline are not already beside the code, they are a drawer: drawn
// out from the left edge, pushed back the same way.
val sidePaneIsShown = layout == ViewerLayout.WIDE && !showsPair
val drawerWidth = (maxWidth * DRAWER_WIDTH_FRACTION).coerceAtMost(MaxDrawerWidth)
val tools = ViewerTools( val tools = ViewerTools(
preferences = preferences, preferences = preferences,
@ -234,6 +271,38 @@ fun CodeViewerScreen(
CodeBrowserNavigation.parent(state.path).value.ifEmpty { stringResource(R.string.codebrowser_root) }, CodeBrowserNavigation.parent(state.path).value.ifEmpty { stringResource(R.string.codebrowser_root) },
) )
ModalNavigationDrawer(
drawerState = drawerState,
// Open, the drawer closes on a swipe anywhere. Closed, only the edge opens it (below),
// so a sideways drag in the code still scrolls the code.
gesturesEnabled = drawerState.isOpen,
drawerContent = {
if (!sidePaneIsShown) {
ModalDrawerSheet(
drawerContainerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier.width(drawerWidth),
) {
SidePane(
state = state,
document = document,
currentLine = firstVisibleLine,
collapsed = collapsed,
onToggleCollapsed = toggleCollapsed,
onOpenFile = {
scope.launch { drawerState.close() }
onOpenSibling(it, null)
},
onOpenEntry = onOpenEntry,
onSymbolSelected = {
scope.launch { drawerState.close() }
goToSymbol(it)
},
modifier = Modifier.fillMaxSize(),
)
}
}
},
) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
when { when {
searching -> SearchBar( searching -> SearchBar(
@ -265,17 +334,25 @@ fun CodeViewerScreen(
LocationStrip(enclosingSymbols(document.symbols, firstVisibleLine)) LocationStrip(enclosingSymbols(document.symbols, firstVisibleLine))
} }
Row(Modifier.weight(1f).fillMaxWidth()) { Row(
Modifier.weight(1f).fillMaxWidth().openFromLeftEdge(enabled = !sidePaneIsShown && drawerState.isClosed) {
scope.launch { drawerState.open() }
},
) {
when { when {
showsPair -> PairPanes( showsPair -> PairPanes(
state = state, state = state,
document = document, document = document,
listState = listState, listState = listState,
otherDocument = otherDocument,
otherState = otherState,
preferences = preferences, preferences = preferences,
highlight = highlight, highlight = highlight,
focusedLine = focusedLine, focusedLine = focusedLine,
syncPanes = syncPanes, syncPanes = syncPanes,
counterpartPath = counterpartPath!!, counterpartPath = counterpartPath!!,
splitFraction = splitFraction,
onSplitChange = { splitFraction = it },
) )
else -> { else -> {
if (layout == ViewerLayout.WIDE) { if (layout == ViewerLayout.WIDE) {
@ -287,7 +364,7 @@ fun CodeViewerScreen(
onToggleCollapsed = toggleCollapsed, onToggleCollapsed = toggleCollapsed,
onOpenFile = { onOpenSibling(it, null) }, onOpenFile = { onOpenSibling(it, null) },
onOpenEntry = onOpenEntry, onOpenEntry = onOpenEntry,
onSymbolSelected = { goToLine(it.line) }, onSymbolSelected = { goToSymbol(it) },
modifier = Modifier.width(sidePaneWidth).fillMaxHeight(), modifier = Modifier.width(sidePaneWidth).fillMaxHeight(),
) )
VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant) VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant)
@ -318,6 +395,7 @@ fun CodeViewerScreen(
if (layout != ViewerLayout.LANDSCAPE) Toolbar(tools) if (layout != ViewerLayout.LANDSCAPE) Toolbar(tools)
} }
} }
}
if (outlineOpen) { if (outlineOpen) {
OutlineSheet( OutlineSheet(
@ -327,7 +405,7 @@ fun CodeViewerScreen(
onToggleCollapsed = toggleCollapsed, onToggleCollapsed = toggleCollapsed,
onSymbolSelected = { onSymbolSelected = {
outlineOpen = false outlineOpen = false
goToLine(it.line) goToSymbol(it)
}, },
onLineEntered = { onLineEntered = {
outlineOpen = false outlineOpen = false
@ -697,21 +775,25 @@ private fun JumpToCounterpart(counterpart: RepositoryPath, modifier: Modifier =
} }
} }
/** A header and its source, side by side, header on the left whichever of the two was opened. */ /**
* A header and its source, side by side, header on the left whichever of the two was opened. The
* bar between them is dragged to give one side more of the width.
*/
@Composable @Composable
private fun PairPanes( private fun PairPanes(
state: CodeBrowserUiState.File, state: CodeBrowserUiState.File,
document: CodeDocument, document: CodeDocument,
listState: LazyListState, listState: LazyListState,
otherDocument: CodeDocument?,
otherState: LazyListState,
preferences: CodeViewerPreferences, preferences: CodeViewerPreferences,
highlight: SearchHighlight, highlight: SearchHighlight,
focusedLine: Int?, focusedLine: Int?,
syncPanes: Boolean, syncPanes: Boolean,
counterpartPath: RepositoryPath, counterpartPath: RepositoryPath,
splitFraction: Float,
onSplitChange: (Float) -> Unit,
) { ) {
val otherText = ((state.counterpart as? CounterpartUiState.Loaded)?.content as? ContentClassification.Text)?.renderedText
val otherDocument = if (otherText != null) rememberCodeDocument(counterpartPath, otherText) else null
val otherState = remember(counterpartPath) { LazyListState() }
if (syncPanes && otherDocument != null) { if (syncPanes && otherDocument != null) {
FollowSymbols(listState, document, otherState, otherDocument) FollowSymbols(listState, document, otherState, otherDocument)
FollowSymbols(otherState, otherDocument, listState, document) FollowSymbols(otherState, otherDocument, listState, document)
@ -742,10 +824,85 @@ private fun PairPanes(
} }
} }
} }
BoxWithConstraints(Modifier.fillMaxSize()) {
val widthPx = with(LocalDensity.current) { maxWidth.toPx() }
// The latest value, for a drag that outlives the composition it started in.
val fraction by rememberUpdatedState(splitFraction)
Row(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) {
(if (currentIsHeader) current else other)(Modifier.weight(1f).fillMaxHeight()) (if (currentIsHeader) current else other)(Modifier.weight(splitFraction).fillMaxHeight())
VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant) SplitHandle(
(if (currentIsHeader) other else current)(Modifier.weight(1f).fillMaxHeight()) onDrag = { deltaPx -> onSplitChange(clampSplit(fraction + deltaPx / widthPx)) },
onReset = { onSplitChange(DEFAULT_SPLIT) },
)
(if (currentIsHeader) other else current)(Modifier.weight(1f - splitFraction).fillMaxHeight())
}
}
}
/** Keeps either pane from being dragged down to nothing. */
internal fun clampSplit(fraction: Float): Float = fraction.coerceIn(MIN_SPLIT, 1f - MIN_SPLIT)
/**
* The bar between two panes. It is wider to touch than it looks: a hairline with a grip in the
* middle, inside a strip a finger can land on. Dragging moves the split; a double tap evens it.
*/
@Composable
private fun SplitHandle(onDrag: (Float) -> Unit, onReset: () -> Unit) {
val description = stringResource(R.string.codeviewer_split_handle)
Box(
Modifier.width(SplitHandleWidth)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surface)
.semantics { contentDescription = description }
.pointerInput(Unit) {
detectHorizontalDragGestures { change, dragAmount ->
change.consume()
onDrag(dragAmount)
}
}
.pointerInput(Unit) { detectTapGestures(onDoubleTap = { onReset() }) },
contentAlignment = Alignment.Center,
) {
Box(Modifier.width(1.dp).fillMaxHeight().background(MaterialTheme.colorScheme.outlineVariant))
Box(
Modifier.size(width = 6.dp, height = 44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.outline),
)
}
}
/**
* Opens something when a drag starts at the left edge and moves right. It watches from the
* parent, ahead of the code pane, and only claims a gesture that began inside the edge strip and
* is going sideways -- a vertical drag that happens to start there still scrolls the code.
*
* With gesture navigation the system takes a swipe from the very edge as "back" before the app
* sees it; the strip is wide enough to be reached from just inside that.
*/
private fun Modifier.openFromLeftEdge(enabled: Boolean, onOpen: () -> Unit): Modifier =
if (!enabled) this else pointerInput(Unit) {
val edgePx = EdgeSwipeWidth.toPx()
val openPx = EdgeSwipeDistance.toPx()
val slop = viewConfiguration.touchSlop
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
if (down.position.x > edgePx) return@awaitEachGesture
var dragX = 0f
var dragY = 0f
while (true) {
val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == down.id } ?: break
if (!change.pressed) break
val delta = change.positionChange()
dragX += delta.x
dragY += abs(delta.y)
if (dragY > slop && dragY > abs(dragX)) break
if (dragX > slop) change.consume()
if (dragX > openPx) {
onOpen()
break
}
}
} }
} }
@ -1062,5 +1219,12 @@ private fun OutlineSheet(
} }
} }
private const val DEFAULT_SPLIT = 0.5f
private const val MIN_SPLIT = 0.2f
private const val DRAWER_WIDTH_FRACTION = 0.85f
private val MaxDrawerWidth = 320.dp
private val SplitHandleWidth = 24.dp
private val EdgeSwipeWidth = 32.dp
private val EdgeSwipeDistance = 56.dp
private const val SEARCH_CONTEXT_LINES = 3 private const val SEARCH_CONTEXT_LINES = 3
private const val MAX_LINE_DIGITS = 6 private const val MAX_LINE_DIGITS = 6

View File

@ -5,6 +5,7 @@
<string name="codebrowser_ref_tag">태그</string> <string name="codebrowser_ref_tag">태그</string>
<string name="codebrowser_ref_commit">커밋</string> <string name="codebrowser_ref_commit">커밋</string>
<string name="codebrowser_root">최상위</string> <string name="codebrowser_root">최상위</string>
<string name="codebrowser_listing_progress">%1$d%% · %2$d / %3$d개</string>
<string name="codebrowser_directory_empty">이 폴더는 비어 있습니다.</string> <string name="codebrowser_directory_empty">이 폴더는 비어 있습니다.</string>
<string name="codebrowser_entry_directory">폴더</string> <string name="codebrowser_entry_directory">폴더</string>
<string name="codebrowser_entry_file">파일</string> <string name="codebrowser_entry_file">파일</string>
@ -74,5 +75,6 @@
<string name="codeviewer_counterpart_loading">짝 파일을 불러오는 중입니다.</string> <string name="codeviewer_counterpart_loading">짝 파일을 불러오는 중입니다.</string>
<string name="codeviewer_counterpart_failed">짝 파일을 불러오지 못했습니다.</string> <string name="codeviewer_counterpart_failed">짝 파일을 불러오지 못했습니다.</string>
<string name="codeviewer_counterpart_not_text">짝 파일은 텍스트로 표시할 수 없습니다.</string> <string name="codeviewer_counterpart_not_text">짝 파일은 텍스트로 표시할 수 없습니다.</string>
<string name="codeviewer_split_handle">창 너비 조절. 좌우로 끌어 조절하고, 두 번 누르면 반반으로 되돌립니다.</string>
<string name="codeviewer_more">더 보기</string> <string name="codeviewer_more">더 보기</string>
</resources> </resources>

View File

@ -236,6 +236,110 @@ class GiteaRepositoryBrowserApiTest {
assertEquals("folder one", url.pathSegments.last()) assertEquals("folder one", url.pathSegments.last())
} }
private val headCommit = """[{"sha":"c0ffee","commit":{"message":"m","author":{"name":"Dev","date":"2026-08-25T10:20:30Z"}}}]"""
@Test
fun `the root is listed from the tree of the ref's commit`() = runTest {
val transport = QueueTransport(
response(headCommit),
response(
"""{"total_count":4,"truncated":false,"tree":[
{"path":"src","mode":"040000","type":"tree","size":0,"sha":"aaa"},
{"path":"App.kt","mode":"100644","type":"blob","size":12,"sha":"bbb"},
{"path":"link","mode":"120000","type":"blob","size":4,"sha":"ccc"},
{"path":"vendor","mode":"160000","type":"commit","size":0,"sha":"ddd"}
]}""",
),
)
val page = api(transport).listTree(repository, RepositoryRef.of("feature/a+b"), RepositoryPath.Root, 1, 100)
assertEquals(
listOf(TreeEntryType.DIRECTORY, TreeEntryType.FILE, TreeEntryType.SYMLINK, TreeEntryType.SUBMODULE),
page.items.map { it.type },
)
assertEquals(listOf("src", "App.kt", "link", "vendor"), page.items.map { it.path.value })
assertNull(page.items.first().sizeBytes)
assertEquals(12L, page.items[1].sizeBytes)
assertNull(page.nextPage)
assertEquals(4, page.totalCount)
val (commits, tree) = transport.urls.map { it.toHttpUrl() }
assertEquals("commits", commits.pathSegments.last())
assertEquals("feature/a+b", commits.queryParameter("sha"))
assertEquals("false", commits.queryParameter("stat"))
assertEquals(listOf("git", "trees", "c0ffee"), tree.pathSegments.takeLast(3))
}
@Test
fun `a directory reached by walking down is listed from its own tree`() = runTest {
val transport = QueueTransport(
response(headCommit),
response("""{"total_count":1,"truncated":false,"tree":[{"path":"folder one","mode":"040000","type":"tree","size":0,"sha":"aaa"}]}"""),
response("""{"total_count":1,"truncated":false,"tree":[{"path":"App.kt","mode":"100644","type":"blob","size":12,"sha":"bbb"}]}"""),
)
val api = api(transport)
val ref = RepositoryRef.of("main")
api.listTree(repository, ref, RepositoryPath.Root, 1, 100)
val page = api.listTree(repository, ref, RepositoryPath.of("folder one"), 1, 100)
assertEquals("folder one/App.kt", page.items.single().path.value)
assertEquals(listOf("git", "trees", "aaa"), transport.urls.last().toHttpUrl().pathSegments.takeLast(3))
}
@Test
fun `a truncated tree has a next page and later pages reuse the first page's commit`() = runTest {
val transport = QueueTransport(
response(headCommit),
response("""{"total_count":3,"truncated":true,"tree":[{"path":"a","mode":"100644","type":"blob","size":1,"sha":"1"},{"path":"b","mode":"100644","type":"blob","size":1,"sha":"2"}]}"""),
response("""{"total_count":3,"truncated":false,"tree":[{"path":"c","mode":"100644","type":"blob","size":1,"sha":"3"}]}"""),
)
val api = api(transport)
val ref = RepositoryRef.of("main")
val first = api.listTree(repository, ref, RepositoryPath.Root, 1, 2)
val second = api.listTree(repository, ref, RepositoryPath.Root, 2, 2)
assertEquals(2, first.nextPage)
assertNull(second.nextPage)
assertEquals(3, transport.urls.size)
assertEquals("2", transport.urls.last().toHttpUrl().queryParameter("page"))
}
@Test
fun `a ref that moved forgets the directories remembered for the old commit`() = runTest {
val moved = headCommit.replace("c0ffee", "decaf")
val transport = QueueTransport(
response(headCommit),
response("""{"total_count":1,"truncated":false,"tree":[{"path":"src","mode":"040000","type":"tree","size":0,"sha":"old"}]}"""),
response(moved),
response("""{"total_count":0,"truncated":false,"tree":[]}"""),
response("""[{"name":"A.kt","path":"src/A.kt","type":"file","size":1}]"""),
)
val api = api(transport)
val ref = RepositoryRef.of("main")
api.listTree(repository, ref, RepositoryPath.Root, 1, 100)
api.listTree(repository, ref, RepositoryPath.Root, 1, 100)
api.listTree(repository, ref, RepositoryPath.of("src"), 1, 100)
// Not the remembered tree "old": the directory is looked up afresh, by path.
assertEquals("src", transport.urls.last().toHttpUrl().pathSegments.last())
}
@Test
fun `an unresolvable ref falls back to the contents endpoint`() = runTest {
val transport = QueueTransport(
HttpResponse(404, "", emptyMap()),
response("""[{"name":"A.kt","path":"A.kt","type":"file","size":1}]"""),
)
val page = api(transport).listTree(repository, RepositoryRef.of("main"), RepositoryPath.Root, 1, 100)
assertEquals("A.kt", page.items.single().name)
assertEquals("contents", transport.urls.last().toHttpUrl().pathSegments.last())
}
@Test @Test
fun `file content is strict base64 and returned as defensive bytes`() = runTest { fun `file content is strict base64 and returned as defensive bytes`() = runTest {
val raw = "한글 source".toByteArray() val raw = "한글 source".toByteArray()

View File

@ -0,0 +1,18 @@
package com.ubisam.ubgitea.feature.codebrowser
import org.junit.Assert.assertEquals
import org.junit.Test
class SplitTest {
@Test
fun `a split inside the limits is kept`() {
assertEquals(0.35f, clampSplit(0.35f), 0f)
}
@Test
fun `neither pane can be dragged below a fifth of the width`() {
assertEquals(0.2f, clampSplit(0.05f), 0f)
assertEquals(0.8f, clampSplit(0.97f), 0f)
assertEquals(0.2f, clampSplit(-1f), 0f)
}
}

Binary file not shown.

View File

@ -1351,3 +1351,19 @@ This is the append-only implementation and handoff record shared by Codex and Cl
Home timeline was seen on the device rendering real activity correctly. This task's own changes Home timeline was seen on the device rendering real activity correctly. This task's own changes
have not been looked at on the device by the implementer. have not been looked at on the device by the implementer.
- Next owner: the product owner, to confirm each item; then Claude for whatever that turns up. - Next owner: the product owner, to confirm each item; then Claude for whatever that turns up.
## 2026-09-28 (continued) — TASK-056: reading gestures, a resizable pair, fast directory listing
- Request: the human product owner asked for the outline to open with a swipe from the left edge
(also with `.h` and `.cpp` side by side), for a draggable divider between the pair, for folders
with many files to load faster, and for a progress bar with a percentage if that was possible.
- What changed: see `tasks/056-reading-gestures-and-fast-listing.md`.
- Why listing was slow: the contents endpoint looks up the last commit of every entry it returns.
Directories are now read from Git's tree object, which does not.
- What the percentage is and is not: it is entries received out of the total the server reports,
so it only appears when a directory needs more than one request (over 100 entries). A single
file has no percentage; it arrives in one response.
- Checks and results: the required checks pass; 427 unit tests, 0 failures; lint 0 errors.
- 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.

View File

@ -0,0 +1,97 @@
# TASK-056: Reading gestures, a resizable pair, and fast directory listing
Status: implemented; required checks pass; installed on the product owner's Z Fold7, awaiting their confirmation
Owner: Claude
Reviewer: human product owner
## Outcome
Second round of requests from the human product owner after using the app on their Galaxy Z Fold7
(SM-F966N), 2026-09-28.
## Scope
- Included:
1. Code viewer: swiping right from the far left edge brings the outline tree in; swiping left
puts it away. This also works while a `.h` and a `.cpp` are shown side by side.
2. Side-by-side pair: a handle between the two panes is dragged to resize them. Dragging left
gives the `.cpp` (right) more room, dragging right gives the `.h` (left) more room. Neither
pane can be made narrower than 20% of the width.
3. Directory listing is read from Git's tree object (`/repos/{owner}/{repo}/git/trees/{sha}`)
instead of the contents endpoint. The contents endpoint looks up the last commit of every
entry, so its cost grows with the number of files.
4. A directory listing that takes more than one request shows a progress bar with the percentage
and the count ("40% · 200 / 500개").
- Excluded:
- A percentage while a single file loads. A file arrives as one response; there is nothing to
measure it against until it has arrived. The indeterminate indicator stays.
- A percentage for a directory that fits in one request (up to 100 entries): it goes from
nothing to complete in one step, so there is no intermediate value to show.
## Writable paths
- `app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/`
- `app/src/main/res/values/strings_codebrowser.xml`
- `app/src/test/java/com/ubisam/ubgitea/`
- `tasks/`, `docs/`
## Acceptance criteria
- [x] The outline opens from the left edge and closes with a swipe left, with one pane or two.
- [x] The split is draggable, clamped, and survives rotation (`rememberSaveable`).
- [x] The root of a ref is listed from the tree of that ref's commit; a directory reached by
walking down is listed from its own tree.
- [x] A ref containing `/` works: it is passed as a query parameter, never as a path segment.
- [x] When the ref moves, directory ids remembered for the old commit are dropped.
- [x] A directory whose tree id is not known (opened directly, not by walking down), or a ref the
commit lookup cannot resolve, is still listed through the contents endpoint.
- [x] Symbolic links (mode `120000`) and submodules (`commit`) keep their types.
- [x] Unit tests cover each of the above for the listing, and the split's clamp.
## How the listing works
A tree is addressed by its id, and a directory's id is only known from the listing of its parent.
1. Root: `GET commits?sha={ref}&limit=1&stat=false&files=false&verification=false` gives the
commit; `GET git/trees/{commit}` gives the root's entries.
2. Each listing remembers the id of every directory in it, keyed by repository, ref and path.
3. A subdirectory is listed with `GET git/trees/{remembered id}`.
4. Every listing of the root compares the commit with the one remembered. If it changed, all ids
remembered for that ref are dropped.
The remembered ids live in memory only, for the life of the process.
## Security and privacy
No additional concerns. The same authenticated transport and the same host are used; no new data
is stored, and nothing is logged.
## Required checks
```text
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
```
## Evidence
- Required checks pass: 427 unit tests, 0 failures; lint 0 errors.
- Measured from a PC against the company Gitea 1.23.5, on a public repository, a directory of 30
entries: contents endpoint about 0.33 s, tree endpoint 0.04 to 0.06 s, commit lookup 0.05 s.
These are request times from a PC, not timings taken inside the app.
- Installed on the SM-F966N over wireless `adb`.
- Not verified by the implementer on the device: the gestures, the handle, the listing speed as
felt in the app, and the progress bar. No directory with more than 100 entries was opened, so
the progress bar has only been exercised by reasoning and by the paging unit test.
## Known limitations
- Opening a subdirectory costs one request; opening the root costs two (commit, then tree).
- A subdirectory opened after the ref moved, without the root being listed again, shows the tree
of the commit it was remembered from until the root is next listed.
- The tree endpoint gives no last-commit information per entry. The listing never showed any, so
nothing visible is lost.
## Review findings
None yet.