From fa87a0b32fd8bf3ef02260760061b19fd1a734ff Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Mon, 28 Sep 2026 15:29:10 +0900 Subject: [PATCH] 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 --- .../repositories/GiteaRepositoryBrowserApi.kt | 104 ++++++++++ .../feature/codebrowser/CodeBrowserScreen.kt | 20 +- .../codebrowser/CodeBrowserViewModel.kt | 24 ++- .../feature/codebrowser/CodeViewerScreen.kt | 186 ++++++++++++++++-- .../main/res/values/strings_codebrowser.xml | 2 + .../GiteaRepositoryBrowserApiTest.kt | 104 ++++++++++ .../ubgitea/feature/codebrowser/SplitTest.kt | 18 ++ docs/UBGitea_기술소개.pptx | Bin 725798 -> 725845 bytes docs/development-log.md | 16 ++ .../056-reading-gestures-and-fast-listing.md | 97 +++++++++ 10 files changed, 557 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/SplitTest.kt create mode 100644 tasks/056-reading-gestures-and-fast-listing.md diff --git a/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt b/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt index ebd74a0..5134834 100644 --- a/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt +++ b/app/src/main/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApi.kt @@ -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 { 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(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(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 { 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() + + 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?, + 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" + } } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt index 6803ceb..87a320a 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt @@ -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 diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt index 91b6805..3f8fb73 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserViewModel.kt @@ -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 { it.type != TreeEntryType.DIRECTORY }.thenBy { it.name.lowercase() }) } diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt index 1ce6f91..f567188 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeViewerScreen.kt @@ -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(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 diff --git a/app/src/main/res/values/strings_codebrowser.xml b/app/src/main/res/values/strings_codebrowser.xml index 0c688e4..7dba76c 100644 --- a/app/src/main/res/values/strings_codebrowser.xml +++ b/app/src/main/res/values/strings_codebrowser.xml @@ -5,6 +5,7 @@ 태그 커밋 최상위 + %1$d%% · %2$d / %3$d개 이 폴더는 비어 있습니다. 폴더 파일 @@ -74,5 +75,6 @@ 짝 파일을 불러오는 중입니다. 짝 파일을 불러오지 못했습니다. 짝 파일은 텍스트로 표시할 수 없습니다. + 창 너비 조절. 좌우로 끌어 조절하고, 두 번 누르면 반반으로 되돌립니다. 더 보기 diff --git a/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt b/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt index 0133603..321590d 100644 --- a/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt +++ b/app/src/test/java/com/ubisam/ubgitea/data/repositories/GiteaRepositoryBrowserApiTest.kt @@ -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() diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/SplitTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/SplitTest.kt new file mode 100644 index 0000000..e85ad8a --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/SplitTest.kt @@ -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) + } +} diff --git a/docs/UBGitea_기술소개.pptx b/docs/UBGitea_기술소개.pptx index bdbd3664d784eaf2bb1292eae15ab61fc3c8525e..6650630218de3803f032484e7e63cce9dd27dd1f 100644 GIT binary patch delta 3291 zcmYjT2~bp57VZCkFQ4}s>4sE;(h^+a+ANBI0xr0WaY>LQMj=X=ijGS%#HB`~7$b?s zl>uz@8RCM$Ery{mQ28bXbt;*f8fP#NH0mTSIMJy{6G2pDpMUTDOr(l`|9kh`d(OS@ z^?U!=G0){=9{ZeGikX?WAvGV!!EB!c9JgwOqM)^MlNru47v4qm-k1x zKfJcf9x%g{(YmQvpmdKw8@X8qdpo+yrk2DcEnN8fPySJHeC#&aJbGM0v^OR(m0q?b z#scnK;@|+VcV>1$SrtFO`6iB2vu8{C*n4Qh9hm;`-GvImHu*4p-3RYUeIUJ4$xNFO zYBJ3T70H(+SH+qw^FskxQ(Pz`e`HZnfw$BPXA+XlqB&3YR4hJu6Id#K zS6cNvy0(svC=)3cq=6L?k8Y#O+4@f_KyGw7k-53&!5auIo~K43RP$dXAy#W%EFoAs z`<8@gtu{+q^O_#6&A1>j60~OZ{vH%09G%P}o@7Z$6&qW*MXRorUY0QRxtY5-3e;Yy zlTe`+ds)hzi_RuX>>=XyjgX1A#v>E&ff#-LfUZ1N&P+x4)zXoFCwRhE10J^BfH&kB z@a_V+`gMq_Zk8D)xYnX$Fu{f;A*V^q)pnW0eAy{W836w}T`{U!7WmZ-tV(;hKr#Bf zqBZ3NMv6p60bSN$m=ALvsi(xtese_gpy3>@d<-TX5$wMRaJXhp{fp^Q>dy_ ze^tVkKwRxGvmIsL9AU18<+t9#qkPVf205hCBrt=`xzu)DQQl}6%}GkRi}SyW{n`v-VA zILJcerO#Q2JlkU-On#&#ZvkKi>@+Y@xdx`&t7A~$_)AB}%uO?y=BAOrxV@G|X%Mvk zz8-{1w-iNJiGn2CP%%-}cg9c6mXB#Mawc9sB62*I2(9#qvc>Qj&J0p+68}g-;k+|g_$IM-jCvNj7$2(|{a2%}zea_uidT(70zTTkjJh#>f=b;OK1nrW^P)=hNJugM zs7gD^UaA_q<(&mVgL1=7rrdC-)1^%*Q@1;$c&+-Z8sxzJ6nAoWLb_}+rOP6@#^%=8 zck0`iExT6FhTZ<;`HPf{}F*LgwUzoBDLkItlJHbo^=Yq zn_E~C*r-VFIB?I#@JD z<0XVz9(1rMoWe^7xh8fJS9T}KI@?LI>|KVe?o`ualJ%!9f}ZL!xbV6`E=RY)6?A+3 zX9Br0x~cBGZc_N)ZXy`bW3(t|_^G!DTG(UA!s`cR)%PIRp8g#Ax+jxEulAI2SfxE3 z96R~4#Hl@apP-JiX)qI#B~ttIP(^mK29xucA+#H*?(hU1lOkwh%$Sg$Qm~h#=ld z{%kAxFIYKk8QVH_4W$}mqf{$xhL+PQm30EO)Y=R!cx#}RaMjR)_XTRnQYpb_DskOc ziEDx%Y1!pRTJHE61lk9Aj0mPNg8(lDBq(GC0p1BnaGMdqv)o9KfA{20O7I>x60ADk zKa#q2k#jgW_S%Ux%}%Ng*h!VoPO1|8`8+)2o;vOi`SUpJ{l;xYzaW8<9;KHQFeJcdE8vNT>51(jyE_|~60(TB$|=$7I|K_8C4t?EA9ZJs?zHF;C}yZX$T zCCSg2dcVMhGl095mr8&qSWFM#@Hs3pVKVcmD(XFQs3;2H@R2RbU^c2$6)ROfK>Vs> zpM_7=-%fIq$%P;Gy^pD4u!F+~k(lMsLoMGCi++%EvCCmpGj`mDChVPZu+|{FQNj{NZ~h6Z{AF7Jzn%C=cZDJt3|_=y(egBQ312a0GFO K^_eE<ELsZXP|@7M zSo!h;4IowicuD0GIKT0ciKQa-q%@*@;=OA)`TeO-w>1he6 zt7g2*(WN%KUb+G{w1@_&U=B_;TOxLmZqs;~bZbLp(*0<%zKH-Yi;y$Z5PrU7!aoQe zyU~CLziq&86&moqBDv;|5LeS8GbFgsrely`?ehT@WafOAOlJPxBTE?o|9ZY+Wc7mI zS3hD_+T8_;vDn$|sh`tgzbJ7gewnXCY4%6u)so0tqCkynpD$5@$fo17siVl|?Y}9M zRb7QbS$$lkP*#VIDZ$S}T-{i+H`=^1-dqFQqm_o62g_I%`GL2;T9a!st;rSHm1ckK z#$@w5xtK}yVG$>MGOQ!T%VyoryGP7XT5W{7UJ})2Zt+CqLvd^C4;z%JM|4UUSzKw3 zehwZ!9%&)jlF=5DT@z{{%$7-(!UcdCzS+Qp6&RR4kB&iWQ_k#rbWXa-G$);`MQ*n& zPKThKcl03im|yI!6h-m2hqZGpmff@ic?p+46?vhSiCRgABf+GssC$J=Vi>vX>)e^Si$*4MZ!);6%`xtv?Y%*Sl-u4~I5 zvz>Sc6HdQfm4%z4Wz^b!qWUlHcJ3q}*{5wrDo?%{Y$M_WKAdT(7i?$g7)Z_P|Ia0T zQ7;*bI^&|PK3H;lhHsr9%4W-@T6>o*#w;r+IwK2r3+#24Z`&oe)^k)H49C9a_+AxMJJB&~n?ZYwcD7F4L$aSd zaq0_^Sfc8+bKa1B?+5ivFhnlyRIOOxOS{z~to{A~uUD|XRu1(#a}zL07IqFE#E51F zr@eTlnZfBT3YwW4+Nx$232jp=bBVz%431#oZedfPUEadt43JCwvxN;cCSBWG*>HHi z*rrD%v@*EcAj%~+{CeiNF{6`5{ST4YMzOlGORR5uKq@MX8J+d_01vs#;-SsB%cev7 z$z7HJ?N4`!&UKH)V-dnlB{J{P=m+;$GN5kUW7D7=d!O1D?^9A=+$WZsMt@v8n+dqr z+Sxp4Pq(u~XoqyL6lBMHD6yA0#Ty+g45rFD*fY?oodiwpG@uu%HCZ6^%T5-C(XaV8 zXu>UVr;xi?EJRK1qNuzsmID3nyU4Jsn^;$MvqTI%muU|cX`Q-oeK&LC5Z*p0FucbQ zz}p4|mi3UpfgTdL-9rL#y(}4{`_fEH$jhg_1ijj8XyHABS_yqr87un8*3mw)?w{T>ng&qAFyLh0!B(+`?pq)0Ul>z4IG!%u8SNFBOg@+^X>D zxCVHOlv(wur_4MYhc2&-tQTpDKD5VdXy&Kd4-&y0GZBPZNGsbyT2&TIMEF?wT-e3u ztTcbX%4Z^L#kJav#5%-gu-aCuImEiiMy$JShOLU!!}S7eZC4Ffc$J{5RF&hqnx~S~ z4=PCody$owy~xTDFPeXd8Kl}vdYl=q@QT32a&U6>BsU1Q+&HkA z2ue8*hcp{GX@z-{)+TTA@|`z%3Gy+v01td*&G+GPcz^!ts6XYyBXO1R5C)6Ngb3Bs zxH~}Thq3;#;mf^o9uH*jtHea9qUf;MCARo-2hQP{3=)zms(rD_M2|0bnTJhNO`f!2 zPTyI{lKhZq;QL%m8^m48$i1NBFLDNP_%IfQFc`j16^)_#?eOg&4jbf(IcUoQ{kY2(-mRLlvsZ~@ zot9uR-;a!B=`+fS054$=^qPolzk^f;pYENq=cdU7|NaA6U>hy2`0+9S2MYKN$XARS M%qLi{-+{&bFDRiF>i_@% diff --git a/docs/development-log.md b/docs/development-log.md index b87ba25..4ce5eec 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -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. diff --git a/tasks/056-reading-gestures-and-fast-listing.md b/tasks/056-reading-gestures-and-fast-listing.md new file mode 100644 index 0000000..e8c688f --- /dev/null +++ b/tasks/056-reading-gestures-and-fast-listing.md @@ -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.