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.io.IOException
import java.util.Base64
import java.util.concurrent.ConcurrentHashMap
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
@ -180,6 +181,15 @@ class GiteaRepositoryBrowserApi(
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(
repository: RepositoryCoordinates,
ref: RepositoryRef,
@ -188,6 +198,82 @@ class GiteaRepositoryBrowserApi(
pageSize: Int,
): Page<TreeEntry> {
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())
.newBuilder()
.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 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(
val owner: OwnerDto?,
val name: String?,
@ -431,4 +530,9 @@ class GiteaRepositoryBrowserApi(
private data class CommitDto(val sha: String?, val commit: CommitDataDto?)
private data class CommitDataDto(val message: String?, val author: CommitAuthorDto?)
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.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.res.stringResource
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.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@ -116,7 +119,8 @@ fun CodeBrowserScreen(
CodeBrowserUiState.Idle -> Unit
is CodeBrowserUiState.Loading -> {
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 -> {
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
private fun BrowserError(type: CodeBrowserError, onRetry: () -> Unit, onAuthenticationExpired: () -> Unit) {
val expired = type == CodeBrowserError.AUTHENTICATION_EXPIRED

View File

@ -54,7 +54,19 @@ sealed interface CodeBrowserUiState {
val target: CodeBrowserTarget,
val path: RepositoryPath,
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(
val target: CodeBrowserTarget,
val path: RepositoryPath,
@ -211,8 +223,16 @@ class CodeBrowserViewModel(
mutableState.value = CodeBrowserUiState.Loading(target, path, isFile = false)
try {
val entries = withContext(defaultDispatcher) {
var loaded = 0
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)
.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.background
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.interaction.collectIsDraggedAsState
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.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@ -41,19 +46,24 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@ -62,7 +72,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
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.UBIcon
import com.ubisam.ubgitea.ui.shell.WideLayoutBreakpoint
import kotlin.math.abs
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
@ -170,11 +185,28 @@ fun CodeViewerScreen(
var focusedLine by remember(state.path) { mutableStateOf<Int?>(null) }
var sideBySide 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) {
focusedLine = line
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) {
if (matches.isEmpty()) return
currentMatch = index.mod(matches.size)
@ -194,6 +226,7 @@ fun CodeViewerScreen(
BackHandler {
when {
drawerState.isOpen -> scope.launch { drawerState.close() }
outlineOpen -> outlineOpen = false
searching -> {
searching = false
@ -213,6 +246,10 @@ fun CodeViewerScreen(
val counterpartPath = state.counterpartPath
val showsPair = layout == ViewerLayout.WIDE && counterpartPath != null && sideBySide
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(
preferences = preferences,
@ -234,6 +271,38 @@ fun CodeViewerScreen(
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()) {
when {
searching -> SearchBar(
@ -265,17 +334,25 @@ fun CodeViewerScreen(
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 {
showsPair -> PairPanes(
state = state,
document = document,
listState = listState,
otherDocument = otherDocument,
otherState = otherState,
preferences = preferences,
highlight = highlight,
focusedLine = focusedLine,
syncPanes = syncPanes,
counterpartPath = counterpartPath!!,
splitFraction = splitFraction,
onSplitChange = { splitFraction = it },
)
else -> {
if (layout == ViewerLayout.WIDE) {
@ -287,7 +364,7 @@ fun CodeViewerScreen(
onToggleCollapsed = toggleCollapsed,
onOpenFile = { onOpenSibling(it, null) },
onOpenEntry = onOpenEntry,
onSymbolSelected = { goToLine(it.line) },
onSymbolSelected = { goToSymbol(it) },
modifier = Modifier.width(sidePaneWidth).fillMaxHeight(),
)
VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant)
@ -317,6 +394,7 @@ fun CodeViewerScreen(
}
if (layout != ViewerLayout.LANDSCAPE) Toolbar(tools)
}
}
}
if (outlineOpen) {
@ -327,7 +405,7 @@ fun CodeViewerScreen(
onToggleCollapsed = toggleCollapsed,
onSymbolSelected = {
outlineOpen = false
goToLine(it.line)
goToSymbol(it)
},
onLineEntered = {
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
private fun PairPanes(
state: CodeBrowserUiState.File,
document: CodeDocument,
listState: LazyListState,
otherDocument: CodeDocument?,
otherState: LazyListState,
preferences: CodeViewerPreferences,
highlight: SearchHighlight,
focusedLine: Int?,
syncPanes: Boolean,
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) {
FollowSymbols(listState, document, otherState, otherDocument)
FollowSymbols(otherState, otherDocument, listState, document)
@ -742,13 +824,88 @@ private fun PairPanes(
}
}
}
Row(Modifier.fillMaxSize()) {
(if (currentIsHeader) current else other)(Modifier.weight(1f).fillMaxHeight())
VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant)
(if (currentIsHeader) other else current)(Modifier.weight(1f).fillMaxHeight())
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()) {
(if (currentIsHeader) current else other)(Modifier.weight(splitFraction).fillMaxHeight())
SplitHandle(
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
}
}
}
}
/**
* While the reader drags [leader], keeps [follower] on the declaration that matches the method at
* the top of [leader]. Only a drag leads: a scroll this effect started itself must not lead the
@ -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 MAX_LINE_DIGITS = 6

View File

@ -5,6 +5,7 @@
<string name="codebrowser_ref_tag">태그</string>
<string name="codebrowser_ref_commit">커밋</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_entry_directory">폴더</string>
<string name="codebrowser_entry_file">파일</string>
@ -74,5 +75,6 @@
<string name="codeviewer_counterpart_loading">짝 파일을 불러오는 중입니다.</string>
<string name="codeviewer_counterpart_failed">짝 파일을 불러오지 못했습니다.</string>
<string name="codeviewer_counterpart_not_text">짝 파일은 텍스트로 표시할 수 없습니다.</string>
<string name="codeviewer_split_handle">창 너비 조절. 좌우로 끌어 조절하고, 두 번 누르면 반반으로 되돌립니다.</string>
<string name="codeviewer_more">더 보기</string>
</resources>

View File

@ -236,6 +236,110 @@ class GiteaRepositoryBrowserApiTest {
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
fun `file content is strict base64 and returned as defensive bytes`() = runTest {
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
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.
## 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.