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:
parent
9046985ba4
commit
fa87a0b32f
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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() })
|
||||
}
|
||||
|
||||
@ -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)
|
||||
@ -318,6 +395,7 @@ fun CodeViewerScreen(
|
||||
if (layout != ViewerLayout.LANDSCAPE) Toolbar(tools)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (outlineOpen) {
|
||||
OutlineSheet(
|
||||
@ -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,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()) {
|
||||
(if (currentIsHeader) current else other)(Modifier.weight(1f).fillMaxHeight())
|
||||
VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
(if (currentIsHeader) other else current)(Modifier.weight(1f).fillMaxHeight())
|
||||
(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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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.
@ -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.
|
||||
|
||||
97
tasks/056-reading-gestures-and-fast-listing.md
Normal file
97
tasks/056-reading-gestures-and-fast-listing.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user