From 291fda5728290741f36a2196e1ab8e3a6459ca92 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 14:46:32 +0000 Subject: [PATCH 01/31] feat: add README and Code tabs to the git repository screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the repository README in the first tab and add a Code tab that browses the repo's file tree and renders source files (syntax-highlighted), reading directly from the NIP-34 clone URL over the git smart-HTTP v2 protocol (works with GRASP/ngit bare servers as well as GitHub/GitLab). quartz (jvmAndroid): a from-scratch git smart-HTTP v2 client — pkt-line codec, packfile parser with OFS/REF delta resolution and SHA-1 oids, tree/commit parsers, and a high-level browser that fetches a shallow filter=blob:none snapshot (one request for the whole tree) and lazily pulls file blobs on demand. Offline tests run against real captured GitHub wire bytes plus a git-generated OFS-delta pack. amethyst: README tab (rich markdown), Code tab (folders-first browser with breadcrumb navigation + a file viewer that renders markdown or syntax-highlighted source), a browser ViewModel, new UI strings, and a Folder material symbol (font subset regenerated). Syntax highlighting uses dev.snipme:highlights (Apache-2.0, permissive). Tabs are now: README, Code, Overview, Issues, Patches & PRs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- amethyst/build.gradle.kts | 3 + .../loggedIn/gitRepo/GitRepositoryScreen.kt | 55 +++- .../loggedIn/gitRepo/code/CodeHighlighter.kt | 95 ++++++ .../loggedIn/gitRepo/code/GitCodeTab.kt | 288 ++++++++++++++++++ .../loggedIn/gitRepo/code/GitFileViewer.kt | 192 ++++++++++++ .../loggedIn/gitRepo/code/GitReadmeTab.kt | 210 +++++++++++++ .../code/GitRepositoryBrowserViewModel.kt | 129 ++++++++ amethyst/src/main/res/values/strings.xml | 11 + .../font/material_symbols_outlined.ttf | Bin 458856 -> 460520 bytes .../commons/icons/symbols/MaterialSymbols.kt | 1 + gradle/libs.versions.toml | 2 + .../2026-06-28-git-smart-http-browser.md | 87 ++++++ .../quartz/nip34Git/git/GitDelta.kt | 94 ++++++ .../quartz/nip34Git/git/GitHttpClient.kt | 182 +++++++++++ .../quartz/nip34Git/git/GitObject.kt | Bin 0 -> 4456 bytes .../nip34Git/git/GitSmartHttpTransport.kt | 149 +++++++++ .../quartz/nip34Git/git/GitUploadPackV2.kt | 143 +++++++++ .../quartz/nip34Git/git/Packfile.kt | 243 +++++++++++++++ .../quartz/nip34Git/git/PktLine.kt | 134 ++++++++ .../nip34Git/git/GitPackfileDeltaTest.kt | 129 ++++++++ .../quartz/nip34Git/git/GitProtocolV2Test.kt | 108 +++++++ 21 files changed, 2249 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/CodeHighlighter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt create mode 100644 quartz/plans/2026-06-28-git-smart-http-browser.md create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitDelta.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/Packfile.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/PktLine.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitPackfileDeltaTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitProtocolV2Test.kt diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index ae681168a5..1c6dc4e336 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -457,6 +457,9 @@ dependencies { implementation(libs.markdown.ui.material3) implementation(libs.markdown.commonmark) + // Syntax highlighting for the git repository code browser (Apache-2.0) + implementation(libs.highlights) + // LaTeX math rendering ($...$ and $$...$$ inline equations) implementation(libs.jlatexmath.android) implementation(libs.jlatexmath.font.greek) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index 87d70f071f..03df150470 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope @@ -49,6 +50,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote @@ -64,6 +66,9 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.GitCodeTab +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.GitReadmeTab +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.GitRepositoryBrowserViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryIssuesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryPatchesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource.RepositoryFilterAssemblerSubscription @@ -120,12 +125,19 @@ private fun PrepareGitRepositoryScreen( factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true), ) + val browserViewModel: GitRepositoryBrowserViewModel = + viewModel( + key = note.idHex + "GitRepoBrowser", + factory = GitRepositoryBrowserViewModel.Factory(accountViewModel.httpClientBuilder::okHttpClientForPreview), + ) + GitRepositoryScreen( note = note, openIssuesViewModel = openIssuesViewModel, closedIssuesViewModel = closedIssuesViewModel, openPatchesViewModel = openPatchesViewModel, closedPatchesViewModel = closedPatchesViewModel, + browserViewModel = browserViewModel, accountViewModel = accountViewModel, nav = nav, ) @@ -139,6 +151,7 @@ private fun GitRepositoryScreen( closedIssuesViewModel: RepositoryIssuesFeedViewModel, openPatchesViewModel: RepositoryPatchesFeedViewModel, closedPatchesViewModel: RepositoryPatchesFeedViewModel, + browserViewModel: GitRepositoryBrowserViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -149,6 +162,13 @@ private fun GitRepositoryScreen( val event by observeNoteEvent(note, accountViewModel) + // Start the smart-HTTP browser as soon as the repository announcement arrives, + // so the README and Code tabs can fetch from its clone URL. + LaunchedEffect(event) { + event?.let { browserViewModel.loadOnce(it.clones()) } + } + val browserState by browserViewModel.state.collectAsStateWithLifecycle() + // RepositoryContentSubAssembler.updateFilter reads note.event and bails out if it isn't // a GitRepositoryEvent yet. The compose subscription manager doesn't re-run updateFilter // when note.event later mutates, so subscribing before the event has arrived (cold-start @@ -159,7 +179,7 @@ private fun GitRepositoryScreen( RepositoryFilterAssemblerSubscription(note, accountViewModel.dataSources().gitRepository) } - val pagerState = rememberForeverPagerState(note.idHex + "GitRepoScreenPagerState") { 3 } + val pagerState = rememberForeverPagerState(note.idHex + "GitRepoScreenPagerState") { 5 } DisappearingScaffold( isInvertedLayout = false, @@ -185,19 +205,29 @@ private fun GitRepositoryScreen( val coroutineScope = rememberCoroutineScope() Tab( selected = pagerState.currentPage == 0, - text = { Text(stringRes(R.string.git_repo_tab_overview)) }, + text = { Text(stringRes(R.string.git_repo_tab_readme)) }, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, ) Tab( selected = pagerState.currentPage == 1, - text = { Text(stringRes(R.string.git_repo_tab_issues)) }, + text = { Text(stringRes(R.string.git_repo_tab_code)) }, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, ) Tab( selected = pagerState.currentPage == 2, - text = { Text(stringRes(R.string.git_repo_tab_patches)) }, + text = { Text(stringRes(R.string.git_repo_tab_overview)) }, onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, ) + Tab( + selected = pagerState.currentPage == 3, + text = { Text(stringRes(R.string.git_repo_tab_issues)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(3) } }, + ) + Tab( + selected = pagerState.currentPage == 4, + text = { Text(stringRes(R.string.git_repo_tab_patches)) }, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(4) } }, + ) } } }, @@ -211,13 +241,26 @@ private fun GitRepositoryScreen( 0 -> { val currentEvent = event if (currentEvent != null) { - GitRepositoryOverview(currentEvent, accountViewModel, nav) + GitReadmeTab(browserState, browserViewModel, currentEvent, accountViewModel, nav) } else { EmptyMessage(stringRes(R.string.loading_feed)) } } 1 -> { + GitCodeTab(browserState, browserViewModel, accountViewModel, nav) + } + + 2 -> { + val currentEvent = event + if (currentEvent != null) { + GitRepositoryOverview(currentEvent, accountViewModel, nav) + } else { + EmptyMessage(stringRes(R.string.loading_feed)) + } + } + + 3 -> { StatusSplitFeed( persistKey = note.idHex + "GitRepoIssuesStatus", openViewModel = openIssuesViewModel, @@ -227,7 +270,7 @@ private fun GitRepositoryScreen( ) } - 2 -> { + 4 -> { StatusSplitFeed( persistKey = note.idHex + "GitRepoPatchesStatus", openViewModel = openPatchesViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/CodeHighlighter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/CodeHighlighter.kt new file mode 100644 index 0000000000..10d0e034b5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/CodeHighlighter.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import dev.snipme.highlights.Highlights +import dev.snipme.highlights.model.BoldHighlight +import dev.snipme.highlights.model.ColorHighlight +import dev.snipme.highlights.model.SyntaxLanguage +import dev.snipme.highlights.model.SyntaxThemes + +/** + * Builds a syntax-highlighted [AnnotatedString] for a source file using the + * `dev.snipme:highlights` KMP tokenizer (Apache-2.0). Highlighting is CPU work, + * so callers should invoke [highlight] off the main thread. + */ +object CodeHighlighter { + /** Maps a file name to a supported language, or null when no highlighter fits. */ + fun languageForFile(name: String): SyntaxLanguage? { + val ext = name.substringAfterLast('.', "").lowercase() + return when (ext) { + "kt", "kts" -> SyntaxLanguage.KOTLIN + "java" -> SyntaxLanguage.JAVA + "js", "mjs", "cjs", "jsx" -> SyntaxLanguage.JAVASCRIPT + "ts", "tsx" -> SyntaxLanguage.TYPESCRIPT + "py" -> SyntaxLanguage.PYTHON + "rb" -> SyntaxLanguage.RUBY + "rs" -> SyntaxLanguage.RUST + "go" -> SyntaxLanguage.GO + "c", "h" -> SyntaxLanguage.C + "cpp", "cc", "cxx", "hpp", "hh", "hxx" -> SyntaxLanguage.CPP + "cs" -> SyntaxLanguage.CSHARP + "swift" -> SyntaxLanguage.SWIFT + "php" -> SyntaxLanguage.PHP + "pl", "pm" -> SyntaxLanguage.PERL + "dart" -> SyntaxLanguage.DART + "coffee" -> SyntaxLanguage.COFFEESCRIPT + "sh", "bash", "zsh", "ksh" -> SyntaxLanguage.SHELL + else -> null + } + } + + fun highlight( + code: String, + language: SyntaxLanguage, + darkMode: Boolean, + ): AnnotatedString { + val highlights = + Highlights + .Builder() + .code(code) + .language(language) + .theme(SyntaxThemes.darcula(darkMode = darkMode)) + .build() + .getHighlights() + + return buildAnnotatedString { + append(code) + val len = code.length + highlights.forEach { highlight -> + val start = highlight.location.start.coerceIn(0, len) + val end = highlight.location.end.coerceIn(start, len) + if (start == end) return@forEach + when (highlight) { + is ColorHighlight -> + addStyle(SpanStyle(color = Color(0xFF000000L or (highlight.rgb.toLong() and 0xFFFFFF))), start, end) + is BoldHighlight -> + addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt new file mode 100644 index 0000000000..096b1ec504 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot +import com.vitorpamplona.quartz.nip34Git.git.GitTreeEntry + +@Composable +fun GitCodeTab( + state: GitBrowseState, + viewModel: GitRepositoryBrowserViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scaffoldPadding = LocalDisappearingScaffoldPadding.current + when (state) { + is GitBrowseState.Loading -> + CenteredStatus(stringRes(R.string.git_repo_code_loading), Modifier.padding(scaffoldPadding)) + + is GitBrowseState.Error -> + CenteredStatus( + text = + if (state.message == GitRepositoryBrowserViewModel.NO_CLONE_URL) { + stringRes(R.string.git_repo_no_clone_url) + } else { + stringRes(R.string.git_repo_code_error) + }, + modifier = Modifier.padding(scaffoldPadding), + onRetry = if (state.message == GitRepositoryBrowserViewModel.NO_CLONE_URL) null else viewModel::reload, + ) + + is GitBrowseState.Loaded -> + CodeBrowser(state.snapshot, viewModel, accountViewModel, nav, scaffoldPaddingTop = scaffoldPadding) + } +} + +@Composable +private fun CodeBrowser( + snapshot: GitRepoSnapshot, + viewModel: GitRepositoryBrowserViewModel, + accountViewModel: AccountViewModel, + nav: INav, + scaffoldPaddingTop: PaddingValues, +) { + var pathString by rememberSaveable(snapshot.headCommit) { mutableStateOf("") } + var openFilePath by rememberSaveable(snapshot.headCommit) { mutableStateOf(null) } + + val path = remember(pathString) { if (pathString.isEmpty()) emptyList() else pathString.split("/") } + val openPath = openFilePath?.let { if (it.isEmpty()) emptyList() else it.split("/") } + + if (openPath != null) { + val entry = remember(openFilePath) { snapshot.entryAt(openPath) } + BackHandler { openFilePath = null } + Column(Modifier.fillMaxSize().padding(scaffoldPaddingTop)) { + FileHeader(name = openPath.lastOrNull() ?: "", onBack = { openFilePath = null }) + HorizontalDivider(thickness = 0.5.dp) + if (entry == null) { + CenteredStatus(stringRes(R.string.git_repo_file_load_error), Modifier) + } else { + GitFileViewer( + snapshot = snapshot, + viewModel = viewModel, + entry = entry, + accountViewModel = accountViewModel, + nav = nav, + modifier = Modifier.fillMaxSize(), + ) + } + } + return + } + + BackHandler(enabled = path.isNotEmpty()) { pathString = path.dropLast(1).joinToString("/") } + + val entries = remember(snapshot, pathString) { snapshot.entriesAt(path).orEmpty() } + + Column(Modifier.fillMaxSize().padding(scaffoldPaddingTop)) { + Breadcrumb( + path = path, + onNavigate = { depth -> pathString = path.take(depth).joinToString("/") }, + ) + HorizontalDivider(thickness = 0.5.dp) + if (entries.isEmpty()) { + CenteredStatus(stringRes(R.string.git_repo_empty_folder), Modifier) + } else { + LazyColumn(Modifier.fillMaxSize()) { + items(entries, key = { it.name }) { entry -> + EntryRow( + entry = entry, + onClick = { + val child = (path + entry.name).joinToString("/") + if (entry.isFolder) pathString = child else openFilePath = child + }, + ) + } + } + } + } +} + +@Composable +private fun Breadcrumb( + path: List, + onNavigate: (depth: Int) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Crumb(label = stringRes(R.string.git_repo_root), enabled = path.isNotEmpty()) { onNavigate(0) } + path.forEachIndexed { index, segment -> + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = null, + modifier = Modifier.size(16.dp).padding(horizontal = 2.dp), + tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.4f), + ) + Crumb(label = segment, enabled = index < path.lastIndex) { onNavigate(index + 1) } + } + } +} + +@Composable +private fun Crumb( + label: String, + enabled: Boolean, + onClick: () -> Unit, +) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (enabled) FontWeight.Normal else FontWeight.SemiBold, + color = + if (enabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + maxLines = 1, + modifier = if (enabled) Modifier.clickable(onClick = onClick) else Modifier, + ) +} + +@Composable +private fun EntryRow( + entry: GitTreeEntry, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + val symbol = + when { + entry.isFolder -> MaterialSymbols.Folder + entry.isSubmodule -> MaterialSymbols.Code + else -> MaterialSymbols.Description + } + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = + if (entry.isFolder) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) + }, + ) + Text( + text = entry.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun FileHeader( + name: String, + onBack: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton(onClick = onBack) { ArrowBackIcon() } + Text( + text = name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun CenteredStatus( + text: String, + modifier: Modifier, + onRetry: (() -> Unit)? = null, +) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(24.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + ) + if (onRetry != null) { + Button(onClick = onRetry) { Text(stringRes(R.string.git_repo_retry)) } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt new file mode 100644 index 0000000000..bd5436c26b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.ui.components.RichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot +import com.vitorpamplona.quartz.nip34Git.git.GitTreeEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException + +/** + * Renders a single file from the repository. Markdown files render as rich text, + * text files render with syntax highlighting, and binary files show a notice. + */ +@Composable +fun GitFileViewer( + snapshot: GitRepoSnapshot, + viewModel: GitRepositoryBrowserViewModel, + entry: GitTreeEntry, + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + val result by + produceState?>(null, entry.oid) { + value = + try { + Result.success(viewModel.readBlob(snapshot, entry.oid)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + } + + val bytes = result + when { + bytes == null -> CenteredMessage(stringRes(R.string.git_repo_code_loading), modifier) + bytes.isFailure -> CenteredMessage(stringRes(R.string.git_repo_file_load_error), modifier) + else -> { + val data = bytes.getOrThrow() + when { + isProbablyBinary(data) -> + CenteredMessage(stringRes(R.string.git_repo_binary_file, humanSize(data.size)), modifier) + isMarkdownFile(entry.name) -> + MarkdownFile(data.decodeToString(), accountViewModel, nav, modifier) + else -> + HighlightedCode(data.decodeToString(), entry.name, modifier) + } + } + } +} + +@Composable +private fun MarkdownFile( + content: String, + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier, +) { + val background = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(background) } + Column( + modifier = + modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + RichTextViewer( + content = content, + canPreview = true, + quotesLeft = 1, + modifier = Modifier.fillMaxWidth(), + tags = EmptyTagList, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +private fun HighlightedCode( + code: String, + fileName: String, + modifier: Modifier, +) { + val darkMode = isSystemInDarkTheme() + val annotated by + produceState(AnnotatedString(code), code, fileName, darkMode) { + value = + withContext(Dispatchers.Default) { + val language = CodeHighlighter.languageForFile(fileName) + if (language == null) AnnotatedString(code) else CodeHighlighter.highlight(code, language, darkMode) + } + } + + Text( + text = annotated, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + lineHeight = 18.sp, + softWrap = false, + color = MaterialTheme.colorScheme.onBackground, + modifier = + modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) +} + +@Composable +private fun CenteredMessage( + text: String, + modifier: Modifier, +) { + Column( + modifier = modifier.fillMaxSize().padding(24.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + ) + } +} + +private fun isMarkdownFile(name: String): Boolean { + val ext = name.substringAfterLast('.', "").lowercase() + return ext == "md" || ext == "markdown" || ext == "mdown" || ext == "mkd" +} + +/** Heuristic: a NUL byte in the first chunk means the content isn't text. */ +private fun isProbablyBinary(data: ByteArray): Boolean { + val limit = minOf(data.size, 8000) + for (i in 0 until limit) if (data[i].toInt() == 0) return true + return false +} + +private fun humanSize(bytes: Int): String = + when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + else -> "${bytes / (1024 * 1024)} MB" + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt new file mode 100644 index 0000000000..1f9e4d5fd7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding +import com.vitorpamplona.amethyst.ui.components.RichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot +import com.vitorpamplona.quartz.nip34Git.git.GitTreeEntry +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import kotlin.coroutines.cancellation.CancellationException + +/** + * First tab of the repository screen. Renders the repository's README as rich + * markdown when it can be fetched, otherwise falls back to the announcement's + * own description so the tab is never empty. + */ +@Composable +fun GitReadmeTab( + state: GitBrowseState, + viewModel: GitRepositoryBrowserViewModel, + event: GitRepositoryEvent, + accountViewModel: AccountViewModel, + nav: INav, +) { + val scaffoldPadding = LocalDisappearingScaffoldPadding.current + + val snapshot = (state as? GitBrowseState.Loaded)?.snapshot + val readme = remember(snapshot) { snapshot?.let { findReadme(it.rootEntries()) } } + + if (snapshot != null && readme != null) { + ReadmeContent(snapshot, viewModel, readme, accountViewModel, nav, scaffoldPaddingTop = scaffoldPadding) + } else { + ReadmeFallback( + event = event, + loading = state is GitBrowseState.Loading, + accountViewModel = accountViewModel, + nav = nav, + scaffoldPaddingTop = scaffoldPadding, + ) + } +} + +@Composable +private fun ReadmeContent( + snapshot: GitRepoSnapshot, + viewModel: GitRepositoryBrowserViewModel, + readme: GitTreeEntry, + accountViewModel: AccountViewModel, + nav: INav, + scaffoldPaddingTop: PaddingValues, +) { + val content by + produceState(null, readme.oid) { + value = + try { + viewModel.readBlob(snapshot, readme.oid).decodeToString() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + "" + } + } + + when (val text = content) { + null -> StatusText(stringRes(R.string.git_repo_code_loading), scaffoldPaddingTop) + "" -> StatusText(stringRes(R.string.git_repo_file_load_error), scaffoldPaddingTop) + else -> { + val background = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(background) } + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(scaffoldPaddingTop) + .padding(horizontal = 12.dp, vertical = 12.dp), + ) { + RichTextViewer( + content = text, + canPreview = true, + quotesLeft = 1, + modifier = Modifier.fillMaxWidth(), + tags = EmptyTagList, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Composable +private fun ReadmeFallback( + event: GitRepositoryEvent, + loading: Boolean, + accountViewModel: AccountViewModel, + nav: INav, + scaffoldPaddingTop: PaddingValues, +) { + val description = event.description()?.takeIf { it.isNotBlank() } + if (description == null) { + StatusText( + text = if (loading) stringRes(R.string.git_repo_code_loading) else stringRes(R.string.git_repo_readme_missing), + scaffoldPaddingTop = scaffoldPaddingTop, + ) + return + } + + val background = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(background) } + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(scaffoldPaddingTop) + .padding(horizontal = 12.dp, vertical = 12.dp), + ) { + Text( + text = event.name() ?: event.dTag(), + style = MaterialTheme.typography.headlineSmall, + ) + RichTextViewer( + content = description, + canPreview = true, + quotesLeft = 1, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + tags = EmptyTagList, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) + if (loading) { + Text( + text = stringRes(R.string.git_repo_code_loading), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), + modifier = Modifier.padding(top = 16.dp), + ) + } + } +} + +@Composable +private fun StatusText( + text: String, + scaffoldPaddingTop: PaddingValues, +) { + Column( + modifier = Modifier.fillMaxSize().padding(scaffoldPaddingTop).padding(24.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + ) + } +} + +/** Picks the most README-like file in the root, preferring markdown. */ +private fun findReadme(entries: List): GitTreeEntry? { + val files = entries.filter { !it.isFolder } + val readmes = files.filter { it.name.substringBeforeLast('.').equals("readme", ignoreCase = true) || it.name.equals("readme", ignoreCase = true) } + if (readmes.isEmpty()) return null + val byPreference = listOf("readme.md", "readme.markdown", "readme.mdown", "readme", "readme.txt", "readme.rst") + for (preferred in byPreference) { + readmes.firstOrNull { it.name.equals(preferred, ignoreCase = true) }?.let { return it } + } + return readmes.first() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt new file mode 100644 index 0000000000..a724320f67 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.quartz.nip34Git.git.GitHttpClient +import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import kotlin.coroutines.cancellation.CancellationException + +/** UI state for the git repository code/README browser. */ +sealed interface GitBrowseState { + object Loading : GitBrowseState + + class Loaded( + val snapshot: GitRepoSnapshot, + ) : GitBrowseState + + class Error( + val message: String, + ) : GitBrowseState +} + +/** + * Loads a shallow snapshot of a NIP-34 repository over git smart-HTTP and serves + * both the README tab and the Code browser. The snapshot lets the UI walk + * directories offline; [readBlob] lazily fetches file contents on demand. + */ +class GitRepositoryBrowserViewModel( + private val okHttpClient: (String) -> OkHttpClient, +) : ViewModel() { + private val client = GitHttpClient(okHttpClient) + + private val _state = MutableStateFlow(GitBrowseState.Loading) + val state = _state.asStateFlow() + + private var cloneUrls: List = emptyList() + private var started = false + + /** + * Loads the repository once, using the announcement's clone URLs. Safe to call + * on every recomposition; only the first call (after the event has arrived) runs. + */ + fun loadOnce(cloneUrls: List) { + if (started) return + started = true + this.cloneUrls = cloneUrls + reload() + } + + fun reload() { + _state.value = GitBrowseState.Loading + viewModelScope.launch(Dispatchers.IO) { + val candidates = candidateUrls() + if (candidates.isEmpty()) { + _state.value = GitBrowseState.Error(NO_CLONE_URL) + return@launch + } + val errors = StringBuilder() + for (url in candidates) { + try { + val snapshot = client.open(url) + _state.value = GitBrowseState.Loaded(snapshot) + return@launch + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + errors + .append(url) + .append(" → ") + .append(e.message ?: e.toString()) + .append('\n') + } + } + _state.value = GitBrowseState.Error(errors.toString().trim()) + } + } + + suspend fun readBlob( + snapshot: GitRepoSnapshot, + oid: String, + ): ByteArray = snapshot.readBlob(oid) + + /** http(s) clone URLs to try, in order, including a `.git` variant when missing. */ + private fun candidateUrls(): List { + val out = LinkedHashSet() + for (raw in cloneUrls) { + val url = raw.trim() + if (!url.startsWith("http://") && !url.startsWith("https://")) continue + out.add(url) + if (!url.removeSuffix("/").endsWith(".git")) out.add(url.removeSuffix("/") + ".git") + } + return out.toList() + } + + class Factory( + private val okHttpClient: (String) -> OkHttpClient, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T = GitRepositoryBrowserViewModel(okHttpClient) as T + } + + companion object { + const val NO_CLONE_URL = "no-clone-url" + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index fde413a38a..07fbdda10c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2578,6 +2578,17 @@ Maintainers Topics Personal fork + Readme + Code + Loading repository… + Could not load the repository from its clone URL. + This repository announcement has no http(s) clone URL to browse. + This repository has no README file. + Could not load this file. + Binary file (%1$s) — preview not available. + This folder is empty. + root + Retry Git Repositories nSite: %1$s nApplet: %1$s diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index 79ccdd87138597ca80a58d397d675f2ebc63f48c..9aeb9602e5f2b87fdfd7294d994a47737760e249 100644 GIT binary patch delta 4045 zcmZ8k33!uL)_(83-8E@a)qynlWfUU|MnO?BMvh*9^+FC#uh88+v(}%r~1{|47S>p?b zrw?CNH@6(PzMb@w6Xs2r4ZquafSaR9&!04X!Yt26@k=QFTR?GiD*0FbGJQ92qnP}) zFHV@_oJB8M;A#s=pBIZ?pBmmR{T(W92KGrWQbNHXK7y9TEa`({+fSobJ_9q;8gZZ29i35C+Ol6u>(OxsM`0ozS` zM|*~Sv3-aAZf9%fNuAepz8K0wUkd#sv^A`I*z&Nix@31*-{p3Ac=)pLD_w)T_UZaY z*S|(+BF0C26!D;2YPXlWo#@uqy|DY5?)8y=o22vR7`ejlE8%IQ>)dQ(jG3 zk(!vgA@ywP!`@lFU+%rJ_w7E~KJk4<^(pT2@4n`~f9iXx-=Kc0`W@<5pB9q#O4^om zk=`?Xb^4+7hK!huS29*+T*=gAzL!~@)g$YWiQPv`jM zq~sLlyp%I9XI;*=oX>JD0o~7hdVrg05Bcs@t73+^IV zp}pPy20APnq#?VdDpViT5Ng6`nDwwupG4< zuxzz_V%clyWf^TL?DR<|Z|B0I>9p4Tz&yje+PuA!&0Jzm2F%UGLFTFEzGk~Q)oD&L zD}auMjs|)LbZiH5M})M6-03(eBqt;!q+`g(!Hpp)Aw?ll@Ydj|rZc9L;6T81$TZpb zzVVT_jA@DSeN(b=9lZ_n>c$Jkv&Jgpa$})swdqY0H~rc8g|Q>qn~a&JJX0UTWy1yI z3&v7ohS7P_@Pl!nF#<4LG%5^jhIR;pU z=-B451!dPyL~)i+ovp73I}y#C zaV!B7nVDHw1PjMMMdjxX zA?Ru^yYqv);?|d(OMDKWTTy*FPoYsvomo72%52}?L`CHJCSL=sjy--1f3JiF5s0U? zjV(*Ln#$fPn|Jk9wzRDFs$<{*8vh86;W%!i5v_R47!ypvRLqxkU|JTy^vu9aETnz) zHrAPi(;h^!Xy#xsjP`{kv0f~VWwPwDsZ}=4rj;$NiYfcFDojO_B{DIZWYsM$vDs`+ z#Tj=8uBu^ky?d|NQ{lehVb1$uU_ZOT9+9C zGbwKNDe$@9!pY~a2VbxSyp#r75exq36!5p-1b?R%{Jo>#f4c*|jRx33wq2LO_b&%O zTmXKQ3LZNHeqsms=al=^eei$MM9wb+uRIL?T_X6UYv5NZ!K-Ld*E7Ix&|-d~N#8C4 zze{6ydON`z>mc|;2*qXy-$4)^4nqXrhp=ZsbXf`!LB6Po5Ycxa9CIOJD83iP^*aTT zwihCu@-kLHWX^}k8V8Y`3(>zjM2;3Bm+}WMfEcnCB7Y0SuuBkyWFJm3BT67fIrAXK z?1LEF8DiXai1Acxay-P;jS$nQV6houCi!NMgqTOg7Z5KDfhd^=;i6(oXyB!bA(oN- zt$c`;9*9*`a8(<`>KhRMR{*i@0K^6h#5*+RrlSz=RzrLc3-Nc#bN-`0#7Ac!D&|A{ zKMnBtZiqAC5MR?E=bIqDC)<^45Z7oW)jJ_>Q~X_;&^@x3I%C|z=Gy~G#$hPeoqz|dU zj;|r@+yZGAP2{7AkoF9Lw3lSXXh{2L68lLWI05O?9!^LHb0HmbLpr$=(m(G*s-)Ro zSp(^s3DR{RNI$KIbcZI@xB^n!0m$-0$o^X(2Ofsp=_KS(AIRNXAV*JwoLCPzWg+B@ zwUD#(ArB_|FtU%G1$n{-$kTg5o&(5lz6`l^E97-k#zEc?0r}njkpEH(`NKhwcNana zaMQkn8Ipdnmq%ehNx@DAc>5@Xv!n zKOPFxS}4qGTsv#_@2p|QyUsBBQVgS?v}rbAfK82|Hcm`;l+8eFB-Rk?5NI=z45C;S z`q&f{??dl0vTeR31(Y^D`TY@O(~=Az*@2{=P76UdnD-VFO2L#uA~vDBGS5>+O^sy zcWtersYy|*4@7&Z-yZx)!9X8Bt=7+{4h?m6bqx*T7ayWPo6T%;yEX3f=P#Mewo8|4 zOs1Mk<+k%&@AD*&m2du$2ZbyWo?5+nu|lucTOw*bixd$N`r2B31oY)se&lwhE0?Qz zWP7IaNoM(wY97K&<*!upVCV1a6;e}eZRsD{^=3c6r`p}-y1J)YOs9y5QhdN{0WLYf z#+duQi`{p-KK&!P@_J28skYhUX>NwBR?Bi*V>6M;)7-A1*|SKJo10|>2GIW&PM`le zf&ih?YE_~GTI=dsTNQzVC_t+fq>`={TD7`Wh*q^e(0g!1?fJ~-w*w4p@_3r4u)=## ziYKN0JWXC*mKmCxey^^T^!iXqio>3m8z+!)LG(3U(p6? z4g)nuH);+$HAgpUj&9T(25N!`YJzUm1O{pXJ2im;LallVcGXizKZ9k}QxL13q8BQ@Ff+h7Gb|zq$}%7z$SwnjfG`4*rYs2-C=XF-spFPwD37RV zdDH7vGbdKQ^9w+qpqU8y&8p&=)od**BfpQ=IK5b3^+r+l z{Qm&Q&eI78R?m35)+83T18r9UwzrL=9BfAH0Y;0Ox>d0owwe4~-bQVd!sxGSDqBF3=KK7q~z0 zZqTbinL%#_tqIy4)EV?>Sje!#VRMJw9_}-|&N}>fFbfV0z7kRqvMuCdX#ddQP)q2t z(4Mfuu*$GGVJpKng*Am82|F8hJ*?N1V|vq6XWDA|-gM4%JG@`GDST4+;_zMJ-4PxU zDG_rbc11iMkvU@Bh?YpZ$oRl_;&TOYe8_Hu0Rs8>hL9(63vH?At~VZ156Bz{Bu zkp!27^n{%W7Zbx1OAq3t5R-v$8H^%j{{{n{o^}Gjd*zD;Ret*OGfOw>K{-uR8Cx zCC;+MvU_~L@fqXW^4;?*^BW6%3g#5tE(|WLEc|>z;Diq+{8Z#sR8jOr-%n4*AAPXm z$;~;FRk601{ZY*J-uJb9z!r)vqNqIl!Fx9OYok@|f7xIvYDJ@f+yt<>GqP1n`wwhz+l=IWvV-7_*bU70RcXV69KBCI+I zc)EJh&GB^dH1?Hw^m_d5(d}96VfOIw@bvh`{h3FUN1+FI-{M}TJ)s@xP7|-)r!CQZ zrlI>o`-Pg%v?DaDX_wvSYtCs-X)bG)YVx!zw6nF)uF@RScoP0pldMhC#;DuX=QNWv z3p5FuqiSo1CSBtPsLyMp`fv3e^>*67sp0C~8lf&%|Ezwj4s@I4*6o(#_CoFJ+U?ry zw!kgib%nFa>9ljHbF*`|Q)wS*--}9h-RXhqnUj;MO|_EV9kU2|shXl%NNc<5rpiH; z<+#^zwn}jvp^8!YI1Bw5?LUg7<&vYNipx7%SmaQz9h-fIVYr~`Yd27BI0YKMZenczwr zxP349Ks#{9OmLUw;BK|x?j_)!^T3T+;65BYFatcK5MVc@ZYG%{VjC!^TA&fSs|Dmgt805!5hNW0^zw6!q5ZZQv~660>VEBBA^{2C?CQ^ zc_V`$VzdykR2O?6VpJzYTq{I;BSbH>&q6stS{QBCBvWbe*{c>fB-93rz)<9Xc> z^9f(r46%4384)figIJmdv1~6yogSio5kv#^TmfE%-L|hFc7{-&uR9?c zsX>b?MC(ToC#cB}Qy|U}b|D4g5}l;W1>!mt{6>Ph3A+^q@qpGRJ&-a4(xDb|-~&kK zt&pynklJ2Ini1*!3FP2r$l#}tCL`o1%1uas9DNj${{H1yS6F4X4s!fq$ikhFrB@&; zsv)bwAgd*0O&VmaBjn5-kROylF7Sq2TmkvfI>^=iA=jLT+(_8w6v(Yauq_<&a|bdK z^2G_rox35wqV?+*$R-l;Ev+_xvNe#q8zJ|nS|Ja)K(<*RPm$VR9zkB33we1uWY=@Z z8zk!CeaL5eC@6x`-vNrFHx%tqD1)X$8C(Ko$d6D$3!#KxgA)4$N_;bvG{VOao__(# zYkxy2Uk9aXDwLT&L$UUU@=+_4`jt@DPWlSU`dldg+686DVJJ-*PcB0OcCRy1Sv=&4= OkHttpClient` provider already injected + into quartz fetchers (`OkHttpNip05Fetcher`, `OkHttpLnurlEndpointResolver`). The + git client takes the same lambda, so it inherits Tor/proxy routing and the + onion-rewrite interceptors for free. +- `RenderContentAsMarkdown` (amethyst) — renders the `README.md` rich-text. +- `GitRepositoryScreen` HorizontalPager + `SecondaryTabRow` — the tab host we + extend. + +Genuinely new: the git smart-HTTP client (no git/pack code existed anywhere) and +a syntax-highlighted code viewer. + +## Protocol client (`quartz`, `jvmAndroid` source set) + +Placed in `jvmAndroid` (shared by Android + Desktop JVM) so it can use +`java.util.zip.Inflater`, `java.security.MessageDigest`, and OkHttp directly with +no expect/actual. Not needed on iOS/native. + +Package `com.vitorpamplona.quartz.nip34Git.git`: + +- `PktLine` — git pkt-line frame reader/writer (flush `0000`, delim `0001`, + response-end `0002`, data otherwise). +- `GitObjectType`, `GitTreeEntry`, `GitTree`/commit parsers — pure byte parsing of + loose object payloads (` \0<20-byte oid>` tree entries; `tree ` + from a commit). +- `Packfile` — parses a v2 packfile: object headers, zlib inflate per object, + `OBJ_OFS_DELTA` / `OBJ_REF_DELTA` resolution, and SHA-1 oid computation + (`sha1(" \0" + content)`). +- `GitDelta` — copy/insert delta instruction decoder. +- `GitSmartHttpTransport` — the three HTTP exchanges: + 1. `GET {clone}/info/refs?service=git-upload-pack` (`Git-Protocol: version=2`) + → capability advertisement (we read `fetch` features incl. `filter`, and + `object-format`). + 2. `POST {clone}/git-upload-pack` `command=ls-refs` → HEAD oid + default branch. + 3. `POST {clone}/git-upload-pack` `command=fetch` → sideband-framed packfile. +- `GitHttpRepository` / `GitHttpClient` — high level: + - `loadRepository(cloneUrl)` → `fetch want deepen 1 filter blob:none`, + yielding the commit + **all** trees in one shallow request. Builds + `treeOid -> entries` and a navigable snapshot (`entriesAt(path)`). + - `loadBlob(oid)` → lazy partial-clone fetch `want filter blob:none` + (cached). Falls back to a full `deepen 1` (no filter) snapshot for servers + that don't advertise `filter`, in which case all blobs arrive up-front. + +### Why this shape + +`filter blob:none` + `deepen 1` is exactly how a git partial clone browses a tip +without downloading file contents; lazy blob-by-oid fetch is the same mechanism +git uses to backfill missing blobs, so any server that advertises `filter` +supports both. We do **not** request `thin-pack`, so packs are self-contained and +deltas are `OFS_DELTA` (offset based) — no cross-pack base lookups. + +Validated end-to-end against `github.com/octocat/Hello-World.git`; the captured +wire bytes are checked in as offline test fixtures (CI-safe, no network). + +## UI (`amethyst`) + +- Tabs become: **README**, **Code**, Overview, Issues, Patches. +- `GitReadmeTab` — fetches `README(.md/.markdown/...)` from the root tree, renders + via `RenderContentAsMarkdown`; falls back to the repo description / overview. +- `GitCodeTab` — file browser (breadcrumb + folders-first listing) backed by a + `GitRepositoryBrowserViewModel`; tapping a file opens `GitFileViewer`. +- `GitFileViewer` — markdown for `.md`, otherwise monospace + syntax highlighting + via the `dev.snipme:highlights` KMP library (Apache-2.0 — permissive, OK). + +## Out of scope (future) + +- Writing/committing, branch switching beyond HEAD, history/blame, large-binary + preview, sha256 object-format repos. diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitDelta.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitDelta.kt new file mode 100644 index 0000000000..6b88a633cf --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitDelta.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +/** + * Applies a git delta ([delta]) against a [base] object payload, reconstructing + * the target object. This is the format used by `OBJ_OFS_DELTA` / `OBJ_REF_DELTA` + * packfile entries. + * + * Layout: a base-size varint, a target-size varint, then a stream of + * instructions — copy (high bit set, offset/size assembled from the selected + * following bytes) or insert (a literal run of `cmd` bytes). + */ +object GitDelta { + fun apply( + base: ByteArray, + delta: ByteArray, + ): ByteArray { + var pos = 0 + + val baseSize = readVarInt(delta) { pos }.also { pos = it.second }.first + require(baseSize == base.size) { + "delta base size mismatch: header says $baseSize, base is ${base.size}" + } + val targetSize = readVarInt(delta) { pos }.also { pos = it.second }.first + + val out = ByteArray(targetSize) + var outPos = 0 + + while (pos < delta.size) { + val cmd = delta[pos++].toInt() and 0xFF + if (cmd and 0x80 != 0) { + // copy from base + var copyOffset = 0 + var copySize = 0 + if (cmd and 0x01 != 0) copyOffset = copyOffset or (delta[pos++].toInt() and 0xFF) + if (cmd and 0x02 != 0) copyOffset = copyOffset or ((delta[pos++].toInt() and 0xFF) shl 8) + if (cmd and 0x04 != 0) copyOffset = copyOffset or ((delta[pos++].toInt() and 0xFF) shl 16) + if (cmd and 0x08 != 0) copyOffset = copyOffset or ((delta[pos++].toInt() and 0xFF) shl 24) + if (cmd and 0x10 != 0) copySize = copySize or (delta[pos++].toInt() and 0xFF) + if (cmd and 0x20 != 0) copySize = copySize or ((delta[pos++].toInt() and 0xFF) shl 8) + if (cmd and 0x40 != 0) copySize = copySize or ((delta[pos++].toInt() and 0xFF) shl 16) + if (copySize == 0) copySize = 0x10000 + base.copyInto(out, outPos, copyOffset, copyOffset + copySize) + outPos += copySize + } else if (cmd != 0) { + // insert literal: the next `cmd` bytes + delta.copyInto(out, outPos, pos, pos + cmd) + outPos += cmd + pos += cmd + } else { + throw IllegalArgumentException("invalid delta opcode 0x00") + } + } + + require(outPos == targetSize) { "delta produced $outPos bytes, expected $targetSize" } + return out + } + + /** Little-endian base-128 varint as used in delta size headers. */ + private inline fun readVarInt( + data: ByteArray, + startPos: () -> Int, + ): Pair { + var pos = startPos() + var value = 0 + var shift = 0 + while (true) { + val b = data[pos++].toInt() and 0xFF + value = value or ((b and 0x7F) shl shift) + if (b and 0x80 == 0) break + shift += 7 + } + return value to pos + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt new file mode 100644 index 0000000000..e7d5657eef --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.OkHttpClient + +/** + * High-level read-only browser for a git repository served over smart-HTTP. + * + * [open] downloads a shallow, blob-less snapshot of the default branch tip (one + * request fetches the commit and every tree), returning a [GitRepoSnapshot] that + * can walk directories offline and lazily fetch individual file blobs on demand. + */ +class GitHttpClient( + okHttpClient: (String) -> OkHttpClient, +) { + private val transport = GitSmartHttpTransport(okHttpClient) + + /** + * Connects to [cloneUrl] and loads the tree of [ref] (default: the server's + * HEAD branch). Only `http(s)` git endpoints are supported. + */ + suspend fun open( + cloneUrl: String, + ref: String? = null, + ): GitRepoSnapshot { + require(cloneUrl.startsWith("http://") || cloneUrl.startsWith("https://")) { + "unsupported git transport (only http/https): $cloneUrl" + } + + val caps = transport.fetchCapabilities(cloneUrl) + if (!caps.supportsLsRefs || !caps.supportsFetch) { + throw GitHttpException("server does not speak git protocol v2 (ls-refs/fetch)") + } + + val refs = transport.lsRefs(cloneUrl, caps) + val head = selectHead(refs, ref) ?: throw GitHttpException("could not resolve a branch to browse") + val branch = head.symrefTarget?.removePrefix("refs/heads/") ?: ref?.removePrefix("refs/heads/") + + val pack = + transport.fetchPack( + cloneUrl = cloneUrl, + caps = caps, + wants = listOf(head.oid), + deepen = 1, + // When the server can't filter, a shallow fetch already pulls every blob, so the + // snapshot is fully self-contained and no lazy per-file fetch is needed. + filterBlobNone = caps.supportsFilter, + ) + val objects = Packfile.parse(pack) + + val commit = objects[head.oid] ?: throw GitHttpException("tip commit ${head.oid} missing from pack") + if (commit.type != GitObjectType.COMMIT) throw GitHttpException("tip ${head.oid} is not a commit") + val rootTreeOid = + GitObjectParser.parseCommitTree(commit.data) + ?: throw GitHttpException("commit ${head.oid} has no tree") + + val trees = HashMap>() + val blobs = HashMap() + for ((oid, obj) in objects) { + when (obj.type) { + GitObjectType.TREE -> trees[oid] = GitObjectParser.parseTree(obj.data) + GitObjectType.BLOB -> blobs[oid] = obj.data + else -> {} + } + } + + return GitRepoSnapshot( + cloneUrl = cloneUrl, + headCommit = head.oid, + branch = branch, + rootTreeOid = rootTreeOid, + trees = trees, + blobs = blobs, + transport = transport, + caps = caps, + ) + } + + /** Picks the ref to browse: an explicit [ref] if given, otherwise HEAD. */ + private fun selectHead( + refs: List, + ref: String?, + ): GitRef? { + if (ref != null) { + return refs.firstOrNull { it.name == ref } ?: refs.firstOrNull { it.name == "refs/heads/$ref" } + } + refs.firstOrNull { it.name == "HEAD" }?.let { return it } + return refs.firstOrNull { it.name == "refs/heads/main" } + ?: refs.firstOrNull { it.name == "refs/heads/master" } + ?: refs.firstOrNull { it.name.startsWith("refs/heads/") } + } +} + +/** + * An offline-navigable snapshot of a repository tree plus lazy blob access. + * Directory listings are resolved from the in-memory tree map; file contents are + * fetched on demand (and cached) unless they were already pulled up-front. + */ +class GitRepoSnapshot( + val cloneUrl: String, + val headCommit: String, + val branch: String?, + private val rootTreeOid: String, + private val trees: Map>, + blobs: Map, + private val transport: GitSmartHttpTransport, + private val caps: GitCapabilities, +) { + private val blobs = HashMap(blobs) + private val blobMutex = Mutex() + + /** Entries at the repository root, folders first. */ + fun rootEntries(): List = sortForDisplay(trees[rootTreeOid].orEmpty()) + + /** + * Entries inside the directory at [path] (a list of path segments). Returns + * null if the path doesn't resolve to a directory. + */ + fun entriesAt(path: List): List? { + var treeOid = rootTreeOid + for (segment in path) { + val entry = trees[treeOid]?.firstOrNull { it.name == segment && it.isFolder } ?: return null + treeOid = entry.oid + } + return trees[treeOid]?.let { sortForDisplay(it) } + } + + /** Resolves the entry of a file (or folder) at the full [path], or null. */ + fun entryAt(path: List): GitTreeEntry? { + if (path.isEmpty()) return null + val parent = entriesAt(path.dropLast(1)) ?: return null + return parent.firstOrNull { it.name == path.last() } + } + + fun hasBlob(oid: String): Boolean = blobs.containsKey(oid) + + /** Returns the bytes of a blob, fetching (and caching) it if not already present. */ + suspend fun readBlob(oid: String): ByteArray { + blobs[oid]?.let { return it } + return blobMutex.withLock { + blobs[oid]?.let { return it } + val pack = + transport.fetchPack( + cloneUrl = cloneUrl, + caps = caps, + wants = listOf(oid), + deepen = null, + filterBlobNone = caps.supportsFilter, + ) + val obj = + Packfile.parse(pack)[oid] + ?: throw GitHttpException("blob $oid missing from fetch response") + obj.data.also { blobs[oid] = it } + } + } + + private fun sortForDisplay(entries: List): List = + entries.sortedWith( + compareByDescending { it.isFolder }.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, + ) +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt new file mode 100644 index 0000000000000000000000000000000000000000..d8349ba4b3dc28efe63df3bdb467f771ac28f8b1 GIT binary patch literal 4456 zcma)AZExDh7QUbJD~|Y5(-K0nNq29Q=3a-G#JU6!IL%g7r404}vo$t59*3}3{qOsn z@e72c)uKwR$LHlaZ_gR`=~Xf6DZcaBf#lQOBMkyx(cG!YPDxslXM zm1QbTNM*E;T241K*CH`8YSBzfN$HHjh0t@^B9oFxHnfsDgF-qrLM1Ahlb{g(Aju}p z0+zCLX4XPW$VViyEDe=_Ns7`iU&_P?!!BnkmKix_A*nGbH5yIpCz2v2l@tagRc~pn z%p%PVX_*Fni~Y2@nI+vC~`DMNMa`Am@O!vM|$9aO;#AK82dt=fTB#7^}b%DOBSzXIqS1r zC-92wKqpF(JBtv0|B#{KL)Mt3ah$F>#xPAH#ervMaLb+pycg5-QQEMIx+kdtYMXys z5eufMbonY<2!t_}W%}S>C4{3gJvKPRnol!>eo&~#D%I9+8FU>*yTWnf(PY>Q?%lCR z{)9&3;V1vfyP}3W!E>WU_kM6QybA~tV|NgIreTlV!DsrPKe%d<_xot5 z)b~9c`-5)(?#drr(m|PmUs~X)F%1EGY{g29_cxwB z@&^pl9gy1%{NaG(=?(|M7>_N4I}U2?dw=4!$Q}C=&P;DSga=M7G~fWxp*QfX6*kG4 zxA5x%L5h=ihv;iWSDxF4IaF)#OqeTtbg&ZPH!OL?ib(1+nqX3sdF!A_|F`eD!&@|m(*Q_JALzuM24l~28U}<`om}>Z zmri3Er_<^raIYaE=KTD@si;zbryEvj1HPEtX7#coQ1<;SvvWL$#(_o1qj;&)b;bq2 zLPkSip?D(i zs-%E=rbj|&Kv9e?P+GKTC40^=cY3xOG3+TUMV>zHZ|teI-GFNp4N>VOaAU2C$`tlx zh|5P(+&M4|59hYi54l+`=$yOb;$P>-ox>@H>kw^m@qmF3Qypbk|JZpls%-J?NSQZS z`)gc)G!n=g*;y}CxL`(GfheSY(p3^m`lc&A0{VDSZ8GKq)4o#rA#tMfo>Y~y@p%Cu@WB($i+Gd(qbbx3dLiv&0+*t zgMS=ElE*JX=#=90j^4ixowfcUaPBEGeE&n+y^h?9ypFuc5pc8^=_~lrqZ5OTiy#;j zB%dXw%5^RF4xa#YUOFo)`=i_&o!fbdcx%0uava02NpIv+S^dgS3vTw%Rud|s$k}aP zhYN@Bkf#=LblU=28r+<#wzmlY!ElM0?Yt+WMF@`IVbdvSt6&m< z*K&prog_rlVV-M>E2*j6<2Ak2bJ?{__=D*zdhS0vBYAXWS?R&LJx4HLmEz7~*(qL& zO#yDEbY=+4&VFm{!6`YS>{}zBxaLd_?Y(Z_=8wt7vF4a+pwZ@s3FePucAEQBt!9OI zTV>vIWi~7^)ly*h9#x1l?0f!VlbHIF3^~#G)!O(<&?dXb_gQhzq6X}gHDUm5`z67A z>6|v(;O*rOAHAq5+8Q4s_?0DHY4r#aw=7vvPu=r-WLaE|SQ(J8x3QGKN!wGtZ=R8# znBoZBihu7^?h*GFZa|i+ef$%C@$|h&I#f!y7pP81R?PJ-lUkX5&W%U(H0_ypoc=l$_7C#N5N{q5gk8b)%~fNB^23t~4b`u6+5 E-w_!qOaK4? literal 0 HcmV?d00001 diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt new file mode 100644 index 0000000000..24963345a0 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.coroutines.executeAsync + +/** A ref returned by `ls-refs`: an oid, its full name, and (for symrefs) its target. */ +class GitRef( + val oid: String, + val name: String, + val symrefTarget: String? = null, +) + +/** Parsed protocol-v2 server capabilities. */ +class GitCapabilities( + val agent: String?, + val objectFormat: String, + val supportsLsRefs: Boolean, + val fetchFeatures: Set, +) { + /** Some servers advertise `fetch` with no feature list; track that we saw the command. */ + var rawHadFetch: Boolean = false + + val supportsFetch: Boolean get() = fetchFeatures.isNotEmpty() || rawHadFetch + val supportsFilter: Boolean get() = fetchFeatures.contains("filter") + val supportsShallow: Boolean get() = fetchFeatures.contains("shallow") +} + +/** + * Low-level git smart-HTTP **protocol v2** transport (`git-upload-pack` only; + * we never write). The three exchanges are `info/refs` (capabilities), + * `ls-refs`, and `fetch`. Wire encoding/decoding lives in [GitUploadPackV2]. + * + * [okHttpClient] is the shared per-URL client provider, so every request inherits + * the app's proxy / Tor routing and onion-rewrite interceptors. + */ +class GitSmartHttpTransport( + private val okHttpClient: (String) -> OkHttpClient, +) { + suspend fun fetchCapabilities(cloneUrl: String): GitCapabilities = + withContext(Dispatchers.IO) { + val url = "${base(cloneUrl)}/info/refs?service=git-upload-pack" + val request = + Request + .Builder() + .url(url) + .header("Git-Protocol", "version=2") + .header("Accept", "*/*") + .get() + .build() + GitUploadPackV2.parseCapabilities(PktLineCodec.parse(execute(url, request))) + } + + suspend fun lsRefs( + cloneUrl: String, + caps: GitCapabilities, + ): List = + withContext(Dispatchers.IO) { + val body = GitUploadPackV2.lsRefsRequest(caps.objectFormat) + GitUploadPackV2.parseRefs(PktLineCodec.parse(postUploadPack(cloneUrl, body))) + } + + /** + * Runs a `fetch` and returns the raw packfile bytes (sideband demuxed). + * + * @param wants object ids to request. + * @param deepen shallow depth (1 = tip only). Null for a full fetch. + * @param filterBlobNone request `filter blob:none` (omit file contents). + */ + suspend fun fetchPack( + cloneUrl: String, + caps: GitCapabilities, + wants: List, + deepen: Int?, + filterBlobNone: Boolean, + ): ByteArray = + withContext(Dispatchers.IO) { + require(wants.isNotEmpty()) { "fetch requires at least one want" } + val body = + GitUploadPackV2.fetchRequest( + objectFormat = caps.objectFormat, + wants = wants, + deepen = if (caps.supportsShallow) deepen else null, + filterBlobNone = filterBlobNone && caps.supportsFilter, + ) + GitUploadPackV2.extractPack(PktLineCodec.parse(postUploadPack(cloneUrl, body))) + } + + private suspend fun postUploadPack( + cloneUrl: String, + body: ByteArray, + ): ByteArray { + val url = "${base(cloneUrl)}/git-upload-pack" + val request = + Request + .Builder() + .url(url) + .header("Git-Protocol", "version=2") + .header("Accept", "application/x-git-upload-pack-result") + .post(body.toRequestBody(GIT_UPLOAD_PACK_REQUEST)) + .build() + return execute(url, request) + } + + private suspend fun execute( + url: String, + request: Request, + ): ByteArray = + okHttpClient(url).newCall(request).executeAsync().use { response -> + if (!response.isSuccessful) { + throw GitHttpException("HTTP ${response.code} from $url") + } + response.body.bytes() + } + + private fun base(cloneUrl: String): String = cloneUrl.trim().trimEnd('/') + + companion object { + private val GIT_UPLOAD_PACK_REQUEST = "application/x-git-upload-pack-request".toMediaType() + } +} + +class GitHttpException( + message: String, +) : Exception(message) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt new file mode 100644 index 0000000000..f4b02e5fc4 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +import java.io.ByteArrayOutputStream + +/** + * Pure (transport-free) encoders and decoders for the git `git-upload-pack` + * protocol-v2 exchanges. Kept separate from [GitSmartHttpTransport] so the wire + * formats can be unit-tested against captured server bytes without any network. + */ +object GitUploadPackV2 { + fun lsRefsRequest(objectFormat: String): ByteArray = + PktLineCodec.build { + write(PktLineCodec.dataLine("command=ls-refs\n")) + write(PktLineCodec.dataLine("object-format=$objectFormat\n")) + write(PktLineCodec.DELIM) + write(PktLineCodec.dataLine("symrefs\n")) + write(PktLineCodec.dataLine("peel\n")) + write(PktLineCodec.dataLine("ref-prefix HEAD\n")) + write(PktLineCodec.dataLine("ref-prefix refs/heads/\n")) + write(PktLineCodec.dataLine("ref-prefix refs/tags/\n")) + write(PktLineCodec.FLUSH) + } + + fun fetchRequest( + objectFormat: String, + wants: List, + deepen: Int?, + filterBlobNone: Boolean, + ): ByteArray = + PktLineCodec.build { + write(PktLineCodec.dataLine("command=fetch\n")) + write(PktLineCodec.dataLine("object-format=$objectFormat\n")) + write(PktLineCodec.DELIM) + write(PktLineCodec.dataLine("no-progress\n")) + wants.forEach { write(PktLineCodec.dataLine("want $it\n")) } + if (deepen != null) write(PktLineCodec.dataLine("deepen $deepen\n")) + if (filterBlobNone) write(PktLineCodec.dataLine("filter blob:none\n")) + write(PktLineCodec.dataLine("done\n")) + write(PktLineCodec.FLUSH) + } + + fun parseCapabilities(lines: List): GitCapabilities { + var agent: String? = null + var objectFormat = "sha1" + var supportsLsRefs = false + var fetchFeatures = emptySet() + var hadFetch = false + + for (line in lines) { + if (line !is PktLine.Data) continue + val text = line.text() + if (text.startsWith("# service=")) continue + val eq = text.indexOf('=') + val key = if (eq >= 0) text.substring(0, eq) else text + val value = if (eq >= 0) text.substring(eq + 1) else "" + when (key) { + "agent" -> agent = value + "object-format" -> if (value.isNotBlank()) objectFormat = value.trim() + "ls-refs" -> supportsLsRefs = true + "fetch" -> { + hadFetch = true + fetchFeatures = + value + .split(' ') + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() + } + } + } + + return GitCapabilities(agent, objectFormat, supportsLsRefs, fetchFeatures).also { it.rawHadFetch = hadFetch } + } + + fun parseRefs(lines: List): List { + val refs = ArrayList() + for (line in lines) { + if (line !is PktLine.Data) continue + val text = line.text() + if (text.isEmpty()) continue + val parts = text.split(' ') + if (parts.size < 2) continue + var symref: String? = null + for (i in 2 until parts.size) { + val attr = parts[i] + if (attr.startsWith("symref-target:")) symref = attr.substringAfter("symref-target:") + } + refs.add(GitRef(parts[0], parts[1], symref)) + } + return refs + } + + /** Demuxes the sideband-64k `packfile` section of a fetch response into raw pack bytes. */ + fun extractPack(lines: List): ByteArray { + val pack = ByteArrayOutputStream() + val errors = StringBuilder() + var inPack = false + for (line in lines) { + when (line) { + is PktLine.Flush -> if (inPack) break + is PktLine.Delim -> {} // section separator + is PktLine.ResponseEnd -> if (inPack) break + is PktLine.Data -> { + if (!inPack) { + if (line.text() == "packfile") inPack = true + continue + } + val payload = line.payload + if (payload.isEmpty()) continue + when (payload[0].toInt() and 0xFF) { + 1 -> pack.write(payload, 1, payload.size - 1) // pack data + 2 -> {} // progress + 3 -> errors.append(payload.decodeToString(1, payload.size)) // fatal + } + } + } + } + if (errors.isNotEmpty()) throw GitHttpException("git server error: $errors") + val bytes = pack.toByteArray() + if (bytes.size < 4) throw GitHttpException("empty packfile in fetch response") + return bytes + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/Packfile.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/Packfile.kt new file mode 100644 index 0000000000..a8b953f6fc --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/Packfile.kt @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +import java.security.MessageDigest +import java.util.zip.Inflater + +/** + * Parser for a git version-2 packfile (`PACK` magic). Inflates each object, + * resolves `OBJ_OFS_DELTA` / `OBJ_REF_DELTA` chains, and computes each object's + * SHA-1 oid so callers can look objects up by id. + * + * We never request thin packs, so every delta base is present inside the same + * pack; `OFS_DELTA` bases always precede their delta, and `REF_DELTA` bases are + * resolved through a pre-built oid index. + */ +object Packfile { + private const val OBJ_COMMIT = 1 + private const val OBJ_TREE = 2 + private const val OBJ_BLOB = 3 + private const val OBJ_TAG = 4 + private const val OBJ_OFS_DELTA = 6 + private const val OBJ_REF_DELTA = 7 + + private class Raw( + val offset: Int, + val type: Int, + val data: ByteArray, + val baseOffset: Int, + val baseOid: String?, + ) + + /** Parses [pack] and returns the resolved objects indexed by their oid (hex). */ + fun parse(pack: ByteArray): Map { + require(pack.size >= 12) { "packfile too short" } + require(pack[0] == 'P'.code.toByte() && pack[1] == 'A'.code.toByte() && pack[2] == 'C'.code.toByte() && pack[3] == 'K'.code.toByte()) { + "missing PACK signature" + } + val version = readUInt32(pack, 4) + require(version == 2L || version == 3L) { "unsupported packfile version $version" } + val count = readUInt32(pack, 8).toInt() + + val raws = ArrayList(count) + val byOffset = HashMap(count * 2) + var p = 12 + repeat(count) { + val start = p + var b = pack[p++].toInt() and 0xFF + val type = (b ushr 4) and 0x07 + var size = (b and 0x0F).toLong() + var shift = 4 + while (b and 0x80 != 0) { + b = pack[p++].toInt() and 0xFF + size = size or ((b and 0x7F).toLong() shl shift) + shift += 7 + } + + var baseOffset = -1 + var baseOid: String? = null + when (type) { + OBJ_OFS_DELTA -> { + b = pack[p++].toInt() and 0xFF + var rel = (b and 0x7F).toLong() + while (b and 0x80 != 0) { + b = pack[p++].toInt() and 0xFF + rel = ((rel + 1) shl 7) or (b and 0x7F).toLong() + } + baseOffset = (start - rel).toInt() + } + OBJ_REF_DELTA -> { + baseOid = toHex(pack, p, 20) + p += 20 + } + } + + val (data, consumed) = inflate(pack, p, size.toInt()) + p += consumed + + val raw = Raw(start, type, data, baseOffset, baseOid) + raws.add(raw) + byOffset[start] = raw + } + + return resolve(raws, byOffset) + } + + private fun resolve( + raws: List, + byOffset: Map, + ): Map { + val memo = HashMap(raws.size * 2) + val oidToOffset = HashMap(raws.size * 2) + + // Pre-index oids of non-delta objects so REF_DELTA bases can be found + // regardless of pack ordering. + for (r in raws) { + if (r.type != OBJ_OFS_DELTA && r.type != OBJ_REF_DELTA) { + oidToOffset[oidOf(baseType(r.type), r.data)] = r.offset + } + } + + fun resolveAt(offset: Int): GitObject { + memo[offset]?.let { return it } + val r = byOffset[offset] ?: throw IllegalStateException("delta base offset $offset not in pack") + val obj = + when (r.type) { + OBJ_OFS_DELTA -> { + val base = resolveAt(r.baseOffset) + val data = GitDelta.apply(base.data, r.data) + GitObject(base.type, data, oidOf(base.type, data)) + } + OBJ_REF_DELTA -> { + val baseOffset = + oidToOffset[r.baseOid] + ?: throw IllegalStateException("REF_DELTA base ${r.baseOid} missing from pack") + val base = resolveAt(baseOffset) + val data = GitDelta.apply(base.data, r.data) + GitObject(base.type, data, oidOf(base.type, data)) + } + else -> GitObject(baseType(r.type), r.data, oidOf(baseType(r.type), r.data)) + } + memo[offset] = obj + oidToOffset[obj.oid] = offset + return obj + } + + val result = HashMap(raws.size * 2) + for (r in raws) { + val obj = resolveAt(r.offset) + result[obj.oid] = obj + } + return result + } + + private fun baseType(type: Int): GitObjectType = + when (type) { + OBJ_COMMIT -> GitObjectType.COMMIT + OBJ_TREE -> GitObjectType.TREE + OBJ_BLOB -> GitObjectType.BLOB + OBJ_TAG -> GitObjectType.TAG + else -> throw IllegalArgumentException("not a base object type: $type") + } + + private fun oidOf( + type: GitObjectType, + data: ByteArray, + ): String { + val digest = MessageDigest.getInstance("SHA-1") + digest.update("${type.header} ${data.size}".encodeToByteArray()) + digest.update(0) + digest.update(data) + return digest.digest().toHex() + } + + private fun inflate( + src: ByteArray, + offset: Int, + expectedSize: Int, + ): Pair { + val inflater = Inflater() + try { + inflater.setInput(src, offset, src.size - offset) + val out = ByteArray(expectedSize) + var got = 0 + // Inflate until the stream is fully consumed (not just until we have all + // output bytes): the trailing adler32 checksum must be read too, otherwise + // bytesRead() is short and the next packed object's offset is misaligned. + val scratch = ByteArray(64) + while (!inflater.finished()) { + if (got < expectedSize) { + val n = inflater.inflate(out, got, expectedSize - got) + if (n == 0) { + if (inflater.needsInput() || inflater.needsDictionary()) break + } else { + got += n + } + } else { + val n = inflater.inflate(scratch, 0, scratch.size) + if (n == 0 && (inflater.needsInput() || inflater.needsDictionary())) break + } + } + if (got != expectedSize) { + throw IllegalStateException("inflate short read: $got of $expectedSize") + } + return out to inflater.bytesRead.toInt() + } finally { + inflater.end() + } + } + + private fun readUInt32( + data: ByteArray, + offset: Int, + ): Long { + var v = 0L + for (i in 0 until 4) v = (v shl 8) or (data[offset + i].toLong() and 0xFF) + return v + } + + private fun toHex( + data: ByteArray, + offset: Int, + len: Int, + ): String { + val sb = StringBuilder(len * 2) + for (i in 0 until len) { + val v = data[offset + i].toInt() and 0xFF + sb.append(HEX[v ushr 4]) + sb.append(HEX[v and 0x0F]) + } + return sb.toString() + } + + private fun ByteArray.toHex(): String { + val sb = StringBuilder(size * 2) + for (b in this) { + val v = b.toInt() and 0xFF + sb.append(HEX[v ushr 4]) + sb.append(HEX[v and 0x0F]) + } + return sb.toString() + } + + private val HEX = "0123456789abcdef".toCharArray() +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/PktLine.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/PktLine.kt new file mode 100644 index 0000000000..e531563d1a --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/PktLine.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +import java.io.ByteArrayOutputStream + +/** + * git `pkt-line` framing used by the smart-HTTP transport. + * + * Each frame is a 4-byte ASCII hexadecimal length prefix followed by that many + * bytes (the length count includes the 4 prefix bytes). Three lengths are + * reserved as control frames: + * + * - `0000` flush-pkt + * - `0001` delim-pkt (protocol v2 section separator) + * - `0002` response-end-pkt + * + * See `gitprotocol-common(5)` and `gitprotocol-v2(5)`. + */ +sealed interface PktLine { + object Flush : PktLine + + object Delim : PktLine + + object ResponseEnd : PktLine + + /** A data frame. [payload] excludes the length prefix. */ + class Data( + val payload: ByteArray, + ) : PktLine { + /** Convenience for text frames, trailing `\n` stripped. */ + fun text(): String = payload.decodeToString().trimEnd('\n') + } +} + +object PktLineCodec { + /** Encodes a text payload as a single data frame (a trailing `\n` is the git convention). */ + fun dataLine(text: String): ByteArray = dataLine(text.encodeToByteArray()) + + /** Encodes a binary payload as a single data frame. */ + fun dataLine(payload: ByteArray): ByteArray { + val len = payload.size + 4 + require(len <= 0xFFFF) { "pkt-line payload too large: ${payload.size}" } + val out = ByteArray(len) + writeLengthPrefix(out, len) + payload.copyInto(out, 4) + return out + } + + val FLUSH: ByteArray = "0000".encodeToByteArray() + val DELIM: ByteArray = "0001".encodeToByteArray() + + private fun writeLengthPrefix( + out: ByteArray, + len: Int, + ) { + val hex = len.toString(16).padStart(4, '0') + for (i in 0 until 4) out[i] = hex[i].code.toByte() + } + + /** + * Parses a full pkt-line stream into frames. The smart-HTTP responses we + * consume are small enough to read fully into memory before decoding. + */ + fun parse(bytes: ByteArray): List { + val result = ArrayList() + var i = 0 + while (i + 4 <= bytes.size) { + val len = parseHex4(bytes, i) + when (len) { + 0 -> { + result.add(PktLine.Flush) + i += 4 + } + 1 -> { + result.add(PktLine.Delim) + i += 4 + } + 2 -> { + result.add(PktLine.ResponseEnd) + i += 4 + } + else -> { + require(len >= 4) { "invalid pkt-line length $len at offset $i" } + val end = i + len + require(end <= bytes.size) { "truncated pkt-line: need $end have ${bytes.size}" } + result.add(PktLine.Data(bytes.copyOfRange(i + 4, end))) + i = end + } + } + } + return result + } + + private fun parseHex4( + bytes: ByteArray, + offset: Int, + ): Int { + var value = 0 + for (i in 0 until 4) { + val c = bytes[offset + i].toInt().toChar() + val digit = + when (c) { + in '0'..'9' -> c - '0' + in 'a'..'f' -> c - 'a' + 10 + in 'A'..'F' -> c - 'A' + 10 + else -> throw IllegalArgumentException("invalid pkt-line length char '$c' at offset ${offset + i}") + } + value = (value shl 4) or digit + } + return value + } + + /** Builds a request body by concatenating pre-encoded frames. */ + fun build(block: ByteArrayOutputStream.() -> Unit): ByteArray = ByteArrayOutputStream().apply(block).toByteArray() +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitPackfileDeltaTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitPackfileDeltaTest.kt new file mode 100644 index 0000000000..1134b6e520 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitPackfileDeltaTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import java.util.Base64 + +class GitPackfileDeltaTest { + // A real pack of two big.txt versions produced with: + // git pack-objects --stdout --delta-base-offset --window=10 --depth=10 + // It contains one full blob and one OBJ_OFS_DELTA against it. + private val deltaPack = + "UEFDSwAAAAIAAAACt4sDeJxl2LGOHFUURdHcX9Gf0Peequ6ukMBIlpADQCK2wAGSPQ5A4vdBZGc5fE68Zc+cVe/9cPv4/rfbjx9+/uXX208fPr5/9/u3r1+/vd2+/Pn2+Xav09Rp65Q6HXU66/So07NOrzpd/bcT0zXTOdM900HTRdNJ003TUdNV21XLv1FXbVdtV21XbVdtV21XbVelq9JV4b+uq9JV6ap0VboqXZWuOrrq6Kqjqw5+orrq6Kqjq46uOrrq6Kqzq86uOrvq7KqTH/SuOrvq7Kqzq86uenTVo6seXfXoqkdXPfj966pHVz266tFVz656dtWzq55d9eyqZ1c9mYWuenbVs6teXfXqqldXvbrq1VWvrnp11Yu16qpXV11ddXXV1VVXV11ddXXV1VVXV12MqCvKjN7Z0TtDemdJ70zpnS29M6Z31vTOnN7p+27m6XPoXXqn3q137F175569HwZ/VofoY/OH0R9Wf5j9YfeH4R+Wf5j+YfsnQkkf8z/s/wDAIMBAwGDAgMCgwMDAHEpOHxIMFAwWDBgMGgwcDB4MIAwizOmnBn2gMKgwsDC4MMAwyDDQMNgw4DAPv4Xow4cBiEGIgYjBiAGJQYmBicGJefqxRh9UDFYMWAxaDFwMXgxgDGIMZMzLr0n6UGNgY3BjgGOQY6BjsGPAY9BjLj93/d7lgxc/Fj8WPxY/Fj8WPxY/Fj8WP3b8IKcPPxY/Fj8WPxY/Fj8WP9b7gheG724M9Hln8NLgrcFrg/cGLw74sfix+LHxSkMffix+LH4sfix+LH4sfix+LH7s4Z2LPvxY/Fj8WPxY/Fj8WPxY/Fj82NNLIX34sfix+LH4sfix+LH4sfix+LEPb6304cfix+LH4sfix+LH4sfix+LHPr1W04cfix+LH4sfix+LH4sfix+LH/vy3k8ffix+LH4sfix+LH4sfix+LH7s5cOELxM8TeBH8CP4EfwIfgQ/gh/Bj+BHxqcT+vAj+BH8CH4EP4IfwY/gR/Aj69sOffgR/Ah+BD+CH8GP+PLk05NvT989PtHn85PvTz5A+QLlExR+BD+CH8GPHL6O0YcfwY/gR/Aj+BH8CH4EP4IfOX2+ow8/gh/Bj+BH8CP4EfwIfgQ/gh/Bj+BH8CP4EfwIfgQ/gh/Bj+BH8CP4EfwIfgQ/gh/Bj+BH8CP4EfwIfgQ/gh/Bj+BH8CP4EfwIfgQ/gh/Bj/znx6e3P26fbm+f/7l9+fTX3///+bt/ARJv6GvuAYYkeJzbbrjCUDQnMy9VIT9NoSS1okQhrzQ3KbVIwWCjwGQJAKvrCr5mknNvxZN0+vEf6iRSfsYDsdzxuw==" + + private fun bigTxtCommon() = (0..399).joinToString("") { "common line $it\n" } + + @Test + fun resolvesOfsDeltaAndComputesOids() { + val pack = Base64.getDecoder().decode(deltaPack) + val objects = Packfile.parse(pack) + + val common = bigTxtCommon() + val expectedHead = "A NEW FIRST LINE\n" + common + "and a new last line\n" + val expectedPrev = "line of text number 0\n" + common + + val head = objects["e873ccdfb69d504f8ce2d51255da3c530d191f21"] + assertNotNull("full base blob present", head) + assertEquals(GitObjectType.BLOB, head!!.type) + assertEquals(expectedHead, head.data.decodeToString()) + + val prev = objects["eb06ed9c0291ae7fd14aec388d470a1bff363f1e"] + assertNotNull("delta blob resolved", prev) + assertEquals(GitObjectType.BLOB, prev!!.type) + // The oid is recomputed from the reconstructed bytes, so a correct oid key + // proves both the delta application and the SHA-1 hashing are right. + assertEquals(expectedPrev, prev.data.decodeToString()) + } + + @Test + fun appliesHandcraftedDelta() { + val base = "the quick brown fox".encodeToByteArray() + // delta: copy "the quick " (offset 0, size 10), insert "red ", copy "fox" (offset 16, size 3) + val delta = + byteArrayOf( + base.size.toByte(), // base size varint (19) + ("the quick ".length + "red ".length + "fox".length).toByte(), // target size (17) + // copy op: cmd 0x90 = copy, offset 0 (no offset bytes), size from one byte (0x0A = 10) + 0x90.toByte(), + 0x0A, + // insert op: literal of 4 bytes + 0x04, + 'r'.code.toByte(), + 'e'.code.toByte(), + 'd'.code.toByte(), + ' '.code.toByte(), + // copy op: cmd 0x91 = copy, offset from one byte (0x10 = 16), size from one byte (0x03) + 0x91.toByte(), + 0x10, + 0x03, + ) + val result = GitDelta.apply(base, delta) + assertEquals("the quick red fox", result.decodeToString()) + } + + @Test + fun parsesTreeEntries() { + // Build a tree payload: "100644 README\0<20 bytes>" + "40000 src\0<20 bytes>" + val out = java.io.ByteArrayOutputStream() + out.write("100644 README".encodeToByteArray()) + out.write(0) + out.write(hex("980a0d5f19a64b4b30a87d4206aade58726b60e3")) + out.write("40000 src".encodeToByteArray()) + out.write(0) + out.write(hex("b4eecafa9be2f2006ce1b709d6857b07069b4608")) + val entries = GitObjectParser.parseTree(out.toByteArray()) + + assertEquals(2, entries.size) + assertEquals("README", entries[0].name) + assertEquals("980a0d5f19a64b4b30a87d4206aade58726b60e3", entries[0].oid) + assertEquals(false, entries[0].isFolder) + assertEquals("src", entries[1].name) + assertEquals(true, entries[1].isFolder) + } + + @Test + fun parsesCommitTreeHeader() { + val commit = + ( + "tree b4eecafa9be2f2006ce1b709d6857b07069b4608\n" + + "parent 0000000000000000000000000000000000000000\n" + + "author Someone 1 +0000\n\nmessage\n" + ).encodeToByteArray() + assertEquals("b4eecafa9be2f2006ce1b709d6857b07069b4608", GitObjectParser.parseCommitTree(commit)) + } + + @Test + fun roundTripsPktLine() { + val frame = PktLineCodec.dataLine("want abc\n") + assertArrayEquals("000dwant abc\n".encodeToByteArray(), frame) + val parsed = PktLineCodec.parse(frame + PktLineCodec.FLUSH + PktLineCodec.DELIM) + assertEquals(3, parsed.size) + assertEquals("want abc", (parsed[0] as PktLine.Data).text()) + assertEquals(PktLine.Flush, parsed[1]) + assertEquals(PktLine.Delim, parsed[2]) + } + + private fun hex(s: String): ByteArray = ByteArray(s.length / 2) { ((s[it * 2].digitToInt(16) shl 4) or s[it * 2 + 1].digitToInt(16)).toByte() } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitProtocolV2Test.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitProtocolV2Test.kt new file mode 100644 index 0000000000..60925b6bb5 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitProtocolV2Test.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Base64 + +/** + * Verifies the protocol-v2 parsers against real bytes captured from + * `github.com/octocat/Hello-World.git`. These fixtures are static so the test + * runs offline. + */ +class GitProtocolV2Test { + private fun decode(b64: String): ByteArray = Base64.getDecoder().decode(b64) + + // GET /info/refs?service=git-upload-pack with Git-Protocol: version=2 + private val infoRefs = + "MDAxZSMgc2VydmljZT1naXQtdXBsb2FkLXBhY2sKMDAwMDAwMGV2ZXJzaW9uIDIKMDAyOGFnZW50PWdpdC9naXRodWItZTBiNThhZGU0OTgwLUxpbnV4CjAwMTNscy1yZWZzPXVuYm9ybgowMDI3ZmV0Y2g9c2hhbGxvdyB3YWl0LWZvci1kb25lIGZpbHRlcgowMDEyc2VydmVyLW9wdGlvbgowMDE3b2JqZWN0LWZvcm1hdD1zaGExCjAwMDA=" + + // POST git-upload-pack command=ls-refs response + private val lsRefs = + "MDA1MjdmZDFhNjBiMDFmOTFiMzE0ZjU5OTU1YTRlNGQ0ZTgwZDhlZGYxMWQgSEVBRCBzeW1yZWYtdGFyZ2V0OnJlZnMvaGVhZHMvbWFzdGVyCjAwM2Y3ZmQxYTYwYjAxZjkxYjMxNGY1OTk1NWE0ZTRkNGU4MGQ4ZWRmMTFkIHJlZnMvaGVhZHMvbWFzdGVyCjAwNDhiMWIzZjk3MjM4MzExNDFhMzFhMWE3MjUyYTIxM2UyMTZlYTc2ZTU2IHJlZnMvaGVhZHMvb2N0b2NhdC1wYXRjaC0xCjAwM2RiM2NiZDViYmQ3ZTgxNDM2ZDJlZWUwNDUzN2VhMmI0YzBjYWQ0Y2RmIHJlZnMvaGVhZHMvdGVzdAowMDAw" + + // POST git-upload-pack command=fetch (want HEAD, deepen 1, filter blob:none) + private val fetchResp = + "MDAxMXNoYWxsb3ctaW5mbwowMDM0c2hhbGxvdyA3ZmQxYTYwYjAxZjkxYjMxNGY1OTk1NWE0ZTRkNGU4MGQ4ZWRmMTFkMDAwMTAwMGRwYWNrZmlsZQowMTMwAVBBQ0sAAAACAAAAAp0UeJydjUtqwzAURedexYOOkz79LQilG2g7aDegz1VssC1XUcj2ayjdQGeXwz2c3gCKGkihBB8hi2S2CSI69tmOxkV2bH3UlsdhDw1bJ2NUkuxcYeSkssnJZOGklcWGoP0Ie6hlxN/fWem1UGIEhIXxOcQYUhRRs/ZHWsrCWbEawr1PtdHXBPpIvabQ6VJ/x+tWHxMazqmuLySUEuyMFEwnHpmHg65z7/iXPLyhXUH7fVmo4fuOW6cnS6XVlT73kHCd6q0/76Gn6SSG4R0PWuYNdCSwZaqFyrzg/ANUZmZ/ogJ4nDM0MDAzMVEIcnV08XVlmMHFGy+5zNvbYEWtE9uqexFF2QmPAZo4Cv5JlwtXPkO1ygyeE+5XX4ICt6XpMDAwNgF9MDAwMA==" + + // POST git-upload-pack command=fetch (want the README blob by oid) + private val blobResp = + "MDAwZHBhY2tmaWxlCjAwM2EBUEFDSwAAAAIAAAABPXic80jNyclXCM8vyklR5AIAIJEESLzcQ5zr061K/FSjSFUFPLVK/b0wMDA2AQgwMDAw" + + @Test + fun parsesCapabilities() { + val caps = GitUploadPackV2.parseCapabilities(PktLineCodec.parse(decode(infoRefs))) + assertEquals("sha1", caps.objectFormat) + assertTrue(caps.supportsLsRefs) + assertTrue(caps.supportsFetch) + assertTrue("server advertised filter", caps.supportsFilter) + assertTrue("server advertised shallow", caps.supportsShallow) + assertEquals("git/github-e0b58ade4980-Linux", caps.agent) + } + + @Test + fun parsesLsRefs() { + val refs = GitUploadPackV2.parseRefs(PktLineCodec.parse(decode(lsRefs))) + val head = refs.first { it.name == "HEAD" } + assertEquals("7fd1a60b01f91b314f59955a4e4d4e80d8edf11d", head.oid) + assertEquals("refs/heads/master", head.symrefTarget) + assertNotNull(refs.firstOrNull { it.name == "refs/heads/master" }) + assertNotNull(refs.firstOrNull { it.name == "refs/heads/test" }) + } + + @Test + fun demuxesAndParsesShallowPack() { + val pack = GitUploadPackV2.extractPack(PktLineCodec.parse(decode(fetchResp))) + val objects = Packfile.parse(pack) + + // commit + root tree, blobs filtered out + val commit = objects["7fd1a60b01f91b314f59955a4e4d4e80d8edf11d"] + assertNotNull(commit) + assertEquals(GitObjectType.COMMIT, commit!!.type) + + val rootTreeOid = GitObjectParser.parseCommitTree(commit.data) + assertEquals("b4eecafa9be2f2006ce1b709d6857b07069b4608", rootTreeOid) + + val tree = objects[rootTreeOid]!! + assertEquals(GitObjectType.TREE, tree.type) + val entries = GitObjectParser.parseTree(tree.data) + val readme = entries.first { it.name == "README" } + assertEquals("980a0d5f19a64b4b30a87d4206aade58726b60e3", readme.oid) + assertFalse(readme.isFolder) + + // blob:none means the README content is not in this pack + assertFalse(objects.containsKey(readme.oid)) + } + + @Test + fun parsesSingleBlobFetch() { + val pack = GitUploadPackV2.extractPack(PktLineCodec.parse(decode(blobResp))) + val objects = Packfile.parse(pack) + val blob = objects["980a0d5f19a64b4b30a87d4206aade58726b60e3"] + assertNotNull(blob) + assertEquals(GitObjectType.BLOB, blob!!.type) + assertEquals("Hello World!\n", blob.data.decodeToString()) + } +} From 059f373ef56543affa43792cd43d346b3b19e002 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 15:25:22 +0000 Subject: [PATCH 02/31] feat: polish git repository README + Code browser UI Make the repository browser flagship-grade rather than utilitarian: - Shared status components: spinner-backed loading box and an icon-led message box with a styled retry action, replacing plain centered text. - Code tab: a repo info bar (branch + short-commit chips, item count), a scrollable chip breadcrumb, and polished entry rows with tinted icon tiles, monospace filenames, folder/file color distinction, a trailing chevron on folders, and hairline dividers. - File viewer: a line-number gutter with horizontally scrollable, syntax-highlighted code, a language/path bar with copy-to-clipboard, and themed surfaces; richer binary/error states. - README tab: spinner loading + icon-led empty/error states. Adds a git_repo_item_count plural and copy/text strings. No new icons. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../loggedIn/gitRepo/code/GitBrowseUi.kt | 186 ++++++++++++++++++ .../loggedIn/gitRepo/code/GitCodeTab.kt | 135 +++++++------ .../loggedIn/gitRepo/code/GitFileViewer.kt | 182 ++++++++++++++--- .../loggedIn/gitRepo/code/GitReadmeTab.kt | 30 +-- amethyst/src/main/res/values/strings.xml | 6 + 5 files changed, 427 insertions(+), 112 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt new file mode 100644 index 0000000000..b631e53f39 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.stringRes + +/** Centered spinner + caption while the repository or a file is loading. */ +@Composable +fun GitLoadingBox( + text: String, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(24.dp), + ) { + CircularProgressIndicator(strokeWidth = 2.5.dp, modifier = Modifier.size(34.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f), + ) + } + } +} + +/** Centered icon + message, with an optional primary retry action. */ +@Composable +fun GitMessageBox( + symbol: MaterialSymbol, + text: String, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, +) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.padding(32.dp), + ) { + Box( + modifier = + Modifier + .size(64.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.08f)), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + ) + } + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + if (onRetry != null) { + FilledTonalButton(onClick = onRetry) { + Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Text( + text = stringRes(R.string.git_repo_retry), + modifier = Modifier.padding(start = 6.dp), + ) + } + } + } + } +} + +/** + * Compact bar identifying the snapshot: the branch and short commit the file + * tree was loaded from, plus the entry count of the current directory. + */ +@Composable +fun RepoInfoBar( + branch: String?, + headCommit: String, + entryCount: Int, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (branch != null) { + InfoChip(symbol = MaterialSymbols.AltRoute, label = branch) + } + InfoChip(symbol = MaterialSymbols.Commit, label = headCommit.take(7), monospace = true) + Text( + text = pluralStringResource(R.plurals.git_repo_item_count, entryCount, entryCount), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), + ) + } +} + +@Composable +private fun InfoChip( + symbol: MaterialSymbol, + label: String, + monospace: Boolean = false, +) { + Row( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + fontFamily = if (monospace) FontFamily.Monospace else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt index 096b1ec504..ddf2f7b5d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement @@ -35,7 +36,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState -import androidx.compose.material3.Button +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -48,6 +49,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -72,19 +75,17 @@ fun GitCodeTab( val scaffoldPadding = LocalDisappearingScaffoldPadding.current when (state) { is GitBrowseState.Loading -> - CenteredStatus(stringRes(R.string.git_repo_code_loading), Modifier.padding(scaffoldPadding)) + GitLoadingBox(stringRes(R.string.git_repo_code_loading), Modifier.padding(scaffoldPadding)) - is GitBrowseState.Error -> - CenteredStatus( - text = - if (state.message == GitRepositoryBrowserViewModel.NO_CLONE_URL) { - stringRes(R.string.git_repo_no_clone_url) - } else { - stringRes(R.string.git_repo_code_error) - }, + is GitBrowseState.Error -> { + val noClone = state.message == GitRepositoryBrowserViewModel.NO_CLONE_URL + GitMessageBox( + symbol = if (noClone) MaterialSymbols.Code else MaterialSymbols.ErrorOutline, + text = if (noClone) stringRes(R.string.git_repo_no_clone_url) else stringRes(R.string.git_repo_code_error), modifier = Modifier.padding(scaffoldPadding), - onRetry = if (state.message == GitRepositoryBrowserViewModel.NO_CLONE_URL) null else viewModel::reload, + onRetry = if (noClone) null else viewModel::reload, ) + } is GitBrowseState.Loaded -> CodeBrowser(state.snapshot, viewModel, accountViewModel, nav, scaffoldPaddingTop = scaffoldPadding) @@ -112,7 +113,7 @@ private fun CodeBrowser( FileHeader(name = openPath.lastOrNull() ?: "", onBack = { openFilePath = null }) HorizontalDivider(thickness = 0.5.dp) if (entry == null) { - CenteredStatus(stringRes(R.string.git_repo_file_load_error), Modifier) + GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_file_load_error)) } else { GitFileViewer( snapshot = snapshot, @@ -132,13 +133,14 @@ private fun CodeBrowser( val entries = remember(snapshot, pathString) { snapshot.entriesAt(path).orEmpty() } Column(Modifier.fillMaxSize().padding(scaffoldPaddingTop)) { + RepoInfoBar(branch = snapshot.branch, headCommit = snapshot.headCommit, entryCount = entries.size) Breadcrumb( path = path, onNavigate = { depth -> pathString = path.take(depth).joinToString("/") }, ) HorizontalDivider(thickness = 0.5.dp) if (entries.isEmpty()) { - CenteredStatus(stringRes(R.string.git_repo_empty_folder), Modifier) + GitMessageBox(MaterialSymbols.Folder, stringRes(R.string.git_repo_empty_folder)) } else { LazyColumn(Modifier.fillMaxSize()) { items(entries, key = { it.name }) { entry -> @@ -149,6 +151,11 @@ private fun CodeBrowser( if (entry.isFolder) pathString = child else openFilePath = child }, ) + HorizontalDivider( + modifier = Modifier.padding(start = 56.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), + ) } } } @@ -165,18 +172,19 @@ private fun Breadcrumb( Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()) - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), ) { - Crumb(label = stringRes(R.string.git_repo_root), enabled = path.isNotEmpty()) { onNavigate(0) } + Crumb(label = stringRes(R.string.git_repo_root), current = path.isEmpty()) { onNavigate(0) } path.forEachIndexed { index, segment -> Icon( symbol = MaterialSymbols.ChevronRight, contentDescription = null, - modifier = Modifier.size(16.dp).padding(horizontal = 2.dp), - tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.4f), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.35f), ) - Crumb(label = segment, enabled = index < path.lastIndex) { onNavigate(index + 1) } + Crumb(label = segment, current = index == path.lastIndex) { onNavigate(index + 1) } } } } @@ -184,21 +192,29 @@ private fun Breadcrumb( @Composable private fun Crumb( label: String, - enabled: Boolean, + current: Boolean, onClick: () -> Unit, ) { + val background = + if (current) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + } + val textColor = if (current) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + Text( text = label, style = MaterialTheme.typography.labelLarge, - fontWeight = if (enabled) FontWeight.Normal else FontWeight.SemiBold, - color = - if (enabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onBackground - }, + fontWeight = if (current) FontWeight.SemiBold else FontWeight.Normal, + color = textColor, maxLines = 1, - modifier = if (enabled) Modifier.clickable(onClick = onClick) else Modifier, + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .let { if (!current) it.clickable(onClick = onClick) else it } + .background(background) + .padding(horizontal = 10.dp, vertical = 4.dp), ) } @@ -212,7 +228,7 @@ private fun EntryRow( Modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 14.dp, vertical = 12.dp), + .padding(horizontal = 12.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -222,23 +238,39 @@ private fun EntryRow( entry.isSubmodule -> MaterialSymbols.Code else -> MaterialSymbols.Description } - Icon( - symbol = symbol, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = - if (entry.isFolder) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) - }, - ) + val tint = if (entry.isFolder) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + Box( + modifier = + Modifier + .size(32.dp) + .clip(RoundedCornerShape(8.dp)) + .background(tint.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(19.dp), + tint = tint, + ) + } Text( text = entry.name, style = MaterialTheme.typography.bodyMedium, + fontFamily = if (entry.isFolder) null else FontFamily.Monospace, + fontWeight = if (entry.isFolder) FontWeight.Medium else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), ) + if (entry.isFolder) { + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.3f), + ) + } } } @@ -256,33 +288,10 @@ private fun FileHeader( Text( text = name, style = MaterialTheme.typography.titleSmall, + fontFamily = FontFamily.Monospace, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } } - -@Composable -private fun CenteredStatus( - text: String, - modifier: Modifier, - onRetry: (() -> Unit)? = null, -) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(24.dp), - ) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - ) - if (onRetry != null) { - Button(onClick = onRetry) { Text(stringRes(R.string.git_repo_retry)) } - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt index bd5436c26b..5ec06412de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt @@ -20,14 +20,21 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code +import android.widget.Toast +import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,26 +42,40 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.ui.components.RichTextViewer +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot import com.vitorpamplona.quartz.nip34Git.git.GitTreeEntry import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.coroutines.cancellation.CancellationException +private val CodeFontSize = 13.sp +private val CodeLineHeight = 20.sp + /** * Renders a single file from the repository. Markdown files render as rich text, - * text files render with syntax highlighting, and binary files show a notice. + * text files render with a line-number gutter and syntax highlighting, and binary + * files show a notice. */ @Composable fun GitFileViewer( @@ -79,13 +100,17 @@ fun GitFileViewer( val bytes = result when { - bytes == null -> CenteredMessage(stringRes(R.string.git_repo_code_loading), modifier) - bytes.isFailure -> CenteredMessage(stringRes(R.string.git_repo_file_load_error), modifier) + bytes == null -> GitLoadingBox(stringRes(R.string.git_repo_code_loading), modifier) + bytes.isFailure -> GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_file_load_error), modifier) else -> { val data = bytes.getOrThrow() when { isProbablyBinary(data) -> - CenteredMessage(stringRes(R.string.git_repo_binary_file, humanSize(data.size)), modifier) + GitMessageBox( + symbol = MaterialSymbols.Description, + text = stringRes(R.string.git_repo_binary_file, humanSize(data.size)), + modifier = modifier, + ) isMarkdownFile(entry.name) -> MarkdownFile(data.decodeToString(), accountViewModel, nav, modifier) else -> @@ -131,47 +156,150 @@ private fun HighlightedCode( modifier: Modifier, ) { val darkMode = isSystemInDarkTheme() + val language = remember(fileName) { CodeHighlighter.languageForFile(fileName) } + val annotated by produceState(AnnotatedString(code), code, fileName, darkMode) { value = withContext(Dispatchers.Default) { - val language = CodeHighlighter.languageForFile(fileName) if (language == null) AnnotatedString(code) else CodeHighlighter.highlight(code, language, darkMode) } } - Text( - text = annotated, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - lineHeight = 18.sp, - softWrap = false, - color = MaterialTheme.colorScheme.onBackground, - modifier = - modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 12.dp, vertical = 8.dp), - ) + Column(modifier.fillMaxSize()) { + CodeBar(languageLabel = language?.name?.let(::prettyLanguage) ?: stringRes(R.string.git_repo_plain_text), code = code) + HorizontalDivider(thickness = 0.5.dp) + CodeWithLineNumbers(annotated, Modifier.fillMaxSize()) + } } @Composable -private fun CenteredMessage( - text: String, - modifier: Modifier, +private fun CodeBar( + languageLabel: String, + code: String, ) { - Column( - modifier = modifier.fillMaxSize().padding(24.dp), + val clipboard = LocalClipboard.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)) + .padding(start = 14.dp, end = 4.dp, top = 2.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, ) { Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + text = languageLabel, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), ) + IconButton( + onClick = { + scope.launch { + clipboard.setText(code) + Toast.makeText(context, stringRes(context, R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show() + } + }, + ) { + Icon( + symbol = MaterialSymbols.ContentCopy, + contentDescription = stringRes(R.string.git_repo_copy_file), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } +@Composable +private fun CodeWithLineNumbers( + annotated: AnnotatedString, + modifier: Modifier, +) { + val lineRanges = remember(annotated) { computeLineRanges(annotated.text) } + val gutterDigits = lineRanges.size.toString().length + val gutterWidth = (gutterDigits * 9 + 20).dp + + val verticalScroll = rememberScrollState() + val horizontalScroll = rememberScrollState() + val gutterColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f) + + Row(modifier.verticalScroll(verticalScroll)) { + // Sticky line-number gutter (only the code area scrolls horizontally). + Column( + modifier = + Modifier + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + .width(gutterWidth) + .padding(vertical = 8.dp, horizontal = 4.dp), + horizontalAlignment = Alignment.End, + ) { + for (i in lineRanges.indices) { + Text( + text = (i + 1).toString(), + fontFamily = FontFamily.Monospace, + fontSize = CodeFontSize, + lineHeight = CodeLineHeight, + color = gutterColor, + textAlign = TextAlign.End, + ) + } + } + Column( + modifier = + Modifier + .horizontalScroll(horizontalScroll) + .padding(start = 12.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + ) { + for ((start, end) in lineRanges) { + Text( + text = annotated.subSequence(start, end), + fontFamily = FontFamily.Monospace, + fontSize = CodeFontSize, + lineHeight = CodeLineHeight, + softWrap = false, + maxLines = 1, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + } +} + +/** Splits text into per-line character ranges, dropping a single trailing newline's empty line. */ +private fun computeLineRanges(text: String): List> { + val ranges = ArrayList>() + var lineStart = 0 + var i = 0 + while (i < text.length) { + if (text[i] == '\n') { + ranges.add(lineStart to i) + lineStart = i + 1 + } + i++ + } + ranges.add(lineStart to text.length) + if (ranges.size > 1 && ranges.last().let { it.first == it.second }) { + ranges.removeAt(ranges.size - 1) + } + return ranges +} + +private fun prettyLanguage(enumName: String): String = + when (enumName) { + "CPP" -> "C++" + "CSHARP" -> "C#" + "JAVASCRIPT" -> "JavaScript" + "TYPESCRIPT" -> "TypeScript" + "COFFEESCRIPT" -> "CoffeeScript" + "PHP" -> "PHP" + else -> enumName.lowercase().replaceFirstChar { it.uppercase() } + } + private fun isMarkdownFile(name: String): Boolean { val ext = name.substringAfterLast('.', "").lowercase() return ext == "md" || ext == "markdown" || ext == "mdown" || ext == "mkd" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt index 1f9e4d5fd7..4bc30479db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding import com.vitorpamplona.amethyst.ui.components.RichTextViewer @@ -101,8 +102,8 @@ private fun ReadmeContent( } when (val text = content) { - null -> StatusText(stringRes(R.string.git_repo_code_loading), scaffoldPaddingTop) - "" -> StatusText(stringRes(R.string.git_repo_file_load_error), scaffoldPaddingTop) + null -> GitLoadingBox(stringRes(R.string.git_repo_code_loading), Modifier.padding(scaffoldPaddingTop)) + "" -> GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_file_load_error), Modifier.padding(scaffoldPaddingTop)) else -> { val background = MaterialTheme.colorScheme.background val backgroundColor = remember { mutableStateOf(background) } @@ -139,10 +140,11 @@ private fun ReadmeFallback( ) { val description = event.description()?.takeIf { it.isNotBlank() } if (description == null) { - StatusText( - text = if (loading) stringRes(R.string.git_repo_code_loading) else stringRes(R.string.git_repo_readme_missing), - scaffoldPaddingTop = scaffoldPaddingTop, - ) + if (loading) { + GitLoadingBox(stringRes(R.string.git_repo_code_loading), Modifier.padding(scaffoldPaddingTop)) + } else { + GitMessageBox(MaterialSymbols.Description, stringRes(R.string.git_repo_readme_missing), Modifier.padding(scaffoldPaddingTop)) + } return } @@ -181,22 +183,6 @@ private fun ReadmeFallback( } } -@Composable -private fun StatusText( - text: String, - scaffoldPaddingTop: PaddingValues, -) { - Column( - modifier = Modifier.fillMaxSize().padding(scaffoldPaddingTop).padding(24.dp), - ) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), - ) - } -} - /** Picks the most README-like file in the root, preferring markdown. */ private fun findReadme(entries: List): GitTreeEntry? { val files = entries.filter { !it.isFolder } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 07fbdda10c..08df8e1484 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2589,6 +2589,12 @@ This folder is empty. root Retry + Copy file contents + Text + + %1$d item + %1$d items + Git Repositories nSite: %1$s nApplet: %1$s From 39727d819eaa396006f85b6968b9bf3c9cd8f829 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 16:00:35 +0000 Subject: [PATCH 03/31] =?UTF-8?q?feat:=20flagship=20git=20review=20?= =?UTF-8?q?=E2=80=94=20diff=20viewer,=20issue=20management,=20polished=20I?= =?UTF-8?q?ssues=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build out the repository screen into a project-review hub. PR/patch diff viewer: - quartz: UnifiedDiffParser turns a NIP-34 patch (kind 1617) `git format-patch` body into a commit message + structured per-file diffs, bounding each hunk by its header counts so the mbox "-- " signature is never miscounted. Unit-tested against a real git-generated patch. - amethyst: GitDiffView renders a GitHub-style file-by-file diff — stat summary, collapsible file cards, +/- line coloring, old/new line numbers, and per-line syntax highlighting reused from the code browser (size-guarded). Wired into the patch card; feeds keep the compact preview. Issue management: - New-issue composer (subject + body) that builds, signs and broadcasts a GitIssueEvent via the account signer; reachable from the Issues tab. - Author/maintainer Open/Close controls on the issue card, publishing GitStatusOpen/Closed events that flow back through GitStatusIndex. Issues tab polish: - Open/Closed filter chips, label (#topic) chips on each row. Adds git_diff_files_changed plural and issue/diff strings. No new icons. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../amethyst/ui/note/types/Git.kt | 22 +- .../amethyst/ui/note/types/GitDiffView.kt | 305 ++++++++++++++++++ .../ui/note/types/GitIssueStatusActions.kt | 125 +++++++ .../screen/loggedIn/gitRepo/GitItemListRow.kt | 35 +- .../ui/screen/loggedIn/gitRepo/GitNewIssue.kt | 113 +++++++ .../loggedIn/gitRepo/GitRepositoryScreen.kt | 95 +++++- amethyst/src/main/res/values/strings.xml | 13 + .../nip34Git/patch/UnifiedDiffParser.kt | 301 +++++++++++++++++ .../nip34Git/patch/UnifiedDiffParserTest.kt | 117 +++++++ 9 files changed, 1107 insertions(+), 19 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitIssueStatusActions.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitNewIssue.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/UnifiedDiffParser.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/UnifiedDiffParserTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index b1fc54e0bf..1d5eb80eec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.patch.UnifiedDiffParser import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent @@ -433,7 +434,22 @@ private fun RenderGitPatchEvent( Spacer(modifier = HalfDoubleVertSpacer) - GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) + // In a collapsed feed preview keep the lightweight markdown body; in the + // full (thread) view parse the patch into a proper file-by-file diff. + val parsed = if (makeItShort) null else remember(noteEvent) { UnifiedDiffParser.parse(noteEvent.content) } + if (parsed != null && parsed.hasDiff) { + if (parsed.message.isNotBlank()) { + Text( + text = parsed.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = HalfDoubleVertSpacer) + } + GitDiffView(parsed, Modifier.fillMaxWidth()) + } else { + GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) + } } } @@ -504,6 +520,10 @@ private fun RenderGitIssueEvent( Spacer(modifier = HalfDoubleVertSpacer) GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) + + if (!makeItShort) { + GitIssueStatusActions(note, accountViewModel) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt new file mode 100644 index 0000000000..70544136dd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.CodeHighlighter +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.patch.GitDiffFile +import com.vitorpamplona.quartz.nip34Git.patch.GitDiffLine +import com.vitorpamplona.quartz.nip34Git.patch.GitDiffLineType +import com.vitorpamplona.quartz.nip34Git.patch.GitFileChange +import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch +import dev.snipme.highlights.model.SyntaxLanguage + +private val AddColor = Color(0xFF1F883D) +private val DeleteColor = Color(0xFFCF222E) +private val DiffFontSize = 12.sp +private val DiffLineHeight = 17.sp + +// Above this many diff lines we skip per-line syntax highlighting to stay snappy. +private const val HIGHLIGHT_LINE_BUDGET = 600 + +/** + * Renders a parsed [ParsedPatch] as a GitHub-style file-by-file diff: a stat + * summary, then one collapsible card per file with +/- line coloring, old/new + * line numbers, and (for reasonably sized diffs) syntax highlighting reused from + * the repository code browser. + */ +@Composable +fun GitDiffView( + parsed: ParsedPatch, + modifier: Modifier = Modifier, +) { + if (!parsed.hasDiff) return + val highlightEnabled = + remember(parsed) { parsed.files.sumOf { f -> f.hunks.sumOf { it.lines.size } } <= HIGHLIGHT_LINE_BUDGET } + + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + DiffStatSummary(parsed) + parsed.files.forEach { file -> + DiffFileCard(file, highlightEnabled) + } + } +} + +@Composable +private fun DiffStatSummary(parsed: ParsedPatch) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + val fileCount = parsed.files.size + Text( + text = pluralStringResource(R.plurals.git_diff_files_changed, fileCount, fileCount), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text("+${parsed.totalAdditions}", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = AddColor) + Text("-${parsed.totalDeletions}", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = DeleteColor) + } +} + +@Composable +private fun DiffFileCard( + file: GitDiffFile, + highlightEnabled: Boolean, +) { + var expanded by rememberSaveable(file.displayPath) { mutableStateOf(true) } + val language = remember(file.displayPath) { CodeHighlighter.languageForFile(file.displayPath) } + val darkMode = isSystemInDarkTheme() + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surface), + ) { + // File header + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val (symbol, tint) = changeBadge(file.change) + Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(16.dp), tint = tint) + Text( + text = file.displayPath, + style = MaterialTheme.typography.labelMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (file.additions > 0) Text("+${file.additions}", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, color = AddColor) + if (file.deletions > 0) Text("-${file.deletions}", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, color = DeleteColor) + Icon( + symbol = if (expanded) MaterialSymbols.KeyboardArrowUp else MaterialSymbols.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (expanded) { + if (file.isBinary) { + Text( + text = stringRes(R.string.git_diff_binary), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp), + ) + } else { + val gutterWidth = remember(file) { gutterWidthFor(file) } + Column(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) { + file.hunks.forEach { hunk -> + HunkHeaderRow(hunk.header) + hunk.lines.forEach { line -> + DiffLineRow(line, gutterWidth, language.takeIf { highlightEnabled }, darkMode) + } + } + } + } + } + } +} + +@Composable +private fun HunkHeaderRow(header: String) { + Text( + text = header, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + fontSize = DiffFontSize, + lineHeight = DiffLineHeight, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.85f), + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.06f)) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) +} + +@Composable +private fun DiffLineRow( + line: GitDiffLine, + gutterWidth: Dp, + language: SyntaxLanguage?, + darkMode: Boolean, +) { + val background = + when (line.type) { + GitDiffLineType.ADD -> AddColor.copy(alpha = 0.12f) + GitDiffLineType.DELETE -> DeleteColor.copy(alpha = 0.12f) + GitDiffLineType.CONTEXT -> Color.Transparent + } + val marker = + when (line.type) { + GitDiffLineType.ADD -> "+" + GitDiffLineType.DELETE -> "-" + GitDiffLineType.CONTEXT -> " " + } + val markerColor = + when (line.type) { + GitDiffLineType.ADD -> AddColor + GitDiffLineType.DELETE -> DeleteColor + GitDiffLineType.CONTEXT -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + } + val numberColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f) + + val content = + remember(line.content, language, darkMode) { + if (language != null && line.content.isNotEmpty()) { + CodeHighlighter.highlight(line.content, language, darkMode) + } else { + AnnotatedString(line.content) + } + } + + Row( + modifier = Modifier.background(background), + verticalAlignment = Alignment.Top, + ) { + LineNumber(line.oldNumber, gutterWidth, numberColor) + LineNumber(line.newNumber, gutterWidth, numberColor) + Text( + text = marker, + fontFamily = FontFamily.Monospace, + fontSize = DiffFontSize, + lineHeight = DiffLineHeight, + color = markerColor, + modifier = Modifier.padding(start = 4.dp), + ) + Text( + text = content, + fontFamily = FontFamily.Monospace, + fontSize = DiffFontSize, + lineHeight = DiffLineHeight, + softWrap = false, + maxLines = 1, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 4.dp, end = 12.dp), + ) + } +} + +@Composable +private fun LineNumber( + number: Int?, + width: Dp, + color: Color, +) { + Text( + text = number?.toString() ?: "", + fontFamily = FontFamily.Monospace, + fontSize = DiffFontSize, + lineHeight = DiffLineHeight, + color = color, + textAlign = TextAlign.End, + maxLines = 1, + modifier = Modifier.width(width).padding(horizontal = 4.dp), + ) +} + +@Composable +private fun changeBadge(change: GitFileChange): Pair = + when (change) { + GitFileChange.ADD -> MaterialSymbols.AddCircle to AddColor + GitFileChange.DELETE -> MaterialSymbols.Cancel to DeleteColor + GitFileChange.RENAME -> MaterialSymbols.AltRoute to MaterialTheme.colorScheme.primary + GitFileChange.MODIFY -> MaterialSymbols.Edit to MaterialTheme.colorScheme.primary + } + +private fun gutterWidthFor(file: GitDiffFile): Dp { + val maxLine = + file.hunks.maxOfOrNull { hunk -> + hunk.lines.maxOfOrNull { maxOf(it.oldNumber ?: 0, it.newNumber ?: 0) } ?: 0 + } ?: 0 + val digits = maxLine.toString().length.coerceAtLeast(2) + return (digits * 8 + 8).dp +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitIssueStatusActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitIssueStatusActions.kt new file mode 100644 index 0000000000..fe9a041c32 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitIssueStatusActions.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.GitStatusIndex +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent + +/** + * Open / Close controls for a git issue. Visible only to the people NIP-34 lets + * moderate the thread: the issue author and the repository owner / maintainers. + * Publishing toggles the issue between [GitStatusOpenEvent] and + * [GitStatusClosedEvent]; [GitStatusIndex] then reflects the change everywhere. + */ +@Composable +fun GitIssueStatusActions( + issueNote: Note, + accountViewModel: AccountViewModel, +) { + val event = issueNote.event as? GitIssueEvent ?: return + + val canModerate = remember(event, issueNote) { canModerate(event, issueNote, accountViewModel) } + if (!canModerate) return + + LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() } + val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle() + if (index == null) return // wait for the initial scan + val isClosed = GitStatusIndex.isClosedOrResolved(issueNote.idHex) + + Row( + modifier = Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (isClosed) { + FilledTonalButton(onClick = { sendIssueStatus(accountViewModel, issueNote, close = false) }) { + Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.git_issue_reopen), modifier = Modifier.padding(start = 6.dp)) + } + } else { + OutlinedButton( + onClick = { sendIssueStatus(accountViewModel, issueNote, close = true) }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.git_issue_close), modifier = Modifier.padding(start = 6.dp)) + } + } + } +} + +private fun canModerate( + event: GitIssueEvent, + issueNote: Note, + accountViewModel: AccountViewModel, +): Boolean { + val myHex = accountViewModel.account.signer.pubKey + if (accountViewModel.isLoggedUser(issueNote.author)) return true + val ownerHex = event.repositoryAddress()?.pubKeyHex + if (myHex == ownerHex) return true + // Include extra maintainers when the repository event is already in cache. + val repoEvent = + event.repositoryAddress()?.let { LocalCache.getAddressableNoteIfExists(it)?.event as? GitRepositoryEvent } + return repoEvent?.maintainers()?.contains(myHex) == true +} + +private fun sendIssueStatus( + accountViewModel: AccountViewModel, + issueNote: Note, + close: Boolean, +) { + val target = issueNote.toEventHint() ?: return + accountViewModel.launchSigner { + val template = + if (close) { + GitStatusClosedEvent.build("", target) + } else { + GitStatusOpenEvent.build("", target) + } + val signed = accountViewModel.account.signer.sign(template) + accountViewModel.account.sendAutomatic(signed) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt index 612bb635cf..6e41084b91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt @@ -21,14 +21,17 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -37,6 +40,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -196,11 +200,33 @@ private fun GitItemRowContent( overflow = TextOverflow.Ellipsis, ) - GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN) + val labels = remember(note.event) { gitLabelsOf(note.event) } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN) + labels.take(6).forEach { LabelChip(it) } + } } } } +@Composable +private fun LabelChip(label: String) { + Text( + text = "#$label", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) +} + private fun gitSubjectOf(event: Event?): String? = when (event) { is GitIssueEvent -> event.subject()?.takeIf { it.isNotBlank() } @@ -208,3 +234,10 @@ private fun gitSubjectOf(event: Event?): String? = is GitPatchEvent -> event.subject()?.takeIf { it.isNotBlank() } else -> null } + +private fun gitLabelsOf(event: Event?): List = + when (event) { + is GitIssueEvent -> event.topics() + is GitPullRequestEvent -> event.labels() + else -> emptyList() + }.filter { it.isNotBlank() }.distinct() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitNewIssue.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitNewIssue.kt new file mode 100644 index 0000000000..d34c982aca --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitNewIssue.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +/** + * A minimal "New issue" composer: a subject and a markdown body. On submit it + * builds a NIP-34 [GitIssueEvent] addressed to [repoNote], signs it with the + * account signer and broadcasts it. The repository owner is notified via the `a` + * tag the builder adds automatically. + */ +@Composable +fun GitNewIssueDialog( + repoNote: AddressableNote, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + var subject by rememberSaveable { mutableStateOf("") } + var body by rememberSaveable { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(MaterialSymbols.Description, contentDescription = null, modifier = Modifier.size(24.dp)) }, + title = { Text(stringRes(R.string.git_new_issue_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = subject, + onValueChange = { subject = it }, + label = { Text(stringRes(R.string.git_new_issue_subject)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = body, + onValueChange = { body = it }, + label = { Text(stringRes(R.string.git_new_issue_body)) }, + minLines = 4, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + enabled = subject.isNotBlank(), + onClick = { + sendGitIssue(accountViewModel, repoNote, subject.trim(), body.trim()) + onDismiss() + }, + ) { + Text(stringRes(R.string.git_new_issue_create)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.git_new_issue_cancel)) } + }, + ) +} + +private fun sendGitIssue( + accountViewModel: AccountViewModel, + repoNote: AddressableNote, + subject: String, + body: String, +) { + val repositoryHint = repoNote.toEventHint() ?: return + accountViewModel.launchSigner { + val template = GitIssueEvent.build(subject, body, repositoryHint, emptyList(), emptyList()) + val signed = accountViewModel.account.signer.sign(template) + accountViewModel.account.sendAutomatic(signed) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index 03df150470..80da2bba56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -24,18 +24,20 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SecondaryTabRow -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -53,6 +55,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel @@ -261,8 +265,9 @@ private fun GitRepositoryScreen( } 3 -> { - StatusSplitFeed( - persistKey = note.idHex + "GitRepoIssuesStatus", + GitIssuesTab( + note = note, + event = event, openViewModel = openIssuesViewModel, closedViewModel = closedIssuesViewModel, accountViewModel = accountViewModel, @@ -285,10 +290,51 @@ private fun GitRepositoryScreen( } /** - * Wraps a feed in an Open / Closed & Resolved segmented selector, swapping between two + * The Issues tab: the open/closed status feed plus a "New issue" composer reachable + * from the filter row once the repository announcement has loaded. + */ +@Composable +private fun GitIssuesTab( + note: AddressableNote, + event: GitRepositoryEvent?, + openViewModel: RepositoryIssuesFeedViewModel, + closedViewModel: RepositoryIssuesFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + var showNewIssue by rememberSaveable(note.idHex) { mutableStateOf(false) } + + StatusSplitFeed( + persistKey = note.idHex + "GitRepoIssuesStatus", + openViewModel = openViewModel, + closedViewModel = closedViewModel, + accountViewModel = accountViewModel, + nav = nav, + headerAction = if (event != null) ({ NewIssueButton { showNewIssue = true } }) else null, + ) + + if (showNewIssue && event != null) { + GitNewIssueDialog(repoNote = note, accountViewModel = accountViewModel, onDismiss = { showNewIssue = false }) + } +} + +@Composable +private fun NewIssueButton(onClick: () -> Unit) { + FilledTonalButton( + onClick = onClick, + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 6.dp), + ) { + Icon(MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.git_new_issue_button), modifier = Modifier.padding(start = 6.dp)) + } +} + +/** + * Wraps a feed in an Open / Closed & Resolved status filter, swapping between two * status-scoped feed view models. Each view model already filters by NIP-34 status, so the * selector only chooses which one is rendered. The selection survives configuration changes - * and tab swipes via [persistKey]. + * and tab swipes via [persistKey]. An optional [headerAction] (e.g. a "New issue" button) + * is shown at the trailing edge of the filter row. */ @Composable private fun StatusSplitFeed( @@ -297,29 +343,44 @@ private fun StatusSplitFeed( closedViewModel: FeedViewModel, accountViewModel: AccountViewModel, nav: INav, + headerAction: (@Composable () -> Unit)? = null, ) { var showClosed by rememberSaveable(persistKey) { mutableStateOf(false) } Column(Modifier.fillMaxSize()) { - SingleChoiceSegmentedButtonRow( + Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - SegmentedButton( + FilterChip( selected = !showClosed, onClick = { showClosed = false }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), - ) { - Text(stringRes(R.string.git_repo_filter_open)) - } - SegmentedButton( + label = { Text(stringRes(R.string.git_repo_filter_open)) }, + leadingIcon = + if (!showClosed) { + { Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp)) } + } else { + null + }, + ) + FilterChip( selected = showClosed, onClick = { showClosed = true }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), - ) { - Text(stringRes(R.string.git_repo_filter_closed)) + label = { Text(stringRes(R.string.git_repo_filter_closed)) }, + leadingIcon = + if (showClosed) { + { Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp)) } + } else { + null + }, + ) + if (headerAction != null) { + Spacer(Modifier.weight(1f)) + headerAction() } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 08df8e1484..6c1cc324d8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2595,6 +2595,19 @@ %1$d item %1$d items + + %1$d file changed + %1$d files changed + + Binary file not shown. + New + New issue + Title + Description + Create + Cancel + Close issue + Reopen Git Repositories nSite: %1$s nApplet: %1$s diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/UnifiedDiffParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/UnifiedDiffParser.kt new file mode 100644 index 0000000000..9cc53a2fc3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/UnifiedDiffParser.kt @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.patch + +/** How a file changed within a diff. */ +enum class GitFileChange { + ADD, + DELETE, + MODIFY, + RENAME, +} + +/** The role of a single line inside a hunk. */ +enum class GitDiffLineType { + CONTEXT, + ADD, + DELETE, +} + +class GitDiffLine( + val type: GitDiffLineType, + val content: String, + val oldNumber: Int?, + val newNumber: Int?, +) + +class GitDiffHunk( + val header: String, + val oldStart: Int, + val newStart: Int, + val lines: List, +) + +class GitDiffFile( + val oldPath: String?, + val newPath: String?, + val change: GitFileChange, + val isBinary: Boolean, + val hunks: List, +) { + val displayPath: String get() = newPath ?: oldPath ?: "?" + val additions: Int get() = hunks.sumOf { hunk -> hunk.lines.count { it.type == GitDiffLineType.ADD } } + val deletions: Int get() = hunks.sumOf { hunk -> hunk.lines.count { it.type == GitDiffLineType.DELETE } } +} + +/** A parsed patch: the human-readable commit message and the changed files. */ +class ParsedPatch( + val message: String, + val files: List, +) { + val hasDiff: Boolean get() = files.isNotEmpty() + val totalAdditions: Int get() = files.sumOf { it.additions } + val totalDeletions: Int get() = files.sumOf { it.deletions } +} + +/** + * Parses a `git format-patch` / unified-diff string (the `content` of a NIP-34 + * patch event, kind 1617) into a commit message plus a structured list of file + * diffs. Tolerant of partial input: anything that isn't a recognizable diff is + * returned as the [ParsedPatch.message]. + */ +object UnifiedDiffParser { + private val DIFF_GIT_PREFIX = "diff --git " + private val HUNK_PREFIX = "@@" + private val MBOX_FROM = Regex("^From [0-9a-fA-F]{7,40}\\b.*") + private val HEADER_KEYS = + setOf( + "from", + "date", + "subject", + "mime-version", + "content-type", + "content-transfer-encoding", + "message-id", + "in-reply-to", + "references", + ) + + fun parse(patch: String): ParsedPatch { + val lines = patch.split("\n") + val firstDiff = lines.indexOfFirst { it.startsWith(DIFF_GIT_PREFIX) } + + if (firstDiff < 0) { + return ParsedPatch(cleanMessage(lines), emptyList()) + } + + val message = cleanMessage(lines.subList(0, firstDiff)) + val files = parseFiles(lines, firstDiff) + return ParsedPatch(message, files) + } + + private fun parseFiles( + lines: List, + from: Int, + ): List { + // Split into per-file blocks at each "diff --git" line. + val blocks = ArrayList() + var blockStart = -1 + for (i in from until lines.size) { + if (lines[i].startsWith(DIFF_GIT_PREFIX)) { + if (blockStart >= 0) blocks.add(blockStart until i) + blockStart = i + } + } + if (blockStart >= 0) blocks.add(blockStart until lines.size) + + return blocks.map { parseFile(lines, it) } + } + + private fun parseFile( + lines: List, + range: IntRange, + ): GitDiffFile { + var oldPath: String? + var newPath: String? + run { + val header = lines[range.first].removePrefix(DIFF_GIT_PREFIX) + val sep = header.indexOf(" b/") + if (sep >= 0) { + oldPath = + header + .substring(0, sep) + .removePrefix("a/") + .trim() + .ifBlank { null } + newPath = + header + .substring(sep + 1) + .removePrefix("b/") + .trim() + .ifBlank { null } + } else { + oldPath = null + newPath = null + } + } + + var change = GitFileChange.MODIFY + var isBinary = false + + var i = range.first + 1 + while (i <= range.last && !lines[i].startsWith(HUNK_PREFIX)) { + val line = lines[i] + when { + line.startsWith("new file mode") -> change = GitFileChange.ADD + line.startsWith("deleted file mode") -> change = GitFileChange.DELETE + line.startsWith("rename from ") -> { + change = GitFileChange.RENAME + oldPath = line.removePrefix("rename from ").trim() + } + line.startsWith("rename to ") -> { + change = GitFileChange.RENAME + newPath = line.removePrefix("rename to ").trim() + } + line.startsWith("--- ") -> oldPath = resolveSidePath(line.removePrefix("--- "), oldPath) + line.startsWith("+++ ") -> newPath = resolveSidePath(line.removePrefix("+++ "), newPath) + line.startsWith("Binary files") || line.startsWith("GIT binary patch") -> isBinary = true + } + i++ + } + + val hunks = if (isBinary) emptyList() else parseHunks(lines, i, range.last) + if (oldPath == null && newPath != null && change == GitFileChange.MODIFY) change = GitFileChange.ADD + if (newPath == null && oldPath != null && change == GitFileChange.MODIFY) change = GitFileChange.DELETE + return GitDiffFile(oldPath, newPath, change, isBinary, hunks) + } + + private fun parseHunks( + lines: List, + from: Int, + last: Int, + ): List { + val hunks = ArrayList() + var i = from + while (i <= last) { + if (!lines[i].startsWith(HUNK_PREFIX)) { + i++ + continue + } + val header = lines[i] + val (oldStart, oldLen, newStart, newLen) = parseHunkHeader(header) + val hunkLines = ArrayList() + var oldNo = oldStart + var newNo = newStart + // Bound the hunk by its declared line counts so trailing content (e.g. the + // format-patch "-- " signature) is never mistaken for a deletion. + var remainingOld = oldLen + var remainingNew = newLen + i++ + while (i <= last && (remainingOld > 0 || remainingNew > 0)) { + val line = lines[i] + if (line.startsWith(HUNK_PREFIX) || line.startsWith(DIFF_GIT_PREFIX)) break + when { + line.startsWith("+") -> { + hunkLines.add(GitDiffLine(GitDiffLineType.ADD, line.substring(1), null, newNo)) + newNo++ + remainingNew-- + } + line.startsWith("-") -> { + hunkLines.add(GitDiffLine(GitDiffLineType.DELETE, line.substring(1), oldNo, null)) + oldNo++ + remainingOld-- + } + line.startsWith("\\") -> {} // "\ No newline at end of file" + else -> { + val text = if (line.startsWith(" ")) line.substring(1) else line + hunkLines.add(GitDiffLine(GitDiffLineType.CONTEXT, text, oldNo, newNo)) + oldNo++ + newNo++ + remainingOld-- + remainingNew-- + } + } + i++ + } + hunks.add(GitDiffHunk(header, oldStart, newStart, hunkLines)) + } + return hunks + } + + private data class HunkHeader( + val oldStart: Int, + val oldLen: Int, + val newStart: Int, + val newLen: Int, + ) + + /** Parses `@@ -oldStart,oldLen +newStart,newLen @@ section`. Omitted lengths default to 1. */ + private fun parseHunkHeader(header: String): HunkHeader { + // header looks like: @@ -12,7 +12,9 @@ optional context + val body = header.removePrefix("@@").substringBefore("@@").trim() + var oldStart = 0 + var oldLen = 1 + var newStart = 0 + var newLen = 1 + for (token in body.split(' ')) { + when { + token.startsWith("-") -> { + val v = token.removePrefix("-") + oldStart = v.substringBefore(',').toIntOrNull() ?: 0 + oldLen = if (',' in v) v.substringAfter(',').toIntOrNull() ?: 1 else 1 + } + token.startsWith("+") -> { + val v = token.removePrefix("+") + newStart = v.substringBefore(',').toIntOrNull() ?: 0 + newLen = if (',' in v) v.substringAfter(',').toIntOrNull() ?: 1 else 1 + } + } + } + return HunkHeader(oldStart, oldLen, newStart, newLen) + } + + /** Resolves a `--- ` / `+++ ` target: `/dev/null` clears the side; a real path sets it; blank keeps [current]. */ + private fun resolveSidePath( + raw: String, + current: String?, + ): String? { + val path = raw.trim() + if (path == "/dev/null") return null + val cleaned = path.removePrefix("a/").removePrefix("b/") + return cleaned.ifBlank { current } + } + + /** Extracts the commit-message body from a format-patch preamble, dropping mbox/email headers and the diffstat. */ + private fun cleanMessage(lines: List): String { + var i = 0 + if (i < lines.size && MBOX_FROM.matches(lines[i])) i++ + + val looksLikeHeaders = i < lines.size && lines[i].substringBefore(':').lowercase() in HEADER_KEYS + if (looksLikeHeaders) { + while (i < lines.size && lines[i].isNotBlank()) i++ + if (i < lines.size && lines[i].isBlank()) i++ + } + + val body = StringBuilder() + while (i < lines.size) { + if (lines[i].trimEnd() == "---") break + body.append(lines[i]).append('\n') + i++ + } + return body.toString().trim() + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/UnifiedDiffParserTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/UnifiedDiffParserTest.kt new file mode 100644 index 0000000000..dffc656b52 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/UnifiedDiffParserTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.patch + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Base64 + +class UnifiedDiffParserTest { + // A real `git format-patch -1 --stdout`: modify a.txt, delete b.txt, add c.txt. + private val sampleB64 = + "RnJvbSAyZTllMGZlZjZhOWZkMTZiYzdjZGNhMjY2ZmI2MGVlNTdmN2Q0NzZjIE1vbiBTZXAgMTcgMDA6MDA6MDAgMjAwMQpGcm9tOiBUZXN0ZXIgPHRAdC5jb20+CkRhdGU6IFN1biwgMjggSnVuIDIwMjYgMTU6NDc6MTkgKzAwMDAKU3ViamVjdDogW1BBVENIXSBVcGRhdGUgYS50eHQsIHJlbW92ZSBiLnR4dCwgYWRkIGMudHh0CgpUaGlzIGlzIHRoZSBib2R5IG9mIHRoZSBjb21taXQgbWVzc2FnZS4KSXQgc3BhbnMgbXVsdGlwbGUgbGluZXMuCi0tLQogYS50eHQgfCAzICsrLQogYi50eHQgfCAxIC0KIGMudHh0IHwgMiArKwogMyBmaWxlcyBjaGFuZ2VkLCA0IGluc2VydGlvbnMoKyksIDIgZGVsZXRpb25zKC0pCiBkZWxldGUgbW9kZSAxMDA2NDQgYi50eHQKIGNyZWF0ZSBtb2RlIDEwMDY0NCBjLnR4dAoKZGlmZiAtLWdpdCBhL2EudHh0IGIvYS50eHQKaW5kZXggMGMyYWEzOC4uNThlMWYxMiAxMDA2NDQKLS0tIGEvYS50eHQKKysrIGIvYS50eHQKQEAgLTEsMyArMSw0IEBACiBsaW5lIG9uZQotbGluZSB0d28KK2xpbmUgdHdvIENIQU5HRUQKIGxpbmUgdGhyZWUKK2xpbmUgZm91cgpkaWZmIC0tZ2l0IGEvYi50eHQgYi9iLnR4dApkZWxldGVkIGZpbGUgbW9kZSAxMDA2NDQKaW5kZXggMmZhOTkyYy4uMDAwMDAwMAotLS0gYS9iLnR4dAorKysgL2Rldi9udWxsCkBAIC0xICswLDAgQEAKLWtlZXAKZGlmZiAtLWdpdCBhL2MudHh0IGIvYy50eHQKbmV3IGZpbGUgbW9kZSAxMDA2NDQKaW5kZXggMDAwMDAwMC4uOTQ5NTRhYgotLS0gL2Rldi9udWxsCisrKyBiL2MudHh0CkBAIC0wLDAgKzEsMiBAQAoraGVsbG8KK3dvcmxkCi0tIAoyLjQzLjAKCg==" + + private fun sample(): String = String(Base64.getDecoder().decode(sampleB64)) + + @Test + fun extractsCommitMessageBody() { + val parsed = UnifiedDiffParser.parse(sample()) + assertEquals("This is the body of the commit message.\nIt spans multiple lines.", parsed.message) + assertTrue(parsed.hasDiff) + } + + @Test + fun parsesAllThreeFiles() { + val files = UnifiedDiffParser.parse(sample()).files + assertEquals(3, files.size) + + val a = files[0] + assertEquals("a.txt", a.displayPath) + assertEquals(GitFileChange.MODIFY, a.change) + assertEquals(2, a.additions) // "line two CHANGED" + "line four" + assertEquals(1, a.deletions) // "line two" + + val b = files[1] + assertEquals("b.txt", b.displayPath) + assertEquals(GitFileChange.DELETE, b.change) + assertNull(b.newPath) + assertEquals(1, b.deletions) + + val c = files[2] + assertEquals("c.txt", c.displayPath) + assertEquals(GitFileChange.ADD, c.change) + assertNull(c.oldPath) + assertEquals(2, c.additions) + } + + @Test + fun assignsLineNumbers() { + val a = UnifiedDiffParser.parse(sample()).files[0] + val hunk = a.hunks.single() + assertEquals(1, hunk.oldStart) + assertEquals(1, hunk.newStart) + + // context "line one" keeps both numbers + val first = hunk.lines.first() + assertEquals(GitDiffLineType.CONTEXT, first.type) + assertEquals("line one", first.content) + assertEquals(1, first.oldNumber) + assertEquals(1, first.newNumber) + + val deleted = hunk.lines.first { it.type == GitDiffLineType.DELETE } + assertEquals("line two", deleted.content) + assertNull(deleted.newNumber) + + val added = hunk.lines.first { it.type == GitDiffLineType.ADD } + assertEquals("line two CHANGED", added.content) + assertNull(added.oldNumber) + } + + @Test + fun totalsAcrossFiles() { + val parsed = UnifiedDiffParser.parse(sample()) + assertEquals(4, parsed.totalAdditions) + assertEquals(2, parsed.totalDeletions) + } + + @Test + fun plainTextWithoutDiffIsMessageOnly() { + val parsed = UnifiedDiffParser.parse("Just a description, no diff here.") + assertFalse(parsed.hasDiff) + assertEquals("Just a description, no diff here.", parsed.message) + assertTrue(parsed.files.isEmpty()) + } + + @Test + fun detectsBinaryFiles() { + val patch = + "diff --git a/img.png b/img.png\n" + + "new file mode 100644\n" + + "index 0000000..abcdef1\n" + + "Binary files /dev/null and b/img.png differ\n" + val file = UnifiedDiffParser.parse(patch).files.single() + assertTrue(file.isBinary) + assertTrue(file.hunks.isEmpty()) + } +} From 7f583c39888e8b3cbf18756a631f003753d4a7b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 19:44:05 +0000 Subject: [PATCH 04/31] feat: surface git pull-request updates (kind 1619) in the repo screen NIP-34 pull-request update events (kind 1619) revise a PR with a newer commit / merge base, but the repository screen neither subscribed to them nor reflected them, so updated PRs looked stale. - Subscribe to GitPullRequestUpdateEvent.KIND in RepositoryContentKinds so updates reach the repo screen. - Add GitPullRequestUpdateIndex (mirrors GitStatusIndex): tracks the latest update per parent PR id from LocalCache, exposed as a StateFlow. - Fold the latest update into the parent PR card: the current commit, merge base and clone URLs now reflect the newest revision, with a "Revised" marker on both the PR card and the compact Patches-tab row. Updates are folded into their parent PR rather than listed as separate rows. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../model/GitPullRequestUpdateIndex.kt | 84 +++++++++++++++++++ .../amethyst/ui/note/types/Git.kt | 33 +++++++- .../screen/loggedIn/gitRepo/GitItemListRow.kt | 40 +++++++++ .../datasource/FilterRepositoryContent.kt | 2 + amethyst/src/main/res/values/strings.xml | 1 + 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt new file mode 100644 index 0000000000..f937515040 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Cross-screen index of the most recent NIP-34 pull-request update event + * (kind 1619) per parent pull-request id, kept up to date from + * [LocalCache.live.newEventBundles]. A PR update *revises* its parent PR with a + * newer commit / merge base, so the UI folds the latest one into the PR rather + * than listing updates separately. Like [GitStatusIndex], updates aren't tracked + * in `Note.replies`, so a per-row cache scan would otherwise be required. + */ +object GitPullRequestUpdateIndex { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val started = AtomicBoolean(false) + + private val mutableLatestByPullRequest = MutableStateFlow?>(null) + val latestByPullRequest: StateFlow?> = mutableLatestByPullRequest.asStateFlow() + + fun startIfNeeded() { + if (!started.compareAndSet(false, true)) return + scope.launch { + LocalCache.live.newEventBundles + .onStart { + val initial = HashMap() + LocalCache.notes.forEach { _, note -> + val event = note.event as? GitPullRequestUpdateEvent ?: return@forEach + val target = event.parentPullRequestId() ?: return@forEach + val current = initial[target] + if (current == null || event.createdAt > current.createdAt) { + initial[target] = event + } + } + mutableLatestByPullRequest.value = initial + }.collect { bundle -> processBundle(bundle) } + } + } + + private fun processBundle(bundle: Set) { + val snapshot = mutableLatestByPullRequest.value ?: emptyMap() + var modified: HashMap? = null + for (note in bundle) { + val event = note.event as? GitPullRequestUpdateEvent ?: continue + val target = event.parentPullRequestId() ?: continue + val map = modified ?: snapshot + val current = map[target] + if (current == null || event.createdAt > current.createdAt) { + if (modified == null) modified = HashMap(snapshot) + modified[target] = event + } + } + modified?.let { mutableLatestByPullRequest.value = it } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index 1d5eb80eec..88fc5c7026 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -50,6 +51,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol @@ -57,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.GitPullRequestUpdateIndex import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.ClickableUrl @@ -562,6 +565,12 @@ private fun RenderGitPullRequestEvent( accountViewModel: AccountViewModel, nav: INav, ) { + // A later pull-request update (kind 1619) revises this PR with a newer commit / + // merge base. Fold the most recent one in so the card reflects the current state. + LaunchedEffect(Unit) { GitPullRequestUpdateIndex.startIfNeeded() } + val updateIndex by GitPullRequestUpdateIndex.latestByPullRequest.collectAsStateWithLifecycle() + val update = updateIndex?.get(note.idHex) + GitCardContainer { val repository = remember(noteEvent) { noteEvent.repositoryAddress() } if (repository != null) { @@ -585,6 +594,15 @@ private fun RenderGitPullRequestEvent( ) GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN) + + if (update != null) { + TypeChip( + text = stringRes(id = R.string.git_pr_revised), + background = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + symbol = MaterialSymbols.Sync, + ) + } } val subject = remember(noteEvent) { noteEvent.subject()?.takeIf { it.isNotBlank() } } @@ -593,9 +611,18 @@ private fun RenderGitPullRequestEvent( } val branch = remember(noteEvent) { noteEvent.branchName()?.takeIf { it.isNotBlank() } } - val currentCommit = remember(noteEvent) { noteEvent.currentCommit()?.takeIf { it.isNotBlank() } } - val mergeBase = remember(noteEvent) { noteEvent.mergeBase()?.takeIf { it.isNotBlank() } } - val cloneUrls = remember(noteEvent) { noteEvent.cloneUrls().filter { it.isNotBlank() } } + val currentCommit = + remember(noteEvent, update) { + (update?.currentCommit() ?: noteEvent.currentCommit())?.takeIf { it.isNotBlank() } + } + val mergeBase = + remember(noteEvent, update) { + (update?.mergeBase() ?: noteEvent.mergeBase())?.takeIf { it.isNotBlank() } + } + val cloneUrls = + remember(noteEvent, update) { + (update?.cloneUrls()?.filter { it.isNotBlank() }?.ifEmpty { null } ?: noteEvent.cloneUrls().filter { it.isNotBlank() }) + } if (branch != null || currentCommit != null || mergeBase != null) { Spacer(modifier = StdVertSpacer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt index 6e41084b91..93290c7811 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed @@ -36,6 +37,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -46,8 +48,11 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding +import com.vitorpamplona.amethyst.model.GitPullRequestUpdateIndex import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport @@ -204,8 +209,12 @@ private fun GitItemRowContent( FlowRow( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(top = 2.dp), ) { GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN) + if (note.event is GitPullRequestEvent) { + GitRevisedChip(note.idHex) + } labels.take(6).forEach { LabelChip(it) } } } @@ -227,6 +236,37 @@ private fun LabelChip(label: String) { ) } +/** Small "Revised" badge shown when a pull request has a later kind-1619 update. */ +@Composable +private fun GitRevisedChip(prIdHex: String) { + LaunchedEffect(Unit) { GitPullRequestUpdateIndex.startIfNeeded() } + val index by GitPullRequestUpdateIndex.latestByPullRequest.collectAsStateWithLifecycle() + if (index?.get(prIdHex) == null) return + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 8.dp, vertical = 2.dp), + ) { + Icon( + symbol = MaterialSymbols.Sync, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringRes(R.string.git_pr_revised), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } +} + private fun gitSubjectOf(event: Event?): String? = when (event) { is GitIssueEvent -> event.subject()?.takeIf { it.isNotBlank() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt index f5b05be87d..b47755d2a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent @@ -37,6 +38,7 @@ val RepositoryContentKinds = GitIssueEvent.KIND, GitPatchEvent.KIND, GitPullRequestEvent.KIND, + GitPullRequestUpdateEvent.KIND, GitStatusOpenEvent.KIND, GitStatusAppliedEvent.KIND, GitStatusClosedEvent.KIND, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6c1cc324d8..e239108444 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2608,6 +2608,7 @@ Cancel Close issue Reopen + Revised Git Repositories nSite: %1$s nApplet: %1$s From cd934fb0ea21415239e312a58f39a7739a760676 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 20:12:00 +0000 Subject: [PATCH 05/31] feat: git code browser branch/tag switch, file search, image preview; issue labels Code browser: - Branch & tag switching: the client now exposes all refs from ls-refs; a branch/tag picker on the repo header reloads the tree at the chosen ref (GitRepositoryBrowserViewModel.switchRef). - In-tree filename search: a search field filters the whole tree (GitRepoSnapshot.searchFiles) and shows matching paths. - Image preview: png/jpg/gif/webp/bmp blobs render via Coil instead of the binary notice. Issues: - Labels at creation: the new-issue composer takes a comma/space separated label list, written as NIP-34 `t` tags. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../ui/screen/loggedIn/gitRepo/GitNewIssue.kt | 22 +++- .../loggedIn/gitRepo/code/GitBrowseUi.kt | 102 +++++++++++++++- .../loggedIn/gitRepo/code/GitCodeTab.kt | 115 +++++++++++++++++- .../loggedIn/gitRepo/code/GitFileViewer.kt | 34 +++++- .../code/GitRepositoryBrowserViewModel.kt | 13 +- amethyst/src/main/res/values/strings.xml | 7 ++ .../quartz/nip34Git/git/GitHttpClient.kt | 42 ++++++- 7 files changed, 324 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitNewIssue.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitNewIssue.kt index d34c982aca..e88f50736b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitNewIssue.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitNewIssue.kt @@ -58,6 +58,7 @@ fun GitNewIssueDialog( ) { var subject by rememberSaveable { mutableStateOf("") } var body by rememberSaveable { mutableStateOf("") } + var labels by rememberSaveable { mutableStateOf("") } AlertDialog( onDismissRequest = onDismiss, @@ -79,13 +80,21 @@ fun GitNewIssueDialog( minLines = 4, modifier = Modifier.fillMaxWidth(), ) + OutlinedTextField( + value = labels, + onValueChange = { labels = it }, + label = { Text(stringRes(R.string.git_new_issue_labels)) }, + placeholder = { Text(stringRes(R.string.git_new_issue_labels_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) } }, confirmButton = { TextButton( enabled = subject.isNotBlank(), onClick = { - sendGitIssue(accountViewModel, repoNote, subject.trim(), body.trim()) + sendGitIssue(accountViewModel, repoNote, subject.trim(), body.trim(), parseLabels(labels)) onDismiss() }, ) { @@ -98,15 +107,24 @@ fun GitNewIssueDialog( ) } +/** Splits a free-text label field on commas/whitespace, stripping any leading `#`. */ +private fun parseLabels(raw: String): List = + raw + .split(',', ' ', '\n', '\t') + .map { it.trim().removePrefix("#") } + .filter { it.isNotEmpty() } + .distinct() + private fun sendGitIssue( accountViewModel: AccountViewModel, repoNote: AddressableNote, subject: String, body: String, + labels: List, ) { val repositoryHint = repoNote.toEventHint() ?: return accountViewModel.launchSigner { - val template = GitIssueEvent.build(subject, body, repositoryHint, emptyList(), emptyList()) + val template = GitIssueEvent.build(subject, body, repositoryHint, emptyList(), labels) val signed = accountViewModel.account.signer.sign(template) accountViewModel.account.sendAutomatic(signed) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt index b631e53f39..ffab9ab6e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -34,10 +35,16 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -123,14 +130,17 @@ fun GitMessageBox( } /** - * Compact bar identifying the snapshot: the branch and short commit the file - * tree was loaded from, plus the entry count of the current directory. + * Compact bar identifying the snapshot: a branch/tag picker, the short commit the + * file tree was loaded from, and the entry count of the current directory. */ @Composable fun RepoInfoBar( branch: String?, headCommit: String, entryCount: Int, + branches: List = emptyList(), + tags: List = emptyList(), + onSelectRef: ((String?) -> Unit)? = null, ) { Row( modifier = @@ -141,7 +151,9 @@ fun RepoInfoBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - if (branch != null) { + if (onSelectRef != null && (branches.size + tags.size) > 1) { + BranchSelector(branch, branches, tags, onSelectRef) + } else if (branch != null) { InfoChip(symbol = MaterialSymbols.AltRoute, label = branch) } InfoChip(symbol = MaterialSymbols.Commit, label = headCommit.take(7), monospace = true) @@ -153,6 +165,90 @@ fun RepoInfoBar( } } +@Composable +private fun BranchSelector( + current: String?, + branches: List, + tags: List, + onSelectRef: (String?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + Row( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .clickable { expanded = true } + .padding(start = 8.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + Icon(MaterialSymbols.AltRoute, contentDescription = null, modifier = Modifier.size(15.dp), tint = MaterialTheme.colorScheme.primary) + Text( + text = current ?: stringRes(R.string.git_repo_default_branch), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + Icon(MaterialSymbols.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (branches.isNotEmpty()) { + SectionHeader(stringRes(R.string.git_repo_branches)) + branches.forEach { name -> + RefMenuItem(name, MaterialSymbols.AltRoute, selected = name == current) { + expanded = false + if (name != current) onSelectRef(name) + } + } + } + if (tags.isNotEmpty()) { + SectionHeader(stringRes(R.string.git_repo_tags)) + tags.forEach { name -> + RefMenuItem(name, MaterialSymbols.Tag, selected = name == current) { + expanded = false + if (name != current) onSelectRef(name) + } + } + } + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) +} + +@Composable +private fun RefMenuItem( + name: String, + symbol: MaterialSymbol, + selected: Boolean, + onClick: () -> Unit, +) { + DropdownMenuItem( + text = { + Text( + name, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) + }, + leadingIcon = { Icon(symbol, contentDescription = null, modifier = Modifier.size(16.dp)) }, + onClick = onClick, + ) +} + @Composable private fun InfoChip( symbol: MaterialSymbol, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt index ddf2f7b5d6..d7469fb329 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt @@ -41,6 +41,8 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -50,6 +52,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -128,12 +131,46 @@ private fun CodeBrowser( return } - BackHandler(enabled = path.isNotEmpty()) { pathString = path.dropLast(1).joinToString("/") } + var query by rememberSaveable(snapshot.headCommit) { mutableStateOf("") } + val searching = query.isNotBlank() + + BackHandler(enabled = searching || path.isNotEmpty()) { + if (searching) query = "" else pathString = path.dropLast(1).joinToString("/") + } val entries = remember(snapshot, pathString) { snapshot.entriesAt(path).orEmpty() } Column(Modifier.fillMaxSize().padding(scaffoldPaddingTop)) { - RepoInfoBar(branch = snapshot.branch, headCommit = snapshot.headCommit, entryCount = entries.size) + RepoInfoBar( + branch = snapshot.branch, + headCommit = snapshot.headCommit, + entryCount = entries.size, + branches = snapshot.branches, + tags = snapshot.tags, + onSelectRef = { viewModel.switchRef(it) }, + ) + FileSearchField(query = query, onQueryChange = { query = it }) + HorizontalDivider(thickness = 0.5.dp) + + if (searching) { + val results = remember(snapshot, query) { snapshot.searchFiles(query.trim()) } + if (results.isEmpty()) { + GitMessageBox(MaterialSymbols.Search, stringRes(R.string.git_repo_no_search_results)) + } else { + LazyColumn(Modifier.fillMaxSize()) { + items(results, key = { it.joinToString("/") }) { result -> + SearchResultRow(result) { openFilePath = result.joinToString("/") } + HorizontalDivider( + modifier = Modifier.padding(start = 40.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), + ) + } + } + } + return@Column + } + Breadcrumb( path = path, onNavigate = { depth -> pathString = path.take(depth).joinToString("/") }, @@ -162,6 +199,80 @@ private fun CodeBrowser( } } +@Composable +private fun FileSearchField( + query: String, + onQueryChange: (String) -> Unit, +) { + TextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + placeholder = { Text(stringRes(R.string.git_repo_search_files)) }, + leadingIcon = { Icon(MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(18.dp)) }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon(MaterialSymbols.Close, contentDescription = null, modifier = Modifier.size(18.dp)) + } + } + }, + colors = + TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 4.dp) + .clip(RoundedCornerShape(10.dp)), + ) +} + +@Composable +private fun SearchResultRow( + path: List, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + symbol = MaterialSymbols.Description, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column(Modifier.weight(1f)) { + Text( + text = path.last(), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (path.size > 1) { + Text( + text = path.dropLast(1).joinToString("/"), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + @Composable private fun Breadcrumb( path: List, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt index 5ec06412de..505c936492 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt @@ -24,6 +24,7 @@ import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -53,6 +54,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.request.ImageRequest import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -105,14 +108,16 @@ fun GitFileViewer( else -> { val data = bytes.getOrThrow() when { + isMarkdownFile(entry.name) -> + MarkdownFile(data.decodeToString(), accountViewModel, nav, modifier) + isImageFile(entry.name) -> + ImageFile(data, entry.name, modifier) isProbablyBinary(data) -> GitMessageBox( symbol = MaterialSymbols.Description, text = stringRes(R.string.git_repo_binary_file, humanSize(data.size)), modifier = modifier, ) - isMarkdownFile(entry.name) -> - MarkdownFile(data.decodeToString(), accountViewModel, nav, modifier) else -> HighlightedCode(data.decodeToString(), entry.name, modifier) } @@ -300,11 +305,36 @@ private fun prettyLanguage(enumName: String): String = else -> enumName.lowercase().replaceFirstChar { it.uppercase() } } +@Composable +private fun ImageFile( + bytes: ByteArray, + name: String, + modifier: Modifier, +) { + val context = LocalContext.current + val request = remember(bytes) { ImageRequest.Builder(context).data(bytes).build() } + Box( + modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), + contentAlignment = Alignment.TopCenter, + ) { + AsyncImage( + model = request, + contentDescription = name, + modifier = Modifier.fillMaxWidth(), + ) + } +} + private fun isMarkdownFile(name: String): Boolean { val ext = name.substringAfterLast('.', "").lowercase() return ext == "md" || ext == "markdown" || ext == "mdown" || ext == "mkd" } +private fun isImageFile(name: String): Boolean { + val ext = name.substringAfterLast('.', "").lowercase() + return ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "gif" || ext == "webp" || ext == "bmp" +} + /** Heuristic: a NUL byte in the first chunk means the content isn't text. */ private fun isProbablyBinary(data: ByteArray): Boolean { val limit = minOf(data.size, 8000) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt index a724320f67..70807beaf4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt @@ -61,6 +61,10 @@ class GitRepositoryBrowserViewModel( private var cloneUrls: List = emptyList() private var started = false + /** The branch/tag currently displayed (null = the server's default branch). */ + var currentRef: String? = null + private set + /** * Loads the repository once, using the announcement's clone URLs. Safe to call * on every recomposition; only the first call (after the event has arrived) runs. @@ -72,6 +76,13 @@ class GitRepositoryBrowserViewModel( reload() } + /** Reloads the tree at [ref] (a branch or tag name; null = default branch). */ + fun switchRef(ref: String?) { + if (ref == currentRef) return + currentRef = ref + reload() + } + fun reload() { _state.value = GitBrowseState.Loading viewModelScope.launch(Dispatchers.IO) { @@ -83,7 +94,7 @@ class GitRepositoryBrowserViewModel( val errors = StringBuilder() for (url in candidates) { try { - val snapshot = client.open(url) + val snapshot = client.open(url, currentRef) _state.value = GitBrowseState.Loaded(snapshot) return@launch } catch (e: CancellationException) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e239108444..3dfcdd5c20 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2589,6 +2589,11 @@ This folder is empty. root Retry + default + Branches + Tags + Search files… + No matching files. Copy file contents Text @@ -2606,6 +2611,8 @@ Description Create Cancel + Labels + bug, enhancement… Close issue Reopen Revised diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt index e7d5657eef..f06922704c 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt @@ -55,7 +55,10 @@ class GitHttpClient( val refs = transport.lsRefs(cloneUrl, caps) val head = selectHead(refs, ref) ?: throw GitHttpException("could not resolve a branch to browse") - val branch = head.symrefTarget?.removePrefix("refs/heads/") ?: ref?.removePrefix("refs/heads/") + val branch = head.symrefTarget?.removePrefix("refs/heads/") ?: ref?.removePrefix("refs/heads/") ?: ref + + val branches = refs.mapNotNull { it.name.removePrefix("refs/heads/").takeIf { _ -> it.name.startsWith("refs/heads/") } }.distinct().sorted() + val tags = refs.mapNotNull { it.name.removePrefix("refs/tags/").takeIf { _ -> it.name.startsWith("refs/tags/") } }.distinct().sorted() val pack = transport.fetchPack( @@ -89,6 +92,8 @@ class GitHttpClient( cloneUrl = cloneUrl, headCommit = head.oid, branch = branch, + branches = branches, + tags = tags, rootTreeOid = rootTreeOid, trees = trees, blobs = blobs, @@ -121,6 +126,8 @@ class GitRepoSnapshot( val cloneUrl: String, val headCommit: String, val branch: String?, + val branches: List = emptyList(), + val tags: List = emptyList(), private val rootTreeOid: String, private val trees: Map>, blobs: Map, @@ -153,6 +160,39 @@ class GitRepoSnapshot( return parent.firstOrNull { it.name == path.last() } } + /** + * Returns up to [limit] full file paths whose file name contains [query] + * (case-insensitive), searching the whole tree. Folders are walked but not + * matched. The tree is fully present (blob-less), so this is offline. + */ + fun searchFiles( + query: String, + limit: Int = 100, + ): List> { + if (query.isBlank()) return emptyList() + val needle = query.lowercase() + val result = ArrayList>() + + fun walk( + treeOid: String, + prefix: List, + ) { + if (result.size >= limit) return + val entries = trees[treeOid] ?: return + for (entry in entries) { + if (result.size >= limit) return + val path = prefix + entry.name + if (entry.isFolder) { + walk(entry.oid, path) + } else if (entry.name.lowercase().contains(needle)) { + result.add(path) + } + } + } + walk(rootTreeOid, emptyList()) + return result + } + fun hasBlob(oid: String): Boolean = blobs.containsKey(oid) /** Returns the bytes of a blob, fetching (and caching) it if not already present. */ From f25138fe7aca9c75b7eacb930baa2487fcc18f5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 20:14:54 +0000 Subject: [PATCH 06/31] feat: edit a NIP-34 repository announcement from the repo screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an owner-only Edit action to the git repository screen's top bar that opens a settings dialog. Because the repository event (kind 30617) is addressable, saving republishes it under the same d-tag — preserving the earliest-unique-commit, relays, maintainers and personal-fork flag — while letting the owner edit the name, description, clone/web URLs and topics. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../loggedIn/gitRepo/GitRepoSettings.kt | 157 ++++++++++++++++++ .../loggedIn/gitRepo/GitRepositoryScreen.kt | 13 ++ amethyst/src/main/res/values/strings.xml | 7 + 3 files changed, 177 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepoSettings.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepoSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepoSettings.kt new file mode 100644 index 0000000000..5c490ecbee --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepoSettings.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +/** + * Edit form for a NIP-34 repository announcement (kind 30617). Because the event + * is addressable, saving republishes it under the same `d` tag — preserving the + * earliest-unique-commit, relays, maintainers and personal-fork flag while letting + * the owner edit the name, description, web/clone URLs and topics. + */ +@Composable +fun GitRepoSettingsDialog( + event: GitRepositoryEvent, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + var name by rememberSaveable { mutableStateOf(event.name() ?: event.dTag()) } + var description by rememberSaveable { mutableStateOf(event.description() ?: "") } + var webUrls by rememberSaveable { mutableStateOf(event.webs().joinToString("\n")) } + var cloneUrls by rememberSaveable { mutableStateOf(event.clones().joinToString("\n")) } + var topics by rememberSaveable { mutableStateOf(event.hashtags().joinToString(", ")) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.git_repo_settings_title)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringRes(R.string.git_repo_settings_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text(stringRes(R.string.git_repo_settings_description)) }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = cloneUrls, + onValueChange = { cloneUrls = it }, + label = { Text(stringRes(R.string.git_repo_settings_clone_urls)) }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = webUrls, + onValueChange = { webUrls = it }, + label = { Text(stringRes(R.string.git_repo_settings_web_urls)) }, + minLines = 1, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = topics, + onValueChange = { topics = it }, + label = { Text(stringRes(R.string.git_repo_settings_topics)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + enabled = name.isNotBlank(), + onClick = { + saveRepository(accountViewModel, event, name.trim(), description.trim(), webUrls, cloneUrls, topics) + onDismiss() + }, + ) { + Text(stringRes(R.string.git_repo_settings_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.git_new_issue_cancel)) } + }, + ) +} + +private fun splitLines(raw: String): List = + raw + .split('\n', ',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + +private fun saveRepository( + accountViewModel: AccountViewModel, + event: GitRepositoryEvent, + name: String, + description: String, + webUrls: String, + cloneUrls: String, + topics: String, +) { + accountViewModel.launchSigner { + val template = + GitRepositoryEvent.build( + name = name, + description = description.ifBlank { null }, + webUrls = splitLines(webUrls), + cloneUrls = splitLines(cloneUrls), + relays = event.relays(), + maintainers = event.maintainers(), + hashtags = splitLines(topics).map { it.removePrefix("#") }, + earliestUniqueCommit = event.earliestUniqueCommit(), + personalFork = event.isPersonalFork(), + dTag = event.dTag(), + ) + val signed = accountViewModel.account.signer.sign(template) + accountViewModel.account.sendAutomatic(signed) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index 80da2bba56..ddc38ccf58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -184,6 +184,12 @@ private fun GitRepositoryScreen( } val pagerState = rememberForeverPagerState(note.idHex + "GitRepoScreenPagerState") { 5 } + var showSettings by rememberSaveable(note.idHex) { mutableStateOf(false) } + + val currentEventForSettings = event + if (showSettings && currentEventForSettings != null) { + GitRepoSettingsDialog(currentEventForSettings, accountViewModel) { showSettings = false } + } DisappearingScaffold( isInvertedLayout = false, @@ -198,6 +204,13 @@ private fun GitRepositoryScreen( IconButton(onClick = nav::popBack) { ArrowBackIcon() } } }, + actions = { + if (event != null && accountViewModel.isLoggedUser(event?.pubKey)) { + IconButton(onClick = { showSettings = true }) { + Icon(MaterialSymbols.Edit, contentDescription = stringRes(R.string.git_repo_settings_title)) + } + } + }, ) SecondaryTabRow( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3dfcdd5c20..cb8b5496f8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2616,6 +2616,13 @@ Close issue Reopen Revised + Edit repository + Name + Description + Clone URLs (one per line) + Web URLs (one per line) + Topics (comma separated) + Save Git Repositories nSite: %1$s nApplet: %1$s From a44ee6b3125346405f2884886b7d68645626e53f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 20:33:31 +0000 Subject: [PATCH 07/31] =?UTF-8?q?feat:=20finish=20git=20PR/patch=20review?= =?UTF-8?q?=20=E2=80=94=20computed=20diffs=20and=20status=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull requests reference a clone URL + commit rather than embedding a patch, so their changes were invisible. Now the app computes and renders them. - quartz: a pure Myers O(ND) line-diff (LineDiff) producing the same GitDiffHunk model the embedded-patch parser uses, unit-tested against git -U3 output and with a reconstruction property check. - quartz: GitHttpClient.computeDiff(cloneUrl, head, base?) — fetches both commit trees, finds changed files by oid, batch-fetches the differing blobs and line-diffs each into a ParsedPatch (base = merge base, or HEAD). - amethyst: a "View changes" section on the PR card that loads the diff over the git client and renders it with the shared GitDiffView. - amethyst: GitStatusActions generalizes the issue open/close controls to issues, patches and PRs — patches/PRs additionally get "Mark merged" (GitStatusAppliedEvent). Visible to the author and repo owner/maintainers. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../amethyst/ui/note/types/Git.kt | 11 +- .../ui/note/types/GitPullRequestChanges.kt | 169 +++++++++++++++++ ...ueStatusActions.kt => GitStatusActions.kt} | 89 +++++---- amethyst/src/main/res/values/strings.xml | 7 + .../quartz/nip34Git/patch/LineDiff.kt | 179 ++++++++++++++++++ .../quartz/nip34Git/git/GitHttpClient.kt | 153 +++++++++++++++ .../quartz/nip34Git/patch/LineDiffTest.kt | 112 +++++++++++ 7 files changed, 686 insertions(+), 34 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/{GitIssueStatusActions.kt => GitStatusActions.kt} (56%) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/LineDiff.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/LineDiffTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index 88fc5c7026..fb5b0d986e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -453,6 +453,10 @@ private fun RenderGitPatchEvent( } else { GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) } + + if (!makeItShort) { + GitStatusActions(note, accountViewModel) + } } } @@ -525,7 +529,7 @@ private fun RenderGitIssueEvent( GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) if (!makeItShort) { - GitIssueStatusActions(note, accountViewModel) + GitStatusActions(note, accountViewModel) } } } @@ -656,6 +660,11 @@ private fun RenderGitPullRequestEvent( Spacer(modifier = HalfDoubleVertSpacer) GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) + + if (!makeItShort) { + GitPullRequestChanges(cloneUrls, currentCommit, mergeBase, accountViewModel) + GitStatusActions(note, accountViewModel) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt new file mode 100644 index 0000000000..6146391da6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.git.GitHttpClient +import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException + +private sealed interface ChangesState { + object Idle : ChangesState + + object Loading : ChangesState + + class Loaded( + val patch: ParsedPatch, + ) : ChangesState + + object Failed : ChangesState +} + +/** + * "View changes" section of a pull-request card. A NIP-34 PR references a clone + * URL + commit instead of embedding a patch, so the actual diff is computed on + * demand over the git smart-HTTP client (base = merge base, head = current + * commit) and rendered with the shared [GitDiffView]. + */ +@Composable +fun GitPullRequestChanges( + cloneUrls: List, + headCommit: String?, + mergeBase: String?, + accountViewModel: AccountViewModel, +) { + val candidates = remember(cloneUrls) { candidateUrls(cloneUrls) } + if (candidates.isEmpty() || headCommit.isNullOrBlank()) return + + var state by remember(headCommit, mergeBase) { mutableStateOf(ChangesState.Idle) } + val scope = rememberCoroutineScope() + + fun load() { + state = ChangesState.Loading + scope.launch { + state = + try { + val patch = loadDiff(accountViewModel, candidates, headCommit, mergeBase) + ChangesState.Loaded(patch) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + ChangesState.Failed + } + } + } + + when (val s = state) { + ChangesState.Idle -> + FilledTonalButton(onClick = { load() }, modifier = Modifier.padding(top = 8.dp)) { + Icon(MaterialSymbols.Code, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.git_pr_view_changes), modifier = Modifier.padding(start = 6.dp)) + } + + ChangesState.Loading -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(top = 12.dp), + ) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.git_pr_loading_changes), style = MaterialTheme.typography.bodySmall) + } + + is ChangesState.Loaded -> + if (s.patch.hasDiff) { + Column(Modifier.fillMaxWidth().padding(top = 12.dp)) { + GitDiffView(s.patch, Modifier.fillMaxWidth()) + } + } else { + Text( + text = stringRes(R.string.git_pr_no_changes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp), + ) + } + + ChangesState.Failed -> + FilledTonalButton(onClick = { load() }, modifier = Modifier.padding(top = 8.dp)) { + Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.git_pr_changes_retry), modifier = Modifier.padding(start = 6.dp)) + } + } +} + +private suspend fun loadDiff( + accountViewModel: AccountViewModel, + candidates: List, + headCommit: String, + mergeBase: String?, +): ParsedPatch = + withContext(Dispatchers.IO) { + val client = GitHttpClient(accountViewModel.httpClientBuilder::okHttpClientForPreview) + var lastError: Exception? = null + for (url in candidates) { + try { + return@withContext client.computeDiff(url, headCommit, mergeBase?.takeIf { it.isNotBlank() }) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + lastError = e + } + } + throw lastError ?: IllegalStateException("no usable clone URL") + } + +private fun candidateUrls(cloneUrls: List): List { + val out = LinkedHashSet() + for (raw in cloneUrls) { + val url = raw.trim() + if (!url.startsWith("http://") && !url.startsWith("https://")) continue + out.add(url) + if (!url.removeSuffix("/").endsWith(".git")) out.add(url.removeSuffix("/") + ".git") + } + return out.toList() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitIssueStatusActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt similarity index 56% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitIssueStatusActions.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt index fe9a041c32..83b8b2e9d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitIssueStatusActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.ButtonDefaults @@ -44,80 +44,103 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent +private enum class StatusTarget { OPEN, CLOSED, APPLIED } + /** - * Open / Close controls for a git issue. Visible only to the people NIP-34 lets - * moderate the thread: the issue author and the repository owner / maintainers. - * Publishing toggles the issue between [GitStatusOpenEvent] and - * [GitStatusClosedEvent]; [GitStatusIndex] then reflects the change everywhere. + * NIP-34 status controls for an issue, patch or pull request. Visible only to the + * people allowed to moderate the thread — the item's author and the repository + * owner / maintainers. Patches and PRs additionally offer "Mark merged" + * ([GitStatusAppliedEvent]); everything can be closed/reopened. [GitStatusIndex] + * reflects the published status everywhere. */ @Composable -fun GitIssueStatusActions( - issueNote: Note, +fun GitStatusActions( + note: Note, accountViewModel: AccountViewModel, ) { - val event = issueNote.event as? GitIssueEvent ?: return + val event = note.event ?: return + val repoAddress = repositoryAddressOf(event) ?: return + val isPatchOrPr = event is GitPatchEvent || event is GitPullRequestEvent - val canModerate = remember(event, issueNote) { canModerate(event, issueNote, accountViewModel) } + val canModerate = remember(event, note) { canModerate(repoAddress, note, accountViewModel) } if (!canModerate) return LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() } val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle() - if (index == null) return // wait for the initial scan - val isClosed = GitStatusIndex.isClosedOrResolved(issueNote.idHex) + if (index == null) return + val current = index?.get(note.idHex) + val closedOrApplied = current is GitStatusClosedEvent || current is GitStatusAppliedEvent - Row( + FlowRow( modifier = Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - if (isClosed) { - FilledTonalButton(onClick = { sendIssueStatus(accountViewModel, issueNote, close = false) }) { + if (closedOrApplied) { + FilledTonalButton(onClick = { sendStatus(accountViewModel, note, StatusTarget.OPEN) }) { Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.git_issue_reopen), modifier = Modifier.padding(start = 6.dp)) + Text(stringRes(R.string.git_status_reopen), modifier = Modifier.padding(start = 6.dp)) } } else { + if (isPatchOrPr) { + FilledTonalButton(onClick = { sendStatus(accountViewModel, note, StatusTarget.APPLIED) }) { + Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.git_status_mark_merged), modifier = Modifier.padding(start = 6.dp)) + } + } OutlinedButton( - onClick = { sendIssueStatus(accountViewModel, issueNote, close = true) }, + onClick = { sendStatus(accountViewModel, note, StatusTarget.CLOSED) }, colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), ) { Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.git_issue_close), modifier = Modifier.padding(start = 6.dp)) + Text(stringRes(R.string.git_status_close), modifier = Modifier.padding(start = 6.dp)) } } } } +private fun repositoryAddressOf(event: Event): Address? = + when (event) { + is GitIssueEvent -> event.repositoryAddress() + is GitPatchEvent -> event.repositoryAddress() + is GitPullRequestEvent -> event.repositoryAddress() + else -> null + } + private fun canModerate( - event: GitIssueEvent, - issueNote: Note, + repoAddress: Address, + note: Note, accountViewModel: AccountViewModel, ): Boolean { val myHex = accountViewModel.account.signer.pubKey - if (accountViewModel.isLoggedUser(issueNote.author)) return true - val ownerHex = event.repositoryAddress()?.pubKeyHex - if (myHex == ownerHex) return true - // Include extra maintainers when the repository event is already in cache. - val repoEvent = - event.repositoryAddress()?.let { LocalCache.getAddressableNoteIfExists(it)?.event as? GitRepositoryEvent } + if (accountViewModel.isLoggedUser(note.author)) return true + if (myHex == repoAddress.pubKeyHex) return true + val repoEvent = LocalCache.getAddressableNoteIfExists(repoAddress)?.event as? GitRepositoryEvent return repoEvent?.maintainers()?.contains(myHex) == true } -private fun sendIssueStatus( +private fun sendStatus( accountViewModel: AccountViewModel, - issueNote: Note, - close: Boolean, + note: Note, + target: StatusTarget, ) { - val target = issueNote.toEventHint() ?: return + val hint = note.toEventHint() ?: return accountViewModel.launchSigner { val template = - if (close) { - GitStatusClosedEvent.build("", target) - } else { - GitStatusOpenEvent.build("", target) + when (target) { + StatusTarget.OPEN -> GitStatusOpenEvent.build("", hint) + StatusTarget.CLOSED -> GitStatusClosedEvent.build("", hint) + StatusTarget.APPLIED -> GitStatusAppliedEvent.build("", hint) } val signed = accountViewModel.account.signer.sign(template) accountViewModel.account.sendAutomatic(signed) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cb8b5496f8..1428329e41 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2615,6 +2615,13 @@ bug, enhancement… Close issue Reopen + Close + Reopen + Mark merged + View changes + Loading changes… + No file changes found. + Retry loading changes Revised Edit repository Name diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/LineDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/LineDiff.kt new file mode 100644 index 0000000000..936a6d83ee --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/LineDiff.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.patch + +/** + * Computes a line-level unified diff between two texts using Myers' O(ND) + * difference algorithm, producing the same [GitDiffHunk] model the embedded + * patch parser uses. This lets the app render a diff for pull requests that + * reference a clone URL + commit instead of embedding a `git format-patch`. + */ +object LineDiff { + private class Op( + val type: GitDiffLineType, + val oldIndex: Int, // 0-based index into old lines, -1 for inserts + val newIndex: Int, // 0-based index into new lines, -1 for deletes + ) + + /** + * Builds unified-diff hunks between [oldLines] and [newLines], with [context] + * unchanged lines around each change (git's default is 3). + */ + fun hunks( + oldLines: List, + newLines: List, + context: Int = 3, + ): List { + val ops = computeOps(oldLines, newLines) + return groupHunks(ops, oldLines, newLines, context) + } + + /** Myers O(ND) diff: returns an ordered op list (CONTEXT / DELETE / ADD). */ + private fun computeOps( + a: List, + b: List, + ): List { + val n = a.size + val m = b.size + if (n == 0 && m == 0) return emptyList() + val max = n + m + val offset = max + val trace = ArrayList() + val v = IntArray(2 * max + 1) + + var found = false + for (d in 0..max) { + trace.add(v.copyOf()) + var k = -d + while (k <= d) { + val idx = k + offset + var x = + if (k == -d || (k != d && v[idx - 1] < v[idx + 1])) { + v[idx + 1] + } else { + v[idx - 1] + 1 + } + var y = x - k + while (x < n && y < m && a[x] == b[y]) { + x++ + y++ + } + v[idx] = x + if (x >= n && y >= m) { + found = true + break + } + k += 2 + } + if (found) break + } + + // Backtrack to recover the edit script. + val reversed = ArrayList() + var x = n + var y = m + for (d in trace.indices.reversed()) { + val vv = trace[d] + val k = x - y + val idx = k + offset + val prevK = + if (k == -d || (k != d && vv[idx - 1] < vv[idx + 1])) { + k + 1 + } else { + k - 1 + } + val prevIdx = prevK + offset + val prevX = vv[prevIdx] + val prevY = prevX - prevK + + while (x > prevX && y > prevY) { + reversed.add(Op(GitDiffLineType.CONTEXT, x - 1, y - 1)) + x-- + y-- + } + if (d > 0) { + if (x == prevX) { + reversed.add(Op(GitDiffLineType.ADD, -1, y - 1)) + } else { + reversed.add(Op(GitDiffLineType.DELETE, x - 1, -1)) + } + } + x = prevX + y = prevY + } + reversed.reverse() + return reversed + } + + private fun groupHunks( + ops: List, + oldLines: List, + newLines: List, + context: Int, + ): List { + if (ops.none { it.type != GitDiffLineType.CONTEXT }) return emptyList() + + val changeIndexes = ops.indices.filter { ops[it].type != GitDiffLineType.CONTEXT } + val hunks = ArrayList() + + var i = 0 + while (i < changeIndexes.size) { + val start = changeIndexes[i] + var end = start + // Extend the hunk while the next change is within 2*context of the previous. + var j = i + while (j + 1 < changeIndexes.size && changeIndexes[j + 1] - changeIndexes[j] <= 2 * context + 1) { + j++ + end = changeIndexes[j] + } + val from = (start - context).coerceAtLeast(0) + val to = (end + context).coerceAtMost(ops.size - 1) + + val lines = ArrayList() + var oldStart = -1 + var newStart = -1 + var oldCount = 0 + var newCount = 0 + for (idx in from..to) { + val op = ops[idx] + val oldNo = if (op.oldIndex >= 0) op.oldIndex + 1 else null + val newNo = if (op.newIndex >= 0) op.newIndex + 1 else null + val content = if (op.type == GitDiffLineType.ADD) newLines[op.newIndex] else oldLines[op.oldIndex] + lines.add(GitDiffLine(op.type, content, oldNo, newNo)) + if (op.type != GitDiffLineType.ADD) { + if (oldStart < 0) oldStart = op.oldIndex + 1 + oldCount++ + } + if (op.type != GitDiffLineType.DELETE) { + if (newStart < 0) newStart = op.newIndex + 1 + newCount++ + } + } + if (oldStart < 0) oldStart = 0 + if (newStart < 0) newStart = 0 + val header = "@@ -$oldStart,$oldCount +$newStart,$newCount @@" + hunks.add(GitDiffHunk(header, oldStart, newStart, lines)) + + i = j + 1 + } + return hunks + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt index f06922704c..9515914903 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.quartz.nip34Git.git +import com.vitorpamplona.quartz.nip34Git.patch.GitDiffFile +import com.vitorpamplona.quartz.nip34Git.patch.GitFileChange +import com.vitorpamplona.quartz.nip34Git.patch.LineDiff +import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.OkHttpClient @@ -102,6 +106,155 @@ class GitHttpClient( ) } + /** + * Computes the diff a pull request introduces: the changes between + * [baseCommit] (or, when null, the server's HEAD) and [headCommit]. Fetches + * both commit trees, finds the changed files by oid, batch-fetches the + * differing blobs and runs a line diff on each. Returns a [ParsedPatch] ready + * for the same renderer the embedded-patch path uses. + */ + suspend fun computeDiff( + cloneUrl: String, + headCommit: String, + baseCommit: String?, + ): ParsedPatch { + require(cloneUrl.startsWith("http://") || cloneUrl.startsWith("https://")) { + "unsupported git transport (only http/https): $cloneUrl" + } + val caps = transport.fetchCapabilities(cloneUrl) + if (!caps.supportsFetch) throw GitHttpException("server does not speak git protocol v2 (fetch)") + + val base = + baseCommit?.takeIf { it.isNotBlank() } + ?: run { + if (!caps.supportsLsRefs) throw GitHttpException("no merge base and server can't list refs") + val refs = transport.lsRefs(cloneUrl, caps) + selectHead(refs, null)?.oid ?: throw GitHttpException("could not resolve a base commit") + } + + // 1. Both commit trees (and, when the server can't filter, every blob too). + val treePack = + transport.fetchPack( + cloneUrl = cloneUrl, + caps = caps, + wants = listOf(headCommit, base).distinct(), + deepen = 1, + filterBlobNone = caps.supportsFilter, + ) + val objects = Packfile.parse(treePack) + + val headTreeOid = commitTreeOf(objects, headCommit) + val baseTreeOid = commitTreeOf(objects, base) + val trees = HashMap>() + val blobs = HashMap() + for ((oid, obj) in objects) { + when (obj.type) { + GitObjectType.TREE -> trees[oid] = GitObjectParser.parseTree(obj.data) + GitObjectType.BLOB -> blobs[oid] = obj.data + else -> {} + } + } + + val headFiles = collectFiles(trees, headTreeOid) + val baseFiles = collectFiles(trees, baseTreeOid) + + // 2. Classify changes and gather the blob oids we still need. + data class Change( + val path: String, + val oldOid: String?, + val newOid: String?, + val change: GitFileChange, + ) + val changes = ArrayList() + for (path in (headFiles.keys + baseFiles.keys).toSortedSet()) { + val oldOid = baseFiles[path] + val newOid = headFiles[path] + when { + oldOid == null && newOid != null -> changes.add(Change(path, null, newOid, GitFileChange.ADD)) + newOid == null && oldOid != null -> changes.add(Change(path, oldOid, null, GitFileChange.DELETE)) + oldOid != null && newOid != null && oldOid != newOid -> + changes.add(Change(path, oldOid, newOid, GitFileChange.MODIFY)) + } + } + + val needed = changes.flatMap { listOfNotNull(it.oldOid, it.newOid) }.filter { it !in blobs }.distinct() + if (needed.isNotEmpty() && caps.supportsFilter) { + val blobPack = transport.fetchPack(cloneUrl, caps, needed, deepen = null, filterBlobNone = true) + for ((oid, obj) in Packfile.parse(blobPack)) { + if (obj.type == GitObjectType.BLOB) blobs[oid] = obj.data + } + } + + // 3. Line-diff every changed file. + val files = + changes.map { change -> + val oldBytes = change.oldOid?.let { blobs[it] } + val newBytes = change.newOid?.let { blobs[it] } + val binary = (oldBytes?.let(::isBinary) == true) || (newBytes?.let(::isBinary) == true) + val hunks = + if (binary) { + emptyList() + } else { + LineDiff.hunks(toLines(oldBytes), toLines(newBytes)) + } + GitDiffFile( + oldPath = if (change.change == GitFileChange.ADD) null else change.path, + newPath = if (change.change == GitFileChange.DELETE) null else change.path, + change = change.change, + isBinary = binary, + hunks = hunks, + ) + } + + return ParsedPatch(message = "", files = files) + } + + private fun commitTreeOf( + objects: Map, + commitOid: String, + ): String { + val commit = objects[commitOid] ?: throw GitHttpException("commit $commitOid missing from pack") + if (commit.type != GitObjectType.COMMIT) throw GitHttpException("$commitOid is not a commit") + return GitObjectParser.parseCommitTree(commit.data) ?: throw GitHttpException("commit $commitOid has no tree") + } + + private fun collectFiles( + trees: Map>, + rootTreeOid: String, + ): Map { + val out = HashMap() + + fun walk( + treeOid: String, + prefix: String, + ) { + val entries = trees[treeOid] ?: return + for (entry in entries) { + val path = if (prefix.isEmpty()) entry.name else "$prefix/${entry.name}" + when { + entry.isFolder -> walk(entry.oid, path) + entry.isSubmodule -> {} // submodule pointers aren't file content + else -> out[path] = entry.oid + } + } + } + walk(rootTreeOid, "") + return out + } + + private fun toLines(bytes: ByteArray?): List { + if (bytes == null || bytes.isEmpty()) return emptyList() + val text = bytes.decodeToString() + val parts = text.split("\n") + return if (text.endsWith("\n")) parts.dropLast(1) else parts + } + + private fun isBinary(bytes: ByteArray): Boolean { + val limit = minOf(bytes.size, 8000) + for (i in 0 until limit) if (bytes[i].toInt() == 0) return true + return false + } + /** Picks the ref to browse: an explicit [ref] if given, otherwise HEAD. */ private fun selectHead( refs: List, diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/LineDiffTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/LineDiffTest.kt new file mode 100644 index 0000000000..3f8889bcca --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/LineDiffTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.patch + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LineDiffTest { + private fun reconstructNew(hunk: GitDiffHunk) = hunk.lines.filter { it.type != GitDiffLineType.DELETE }.map { it.content } + + private fun reconstructOld(hunk: GitDiffHunk) = hunk.lines.filter { it.type != GitDiffLineType.ADD }.map { it.content } + + @Test + fun mergesNearbyChangesIntoOneHunk() { + val old = listOf("alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel") + val new = listOf("alpha", "bravo", "CHARLIE", "delta", "echo", "foxtrot", "NEW LINE", "golf", "hotel") + + val hunks = LineDiff.hunks(old, new) + assertEquals(1, hunks.size) + val hunk = hunks.single() + // matches `git diff -U3` + assertEquals("@@ -1,8 +1,9 @@", hunk.header) + assertEquals(1, hunk.oldStart) + assertEquals(1, hunk.newStart) + + // The whole file is one hunk here, so it must reconstruct both sides exactly. + assertEquals(old, reconstructOld(hunk)) + assertEquals(new, reconstructNew(hunk)) + + val adds = hunk.lines.filter { it.type == GitDiffLineType.ADD }.map { it.content } + val dels = hunk.lines.filter { it.type == GitDiffLineType.DELETE }.map { it.content } + assertEquals(listOf("CHARLIE", "NEW LINE"), adds) + assertEquals(listOf("charlie"), dels) + } + + @Test + fun splitsFarApartChangesIntoTwoHunks() { + val old = (1..60).map { "line $it" } + val new = + old.toMutableList().apply { + this[1] = "line 2 CHANGED" + this[50] = "line 51 CHANGED" + } + + val hunks = LineDiff.hunks(old, new) + assertEquals(2, hunks.size) + // First hunk around line 2 (within the leading context), second around line 51. + assertTrue("first hunk near top, was ${hunks[0].oldStart}", hunks[0].oldStart <= 2) + assertTrue("second hunk near 51, was ${hunks[1].oldStart}", hunks[1].oldStart in 47..51) + } + + @Test + fun pureAdditionIntoEmptyFile() { + val hunks = LineDiff.hunks(emptyList(), listOf("a", "b", "c")) + val hunk = hunks.single() + assertEquals("@@ -0,0 +1,3 @@", hunk.header) + assertEquals(3, hunk.lines.count { it.type == GitDiffLineType.ADD }) + assertEquals(0, hunk.lines.count { it.type != GitDiffLineType.ADD }) + } + + @Test + fun fullDeletion() { + val hunks = LineDiff.hunks(listOf("a", "b"), emptyList()) + val hunk = hunks.single() + assertEquals("@@ -1,2 +0,0 @@", hunk.header) + assertEquals(2, hunk.lines.count { it.type == GitDiffLineType.DELETE }) + } + + @Test + fun identicalFilesProduceNoHunks() { + val same = listOf("x", "y", "z") + assertTrue(LineDiff.hunks(same, same).isEmpty()) + } + + @Test + fun reconstructsAcrossManyEdits() { + // Deterministic pseudo-random-ish edits, then verify each hunk reconstructs locally. + val old = (1..40).map { "item-$it" } + val new = + old.toMutableList().apply { + removeAt(35) + add(10, "inserted-A") + this[5] = "item-6-edited" + add("appended") + } + val hunks = LineDiff.hunks(old, new, context = 3) + // Each hunk's reconstructed old/new slices must match the corresponding source slices. + for (hunk in hunks) { + val oldSlice = old.subList(hunk.oldStart - 1, hunk.oldStart - 1 + reconstructOld(hunk).size) + assertEquals(oldSlice, reconstructOld(hunk)) + } + } +} From 9207209b0b0ba9653db6a9edc40112d81d8575c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 21:14:57 +0000 Subject: [PATCH 08/31] feat: word-level intra-line highlighting in git diffs Emphasize what changed inside a modified line, not just the whole line. A new pure IntralineDiff helper pairs each delete with its corresponding add within a hunk and trims the common prefix/suffix to the differing middle; GitDiffView layers a stronger add/delete background over just that character span, on top of the existing syntax highlighting. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../amethyst/ui/note/types/GitDiffView.kt | 37 ++++++-- .../quartz/nip34Git/patch/IntralineDiff.kt | 85 +++++++++++++++++++ .../nip34Git/patch/IntralineDiffTest.kt | 69 +++++++++++++++ 3 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/IntralineDiff.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/IntralineDiffTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt index 70544136dd..2c78d82a70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt @@ -47,6 +47,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -60,10 +62,12 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.CodeHighlighter import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.patch.CharSpan import com.vitorpamplona.quartz.nip34Git.patch.GitDiffFile import com.vitorpamplona.quartz.nip34Git.patch.GitDiffLine import com.vitorpamplona.quartz.nip34Git.patch.GitDiffLineType import com.vitorpamplona.quartz.nip34Git.patch.GitFileChange +import com.vitorpamplona.quartz.nip34Git.patch.IntralineDiff import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch import dev.snipme.highlights.model.SyntaxLanguage @@ -177,8 +181,9 @@ private fun DiffFileCard( Column(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) { file.hunks.forEach { hunk -> HunkHeaderRow(hunk.header) - hunk.lines.forEach { line -> - DiffLineRow(line, gutterWidth, language.takeIf { highlightEnabled }, darkMode) + val emphasis = remember(hunk) { IntralineDiff.emphasis(hunk.lines) } + hunk.lines.forEachIndexed { index, line -> + DiffLineRow(line, gutterWidth, language.takeIf { highlightEnabled }, darkMode, emphasis[index]) } } } @@ -211,6 +216,7 @@ private fun DiffLineRow( gutterWidth: Dp, language: SyntaxLanguage?, darkMode: Boolean, + emphasis: CharSpan?, ) { val background = when (line.type) { @@ -218,6 +224,12 @@ private fun DiffLineRow( GitDiffLineType.DELETE -> DeleteColor.copy(alpha = 0.12f) GitDiffLineType.CONTEXT -> Color.Transparent } + val emphasisColor = + when (line.type) { + GitDiffLineType.ADD -> AddColor.copy(alpha = 0.30f) + GitDiffLineType.DELETE -> DeleteColor.copy(alpha = 0.30f) + GitDiffLineType.CONTEXT -> Color.Transparent + } val marker = when (line.type) { GitDiffLineType.ADD -> "+" @@ -233,11 +245,24 @@ private fun DiffLineRow( val numberColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f) val content = - remember(line.content, language, darkMode) { - if (language != null && line.content.isNotEmpty()) { - CodeHighlighter.highlight(line.content, language, darkMode) + remember(line.content, language, darkMode, emphasis) { + val base = + if (language != null && line.content.isNotEmpty()) { + CodeHighlighter.highlight(line.content, language, darkMode) + } else { + AnnotatedString(line.content) + } + if (emphasis != null && !emphasis.isEmpty) { + buildAnnotatedString { + append(base) + addStyle( + SpanStyle(background = emphasisColor), + emphasis.start.coerceIn(0, line.content.length), + emphasis.end.coerceIn(0, line.content.length), + ) + } } else { - AnnotatedString(line.content) + base } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/IntralineDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/IntralineDiff.kt new file mode 100644 index 0000000000..dbfae3a4a5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/IntralineDiff.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.patch + +/** A half-open `[start, end)` character span within a line. */ +class CharSpan( + val start: Int, + val end: Int, +) { + val isEmpty: Boolean get() = end <= start +} + +/** + * Computes intra-line (word/character level) change spans for a hunk, so the diff + * renderer can emphasize *what* changed inside a modified line rather than just + * coloring the whole line. Contiguous delete-runs are paired with the following + * add-runs (delete[i] ↔ add[i]); each pair is reduced to the differing middle by + * trimming the common prefix and suffix. + */ +object IntralineDiff { + /** The changed middle of [old] vs [new] (common prefix/suffix trimmed). */ + fun changedSpans( + old: String, + new: String, + ): Pair { + if (old == new) return CharSpan(0, 0) to CharSpan(0, 0) + val n = old.length + val m = new.length + var prefix = 0 + while (prefix < n && prefix < m && old[prefix] == new[prefix]) prefix++ + var suffix = 0 + while (suffix < n - prefix && suffix < m - prefix && old[n - 1 - suffix] == new[m - 1 - suffix]) suffix++ + return CharSpan(prefix, n - suffix) to CharSpan(prefix, m - suffix) + } + + /** + * Returns, per line index in [lines], the changed span to emphasize. Only + * paired modified lines get an entry; pure add/delete blocks and context + * lines are absent. + */ + fun emphasis(lines: List): Map { + val result = HashMap() + var i = 0 + while (i < lines.size) { + if (lines[i].type != GitDiffLineType.DELETE) { + i++ + continue + } + val delStart = i + while (i < lines.size && lines[i].type == GitDiffLineType.DELETE) i++ + val delEnd = i + val addStart = i + while (i < lines.size && lines[i].type == GitDiffLineType.ADD) i++ + val addEnd = i + + val pairs = minOf(delEnd - delStart, addEnd - addStart) + for (k in 0 until pairs) { + val delIdx = delStart + k + val addIdx = addStart + k + val (oldSpan, newSpan) = changedSpans(lines[delIdx].content, lines[addIdx].content) + if (!oldSpan.isEmpty) result[delIdx] = oldSpan + if (!newSpan.isEmpty) result[addIdx] = newSpan + } + } + return result + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/IntralineDiffTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/IntralineDiffTest.kt new file mode 100644 index 0000000000..b89fe62e21 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/IntralineDiffTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.patch + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class IntralineDiffTest { + private fun text( + old: String, + new: String, + ) = old.substring(IntralineDiff.changedSpans(old, new).first.let { it.start until it.end }) to + new.substring(IntralineDiff.changedSpans(old, new).second.let { it.start until it.end }) + + @Test + fun trimsCommonPrefixAndSuffix() { + val (oldChanged, newChanged) = text("the quick brown fox", "the quick red fox") + assertEquals("brown", oldChanged) + assertEquals("red", newChanged) + } + + @Test + fun pureInsertionHasEmptyOldSpan() { + val (old, new) = IntralineDiff.changedSpans("abcd", "abXYcd") + assertTrue(old.isEmpty) + assertEquals("XY", "abXYcd".substring(new.start until new.end)) + } + + @Test + fun identicalLinesProduceEmptySpans() { + val (old, new) = IntralineDiff.changedSpans("same", "same") + assertTrue(old.isEmpty) + assertTrue(new.isEmpty) + } + + @Test + fun emphasisPairsDeletesWithAdds() { + val lines = + listOf( + GitDiffLine(GitDiffLineType.CONTEXT, "ctx", 1, 1), + GitDiffLine(GitDiffLineType.DELETE, "value = 1", 2, null), + GitDiffLine(GitDiffLineType.ADD, "value = 2", null, 2), + ) + val emphasis = IntralineDiff.emphasis(lines) + // line 1 (delete) and line 2 (add) get the differing "1"/"2" highlighted + assertEquals("1", "value = 1".substring(emphasis[1]!!.start until emphasis[1]!!.end)) + assertEquals("2", "value = 2".substring(emphasis[2]!!.start until emphasis[2]!!.end)) + assertTrue(!emphasis.containsKey(0)) + } +} From 402c2fd3b4521a8f3983a2e2fa847636030697e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 21:27:12 +0000 Subject: [PATCH 09/31] feat: commit history in the git repository code browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a History action to the Code tab that shows a git-log-style list of the branch's recent commits, and opens the diff a commit introduced (vs its first parent) with the shared diff viewer. quartz: GitCommit + a commit-object parser (skips multi-line headers like a signed gpgsig), and GitHttpClient.loadHistory() — a shallow tree:0 fetch (commits only) walked most-recent-first. The fetch filter is generalized from a blob:none boolean to a filter spec so tree:0 can be requested. Unit-tested against a real SSH-signed commit object. amethyst: GitCommitLog (log list + per-commit diff), a History button on the repo header, and ViewModel hooks (loadHistory / commitDiff). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../loggedIn/gitRepo/code/GitBrowseUi.kt | 45 +-- .../loggedIn/gitRepo/code/GitCodeTab.kt | 9 + .../loggedIn/gitRepo/code/GitCommitLog.kt | 259 ++++++++++++++++++ .../code/GitRepositoryBrowserViewModel.kt | 15 + amethyst/src/main/res/values/strings.xml | 2 + .../quartz/nip34Git/git/GitHttpClient.kt | 75 ++++- .../quartz/nip34Git/git/GitObject.kt | Bin 4456 -> 6951 bytes .../nip34Git/git/GitSmartHttpTransport.kt | 8 +- .../quartz/nip34Git/git/GitUploadPackV2.kt | 4 +- .../nip34Git/git/GitCommitParserTest.kt | 48 ++++ 10 files changed, 440 insertions(+), 25 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCommitLog.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitCommitParserTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt index ffab9ab6e7..7bdc521e6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt @@ -38,6 +38,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -141,27 +142,39 @@ fun RepoInfoBar( branches: List = emptyList(), tags: List = emptyList(), onSelectRef: ((String?) -> Unit)? = null, + onHistory: (() -> Unit)? = null, ) { Row( - modifier = - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 12.dp, vertical = 8.dp), + modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - if (onSelectRef != null && (branches.size + tags.size) > 1) { - BranchSelector(branch, branches, tags, onSelectRef) - } else if (branch != null) { - InfoChip(symbol = MaterialSymbols.AltRoute, label = branch) + Row( + modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (onSelectRef != null && (branches.size + tags.size) > 1) { + BranchSelector(branch, branches, tags, onSelectRef) + } else if (branch != null) { + InfoChip(symbol = MaterialSymbols.AltRoute, label = branch) + } + InfoChip(symbol = MaterialSymbols.Commit, label = headCommit.take(7), monospace = true) + Text( + text = pluralStringResource(R.plurals.git_repo_item_count, entryCount, entryCount), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), + ) + } + if (onHistory != null) { + IconButton(onClick = onHistory) { + Icon( + symbol = MaterialSymbols.History, + contentDescription = stringRes(R.string.git_repo_commits), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } } - InfoChip(symbol = MaterialSymbols.Commit, label = headCommit.take(7), monospace = true) - Text( - text = pluralStringResource(R.plurals.git_repo_item_count, entryCount, entryCount), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), - ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt index d7469fb329..6523965452 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt @@ -105,6 +105,14 @@ private fun CodeBrowser( ) { var pathString by rememberSaveable(snapshot.headCommit) { mutableStateOf("") } var openFilePath by rememberSaveable(snapshot.headCommit) { mutableStateOf(null) } + var showHistory by rememberSaveable(snapshot.headCommit) { mutableStateOf(false) } + + if (showHistory) { + Column(Modifier.fillMaxSize().padding(scaffoldPaddingTop)) { + GitCommitLog(snapshot, viewModel, onBack = { showHistory = false }) + } + return + } val path = remember(pathString) { if (pathString.isEmpty()) emptyList() else pathString.split("/") } val openPath = openFilePath?.let { if (it.isEmpty()) emptyList() else it.split("/") } @@ -148,6 +156,7 @@ private fun CodeBrowser( branches = snapshot.branches, tags = snapshot.tags, onSelectRef = { viewModel.switchRef(it) }, + onHistory = { showHistory = true }, ) FileSearchField(query = query, onQueryChange = { query = it }) HorizontalDivider(thickness = 0.5.dp) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCommitLog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCommitLog.kt new file mode 100644 index 0000000000..90be83a102 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCommitLog.kt @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code + +import android.text.format.DateUtils +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.note.types.GitDiffView +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.git.GitCommit +import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot +import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch +import kotlin.coroutines.cancellation.CancellationException + +/** + * Commit history for the current branch: a `git log`-style list. Tapping a commit + * loads the diff it introduced (vs its first parent) and renders it with the + * shared [GitDiffView]. + */ +@Composable +fun GitCommitLog( + snapshot: GitRepoSnapshot, + viewModel: GitRepositoryBrowserViewModel, + onBack: () -> Unit, +) { + var openCommit by rememberSaveable(snapshot.headCommit) { mutableStateOf(null) } + + val history by + produceState>?>(null, snapshot.headCommit) { + value = + try { + Result.success(viewModel.loadHistory(snapshot)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + } + + val commits = history?.getOrNull() + + if (openCommit != null) { + val commit = remember(openCommit, commits) { commits?.firstOrNull { it.oid == openCommit } } + BackHandler { openCommit = null } + Column(Modifier.fillMaxSize()) { + LogHeader(title = commit?.shortOid ?: "", onBack = { openCommit = null }) + HorizontalDivider(thickness = 0.5.dp) + if (commit == null) { + GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_file_load_error)) + } else { + CommitDiff(snapshot, viewModel, commit) + } + } + return + } + + BackHandler { onBack() } + Column(Modifier.fillMaxSize()) { + LogHeader(title = stringRes(R.string.git_repo_commits), onBack = onBack) + HorizontalDivider(thickness = 0.5.dp) + when { + history == null -> GitLoadingBox(stringRes(R.string.git_repo_code_loading)) + history!!.isFailure -> GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_code_error)) + commits.isNullOrEmpty() -> GitMessageBox(MaterialSymbols.History, stringRes(R.string.git_repo_no_commits)) + else -> + LazyColumn(Modifier.fillMaxSize()) { + items(commits, key = { it.oid }) { commit -> + CommitRow(commit) { openCommit = commit.oid } + HorizontalDivider( + modifier = Modifier.padding(start = 14.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), + ) + } + } + } + } +} + +@Composable +private fun CommitDiff( + snapshot: GitRepoSnapshot, + viewModel: GitRepositoryBrowserViewModel, + commit: GitCommit, +) { + val result by + produceState?>(null, commit.oid) { + value = + try { + Result.success(viewModel.commitDiff(snapshot, commit)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + } + + when (val r = result) { + null -> GitLoadingBox(stringRes(R.string.git_repo_code_loading)) + else -> + if (r.isFailure || r.getOrThrow().files.isEmpty()) { + GitMessageBox(MaterialSymbols.Code, stringRes(R.string.git_pr_no_changes)) + } else { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(12.dp), + ) { + if (commit.summary.isNotBlank()) { + Text(commit.summary, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text( + text = "${commit.shortOid} · ${commit.authorName}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.padding(bottom = 8.dp), + ) + } + GitDiffView(r.getOrThrow(), Modifier.fillMaxWidth()) + } + } + } +} + +@Composable +private fun CommitRow( + commit: GitCommit, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + symbol = MaterialSymbols.Commit, + contentDescription = null, + modifier = Modifier.size(18.dp).padding(top = 2.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column(Modifier.weight(1f)) { + Text( + text = commit.summary.ifBlank { commit.shortOid }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = commit.shortOid, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), + modifier = + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 5.dp, vertical = 1.dp), + ) + Text( + text = "${commit.authorName} · ${relativeTime(commit.authorTimeSec)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun LogHeader( + title: String, + onBack: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton(onClick = onBack) { ArrowBackIcon() } + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private fun relativeTime(epochSec: Long): String = + if (epochSec <= 0) { + "" + } else { + DateUtils.getRelativeTimeSpanString(epochSec * 1000L, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt index 70807beaf4..a4dbe7d846 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt @@ -23,12 +23,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import com.vitorpamplona.quartz.nip34Git.git.GitCommit import com.vitorpamplona.quartz.nip34Git.git.GitHttpClient import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot +import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import kotlin.coroutines.cancellation.CancellationException @@ -116,6 +119,18 @@ class GitRepositoryBrowserViewModel( oid: String, ): ByteArray = snapshot.readBlob(oid) + /** Recent commits ending at the snapshot's tip, most recent first. */ + suspend fun loadHistory(snapshot: GitRepoSnapshot): List = withContext(Dispatchers.IO) { client.loadHistory(snapshot.cloneUrl, snapshot.headCommit) } + + /** The diff a commit introduced (commit vs its first parent). */ + suspend fun commitDiff( + snapshot: GitRepoSnapshot, + commit: GitCommit, + ): ParsedPatch = + withContext(Dispatchers.IO) { + client.computeDiff(snapshot.cloneUrl, commit.oid, commit.parents.firstOrNull()) + } + /** http(s) clone URLs to try, in order, including a `.git` variant when missing. */ private fun candidateUrls(): List { val out = LinkedHashSet() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1428329e41..1329ff6d49 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2594,6 +2594,8 @@ Tags Search files… No matching files. + Commits + No commit history available. Copy file contents Text diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt index 9515914903..e0369dd187 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.OkHttpClient +import java.util.PriorityQueue /** * High-level read-only browser for a git repository served over smart-HTTP. @@ -72,7 +73,7 @@ class GitHttpClient( deepen = 1, // When the server can't filter, a shallow fetch already pulls every blob, so the // snapshot is fully self-contained and no lazy per-file fetch is needed. - filterBlobNone = caps.supportsFilter, + filter = "blob:none", ) val objects = Packfile.parse(pack) @@ -106,6 +107,72 @@ class GitHttpClient( ) } + /** + * Loads up to [depth] commits of history starting at [startCommit] (or the + * server's HEAD), most recent first. Uses a shallow `tree:0` fetch so only the + * commit objects come down — no trees or blobs. The shallow boundary caps how + * far back the log goes. + */ + suspend fun loadHistory( + cloneUrl: String, + startCommit: String?, + depth: Int = 50, + ): List { + require(cloneUrl.startsWith("http://") || cloneUrl.startsWith("https://")) { + "unsupported git transport (only http/https): $cloneUrl" + } + val caps = transport.fetchCapabilities(cloneUrl) + if (!caps.supportsFetch) throw GitHttpException("server does not speak git protocol v2 (fetch)") + + val start = + startCommit?.takeIf { it.isNotBlank() } + ?: run { + if (!caps.supportsLsRefs) throw GitHttpException("server can't list refs") + val refs = transport.lsRefs(cloneUrl, caps) + selectHead(refs, null)?.oid ?: throw GitHttpException("could not resolve HEAD") + } + + val pack = + transport.fetchPack( + cloneUrl = cloneUrl, + caps = caps, + wants = listOf(start), + deepen = depth, + // tree:0 → commits only; if the server can't filter, blob:none still works (we ignore the trees). + filter = if (caps.supportsFilter) "tree:0" else "blob:none", + ) + val objects = Packfile.parse(pack) + val commits = HashMap() + for (obj in objects.values) { + if (obj.type == GitObjectType.COMMIT) { + val commit = GitObjectParser.parseCommit(obj.oid, obj.data) + commits[commit.oid] = commit + } + } + + // Walk parents most-recent-first (like `git log`), bounded by depth and the shallow set. + val result = ArrayList() + val visited = HashSet() + val frontier = PriorityQueue(compareByDescending { it.authorTimeSec }) + commits[start]?.let { + frontier.add(it) + visited.add(start) + } + while (frontier.isNotEmpty() && result.size < depth) { + val commit = frontier.poll() + result.add(commit) + for (parent in commit.parents) { + if (parent !in visited) { + commits[parent]?.let { + frontier.add(it) + visited.add(parent) + } + } + } + } + return result + } + /** * Computes the diff a pull request introduces: the changes between * [baseCommit] (or, when null, the server's HEAD) and [headCommit]. Fetches @@ -139,7 +206,7 @@ class GitHttpClient( caps = caps, wants = listOf(headCommit, base).distinct(), deepen = 1, - filterBlobNone = caps.supportsFilter, + filter = "blob:none", ) val objects = Packfile.parse(treePack) @@ -179,7 +246,7 @@ class GitHttpClient( val needed = changes.flatMap { listOfNotNull(it.oldOid, it.newOid) }.filter { it !in blobs }.distinct() if (needed.isNotEmpty() && caps.supportsFilter) { - val blobPack = transport.fetchPack(cloneUrl, caps, needed, deepen = null, filterBlobNone = true) + val blobPack = transport.fetchPack(cloneUrl, caps, needed, deepen = null, filter = "blob:none") for ((oid, obj) in Packfile.parse(blobPack)) { if (obj.type == GitObjectType.BLOB) blobs[oid] = obj.data } @@ -359,7 +426,7 @@ class GitRepoSnapshot( caps = caps, wants = listOf(oid), deepen = null, - filterBlobNone = caps.supportsFilter, + filter = "blob:none", ) val obj = Packfile.parse(pack)[oid] diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt index d8349ba4b3dc28efe63df3bdb467f771ac28f8b1..e65b2ca487620aceb1100b78556b61b1d5e2b72d 100644 GIT binary patch delta 2307 zcmZ`)&1xJ+5XNAT5%Gcv1SbbmnS(X6T8-8=SfEJj*dZhWBN=SSAr7du+dJFQ{A{Ls zEDN#BJ7jaqA;(+;evv#zATN+(s;awZX1&r}c6O@ztG}0f``d9vH@!*iI6 zQp*V>%D}kDGW;pdewJesLw|R3oC>YsJ7vCMQ@H$cj2yHYCHhXZsKYAAY}+-tKW?(1%ylZoM4dyslOBuLY{(bjxO?=UEj{P_CJ@zY~$oD7HFnF84%>y0;Wy7%OqhUaA|F1s8LyOosC5M9w9&N*yP5fz5Ldb(wxtsMz-1Ix( zGrpL~9Il=;2>v^P#M+3`=pU4sg#m3d1QG0vT;0pxdO>}oyX}HI;93%k$%M0u+d1t; zZW-Fv+=yxWB`t6f*+EQ!TB#3ELjkcqrc!Se;I!&3TLs~tW0B-i+i zwopKb{zoK5LJtbt?DZFn?5hgPzv6ZBA}Za8q*gdq!+Mv$zd_KKv>{^9iaz}mWX78wCB zblVPpntt7iuLSk>7UG3O`I6xlbjIVoEXvmf-SGBewby4y`^aTs8@gVD^IP7!_xS_Q Wl#r%1uw2G{vYQxv`0tbX?f(G@ppy;& delta 17 ZcmZ2(_CjgHe2&c;Tp28zkBKF+002S#27~|r diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt index 24963345a0..d8f63b8aaf 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt @@ -89,14 +89,16 @@ class GitSmartHttpTransport( * * @param wants object ids to request. * @param deepen shallow depth (1 = tip only). Null for a full fetch. - * @param filterBlobNone request `filter blob:none` (omit file contents). + * @param filter a partial-clone filter spec such as `blob:none` (omit file + * contents) or `tree:0` (omit trees and blobs). Applied only when the server + * advertises `filter`. */ suspend fun fetchPack( cloneUrl: String, caps: GitCapabilities, wants: List, deepen: Int?, - filterBlobNone: Boolean, + filter: String?, ): ByteArray = withContext(Dispatchers.IO) { require(wants.isNotEmpty()) { "fetch requires at least one want" } @@ -105,7 +107,7 @@ class GitSmartHttpTransport( objectFormat = caps.objectFormat, wants = wants, deepen = if (caps.supportsShallow) deepen else null, - filterBlobNone = filterBlobNone && caps.supportsFilter, + filter = filter?.takeIf { caps.supportsFilter }, ) GitUploadPackV2.extractPack(PktLineCodec.parse(postUploadPack(cloneUrl, body))) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt index f4b02e5fc4..61e9ce1f92 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt @@ -45,7 +45,7 @@ object GitUploadPackV2 { objectFormat: String, wants: List, deepen: Int?, - filterBlobNone: Boolean, + filter: String?, ): ByteArray = PktLineCodec.build { write(PktLineCodec.dataLine("command=fetch\n")) @@ -54,7 +54,7 @@ object GitUploadPackV2 { write(PktLineCodec.dataLine("no-progress\n")) wants.forEach { write(PktLineCodec.dataLine("want $it\n")) } if (deepen != null) write(PktLineCodec.dataLine("deepen $deepen\n")) - if (filterBlobNone) write(PktLineCodec.dataLine("filter blob:none\n")) + if (filter != null) write(PktLineCodec.dataLine("filter $filter\n")) write(PktLineCodec.dataLine("done\n")) write(PktLineCodec.FLUSH) } diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitCommitParserTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitCommitParserTest.kt new file mode 100644 index 0000000000..f8e2c8074e --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitCommitParserTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Base64 + +class GitCommitParserTest { + // A real, SSH-signed commit object (raw `git cat-file commit HEAD`), so the + // multi-line `gpgsig` header must be skipped without breaking parsing. + private val signedCommitB64 = + "dHJlZSA5YjZiM2Y5YmEzYjQxMjUxZTQzZGY0MDJiMjM0Njc4NzBiNDA4YTU4CnBhcmVudCBlNTc2ZjVmY2FmZjBkMDAwNGI2NmIzMjE2YmI2MTlhN2UyNjIwMTA2CmF1dGhvciBKYW5lIERldiA8YUBiLmNvbT4gMTc4MjY4MTU3NSArMDAwMApjb21taXR0ZXIgSmFuZSBEZXYgPGFAYi5jb20+IDE3ODI2ODE1NzUgKzAwMDAKZ3Bnc2lnIC0tLS0tQkVHSU4gU1NIIFNJR05BVFVSRS0tLS0tCiBVMU5JVTBsSEFBQUFBUUFBQURNQUFBQUxjM05vTFdWa01qVTFNVGtBQUFBZ3JMenNmRklTRjRieThRK0ZLejI3WXBrSzFVU3NCQittCiBhbXUxUWtKbmJEc0FBQUFEWjJsMEFBQUFBQUFBQUFaemFHRTFNVElBQUFCVEFBQUFDM056YUMxbFpESTFOVEU1QUFBQVFNRVUrNE1HCiAxU0tyWmVIUE5zeldQRGk1TzRIN3IyeU1mMkpWeGx1SG5xV2V4WTBHWHN1ZXBrSTBETXBOMldmWHZvMDc3cm9ac2Ivejhnb3RJSS92CiBCd009CiAtLS0tLUVORCBTU0ggU0lHTkFUVVJFLS0tLS0KCnNlY29uZCBjb21taXQKCndpdGggYSBib2R5IGxpbmUK" + + @Test + fun parsesSignedCommit() { + val data = Base64.getDecoder().decode(signedCommitB64) + val commit = GitObjectParser.parseCommit("20f90551a0e493306229a6b50990d3025b721cb0", data) + + assertEquals("9b6b3f9ba3b41251e43df402b23467870b408a58", commit.treeOid) + assertEquals(listOf("e576f5fcaff0d0004b66b3216bb619a7e2620106"), commit.parents) + assertEquals("Jane Dev", commit.authorName) + assertEquals("a@b.com", commit.authorEmail) + assertEquals(1782681575L, commit.authorTimeSec) + assertEquals("second commit", commit.summary) + assertEquals("20f9055", commit.shortOid) + // The signature block must not leak into the message. + assertEquals("second commit\n\nwith a body line\n", commit.message) + } +} From 5aaaa90cd7f9585b393ac08da1859f639f6a92a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 21:57:31 +0000 Subject: [PATCH 10/31] feat(git): bookmark repos + label filter & counts on status feed Adds NIP-51 (kind 10018) repository bookmarking with a star toggle in the git repository top bar, backed by a new GitRepositoryListState in commons. Removal rebuilds the public tag set and re-signs so encrypted private bookmarks are preserved without decryption. Adds a label-filter chip row and open/closed item counts to the Issues and Patches & PRs status feeds. The active feed's distinct labels drive the chips; selecting one filters the rendered list, and a stale selection is dropped when switching status. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../vitorpamplona/amethyst/model/Account.kt | 12 +++ .../model/nip51Lists/BookmarkListState.kt | 2 + .../ui/screen/loggedIn/AccountViewModel.kt | 12 +++ .../screen/loggedIn/gitRepo/GitItemListRow.kt | 9 +- .../loggedIn/gitRepo/GitRepositoryScreen.kt | 88 ++++++++++++++++- amethyst/src/main/res/values/strings.xml | 2 + .../nip51Lists/GitRepositoryListState.kt | 97 +++++++++++++++++++ 7 files changed, 217 insertions(+), 5 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/GitRepositoryListState.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 56ad5aad15..98db5ad4ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState +import com.vitorpamplona.amethyst.model.nip51Lists.GitRepositoryListState import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.PinListState @@ -397,6 +398,7 @@ class Account( val appRecommendations = AppRecommendationsState(signer, cache, scope) val oldBookmarkState = OldBookmarkListState(signer, cache, scope) val bookmarkState = BookmarkListState(signer, cache, scope) + val gitRepositoryListState = GitRepositoryListState(signer, cache, scope) val pinState = PinListState(signer, cache, scope) val emoji = EmojiPackState(signer, cache, scope) val ownedEmojiPacks = OwnedEmojiPacksState(signer, cache, scope) @@ -3010,6 +3012,16 @@ class Account( delete(note) } + suspend fun addGitRepositoryBookmark(note: AddressableNote) { + if (!isWriteable()) return + sendMyPublicAndPrivateOutbox(gitRepositoryListState.addRepository(note)) + } + + suspend fun removeGitRepositoryBookmark(note: AddressableNote) { + if (!isWriteable()) return + gitRepositoryListState.removeRepository(note)?.let { sendMyPublicAndPrivateOutbox(it) } + } + suspend fun addBookmark( note: Note, isPrivate: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt index fb13e4d527..42ef3e2ae1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt @@ -23,3 +23,5 @@ package com.vitorpamplona.amethyst.model.nip51Lists typealias BookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState typealias OldBookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState + +typealias GitRepositoryListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 23def5aa6b..264089f734 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1146,6 +1146,18 @@ class AccountViewModel( direct = { account.removeBookmark(note, false) }, ) + /** Stars/unstars a git repository in the user's NIP-51 kind 10018 list. */ + fun toggleRepositoryBookmark( + note: AddressableNote, + isBookmarked: Boolean, + ) = launchSigner { + if (isBookmarked) { + account.removeGitRepositoryBookmark(note) + } else { + account.addGitRepositoryBookmark(note) + } + } + /** NIP-32: tags [note] with [hashtag] by publishing a kind 1985 label event. */ fun labelWithHashtag( note: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt index 93290c7811..cd4a789797 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt @@ -96,15 +96,20 @@ fun GitItemFeedLoaded( listState: LazyListState, accountViewModel: AccountViewModel, nav: INav, + labelFilter: String? = null, ) { val items by loaded.feed.collectAsStateWithLifecycle() + val list = + remember(items, labelFilter) { + if (labelFilter == null) items.list else items.list.filter { labelFilter in gitLabelsOf(it.event) } + } LazyColumn( contentPadding = rememberFeedContentPadding(FeedPadding), state = listState, ) { itemsIndexed( - items.list, + list, key = { _, item -> item.idHex }, contentType = { _, item -> item.event?.kind ?: -1 }, ) { _, item -> @@ -275,7 +280,7 @@ private fun gitSubjectOf(event: Event?): String? = else -> null } -private fun gitLabelsOf(event: Event?): List = +internal fun gitLabelsOf(event: Event?): List = when (event) { is GitIssueEvent -> event.topics() is GitPullRequestEvent -> event.labels() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index ddc38ccf58..a5e752d618 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -32,6 +33,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilterChip @@ -44,6 +46,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -57,7 +60,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState @@ -80,6 +85,10 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @Composable @@ -205,6 +214,16 @@ private fun GitRepositoryScreen( } }, actions = { + val bookmarkedSet by accountViewModel.account.gitRepositoryListState.publicRepositoryAddressSet + .collectAsStateWithLifecycle() + val isBookmarked = remember(bookmarkedSet, note) { bookmarkedSet.contains(note.address) } + IconButton(onClick = { accountViewModel.toggleRepositoryBookmark(note, isBookmarked) }) { + Icon( + symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkBorder, + contentDescription = stringRes(R.string.git_repo_bookmark), + tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } if (event != null && accountViewModel.isLoggedUser(event?.pubKey)) { IconButton(onClick = { showSettings = true }) { Icon(MaterialSymbols.Edit, contentDescription = stringRes(R.string.git_repo_settings_title)) @@ -359,6 +378,21 @@ private fun StatusSplitFeed( headerAction: (@Composable () -> Unit)? = null, ) { var showClosed by rememberSaveable(persistKey) { mutableStateOf(false) } + var selectedLabel by rememberSaveable(persistKey) { mutableStateOf(null) } + + val openItems = rememberGitFeedItems(openViewModel) + val closedItems = rememberGitFeedItems(closedViewModel) + + val activeItems = if (showClosed) closedItems else openItems + val labels = + remember(activeItems) { + activeItems.flatMap { gitLabelsOf(it.event) }.distinct().sorted() + } + + // A label selected under one status may not exist under the other; drop it when it's gone. + LaunchedEffect(labels) { + if (selectedLabel != null && selectedLabel !in labels) selectedLabel = null + } Column(Modifier.fillMaxSize()) { Row( @@ -372,7 +406,7 @@ private fun StatusSplitFeed( FilterChip( selected = !showClosed, onClick = { showClosed = false }, - label = { Text(stringRes(R.string.git_repo_filter_open)) }, + label = { Text(countedLabel(stringRes(R.string.git_repo_filter_open), openItems.size)) }, leadingIcon = if (!showClosed) { { Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp)) } @@ -383,7 +417,7 @@ private fun StatusSplitFeed( FilterChip( selected = showClosed, onClick = { showClosed = true }, - label = { Text(stringRes(R.string.git_repo_filter_closed)) }, + label = { Text(countedLabel(stringRes(R.string.git_repo_filter_closed), closedItems.size)) }, leadingIcon = if (showClosed) { { Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp)) } @@ -397,18 +431,66 @@ private fun StatusSplitFeed( } } + if (labels.isNotEmpty()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = selectedLabel == null, + onClick = { selectedLabel = null }, + label = { Text(stringRes(R.string.git_repo_label_all)) }, + ) + labels.forEach { label -> + FilterChip( + selected = selectedLabel == label, + onClick = { selectedLabel = if (selectedLabel == label) null else label }, + label = { Text("#$label") }, + ) + } + } + } + RefresheableFeedView( viewModel = if (showClosed) closedViewModel else openViewModel, routeForLastRead = null, accountViewModel = accountViewModel, nav = nav, onLoaded = { loaded, listState -> - GitItemFeedLoaded(loaded, listState, accountViewModel, nav) + GitItemFeedLoaded(loaded, listState, accountViewModel, nav, labelFilter = selectedLabel) }, ) } } +/** Appends a count to a chip label, e.g. "Open · 3". Hidden while the feed is still empty/loading. */ +private fun countedLabel( + base: String, + count: Int, +): String = if (count > 0) "$base · $count" else base + +/** + * Mirrors the active feed list out of a [FeedViewModel] so the status row can show item counts + * and derive the available label set. Emits an empty list while the feed is loading or empty. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Composable +private fun rememberGitFeedItems(viewModel: FeedViewModel): List { + val flow = + remember(viewModel) { + viewModel.feedState.feedContent.flatMapLatest { state -> + if (state is FeedState.Loaded) state.feed.map { it.list } else flowOf(emptyList()) + } + } + val items by flow.collectAsStateWithLifecycle(emptyList()) + return items +} + @Composable private fun TopBarTitle( event: GitRepositoryEvent?, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1329ff6d49..d0938c4b97 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2572,6 +2572,8 @@ Patches & PRs Open Closed & Resolved + All + Bookmark repository Untitled About Links diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/GitRepositoryListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/GitRepositoryListState.kt new file mode 100644 index 0000000000..16d02ba621 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/GitRepositoryListState.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.nip51Lists + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.gitRepositoryList.GitRepositoryListEvent +import com.vitorpamplona.quartz.nip51Lists.remove +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn + +/** + * Account state for the user's bookmarked (starred) git repositories — NIP-51 + * kind 10018. Mirrors [BookmarkListState] but only the public list is used for + * the star toggle; removal rebuilds the public tags and preserves the encrypted + * private section untouched, so it never needs to decrypt. + */ +@Stable +class GitRepositoryListState( + val signer: NostrSigner, + val cache: ICacheProvider, + val scope: CoroutineScope, +) { + // Long-term reference so the GC keeps the note alive. + val repositoryList = cache.getOrCreateAddressableNote(getListAddress()) + + fun getListAddress() = GitRepositoryListEvent.createAddress(signer.pubKey) + + fun getListFlow(): StateFlow = repositoryList.flow().metadata.stateFlow + + fun getList(): GitRepositoryListEvent? = repositoryList.event as? GitRepositoryListEvent + + private fun publicAddresses(note: Note): Set
= (note.event as? GitRepositoryListEvent)?.publicRepositories()?.map { it.address }?.toSet() ?: emptySet() + + @OptIn(FlowPreview::class) + val publicRepositoryAddressSet: StateFlow> = + getListFlow() + .map { publicAddresses(it.note) } + .onStart { emit(publicAddresses(repositoryList)) } + .debounce(100) + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + fun isBookmarked(address: Address): Boolean = publicRepositoryAddressSet.value.contains(address) + + /** Adds [note]'s address to the public list, creating the list if needed. */ + suspend fun addRepository(note: AddressableNote): GitRepositoryListEvent { + val list = getList() + val bookmark = AddressBookmark(note.address, note.relayHintUrl()) + return if (list == null) { + GitRepositoryListEvent.create(publicRepositories = listOf(bookmark), signer = signer) + } else { + GitRepositoryListEvent.add(earlierVersion = list, repository = bookmark, isPrivate = false, signer = signer) + } + } + + /** Removes [note]'s address from the public list, leaving the private section intact. */ + suspend fun removeRepository(note: AddressableNote): GitRepositoryListEvent? { + val list = getList() ?: return null + val idTag = AddressBookmark(note.address, note.relayHintUrl()).toTagIdOnly() + val newTags = list.tags.remove(idTag) + if (newTags.size == list.tags.size) return null + return GitRepositoryListEvent.resign(content = list.content, tags = newTags, signer = signer) + } +} From 714d202e7de61a439df4ddf5170025d768053998 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 22:55:56 +0000 Subject: [PATCH 11/31] refactor(git): move code-browser ViewModel + syntax highlighter to commons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocates the two app-agnostic pieces of the NIP-34 code browser into commons so the desktop front end can reuse them verbatim: - GitRepositoryBrowserViewModel (+ GitBrowseState) → commons jvmAndroid nip34Git package. Pure StateFlow ViewModel over quartz's GitHttpClient; no Android/AccountViewModel/INav dependency. - CodeHighlighter → commons commonMain nip34Git/ui. Pulls the Apache-2.0 dev.snipme:highlights dependency into commons commonMain. Amethyst composables now import both from commons. The screen-level composables stay app-side, matching the commons convention that shared UI never takes AccountViewModel/INav. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt | 2 +- .../amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt | 2 +- .../amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt | 2 ++ .../amethyst/ui/screen/loggedIn/gitRepo/code/GitCommitLog.kt | 1 + .../amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt | 2 ++ .../amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt | 2 ++ commons/build.gradle.kts | 3 +++ .../amethyst/commons/nip34Git/ui}/CodeHighlighter.kt | 2 +- .../commons/nip34Git}/GitRepositoryBrowserViewModel.kt | 2 +- 9 files changed, 14 insertions(+), 4 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui}/CodeHighlighter.kt (98%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git}/GitRepositoryBrowserViewModel.kt (98%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt index 2c78d82a70..e612e7bbef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt @@ -60,7 +60,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.CodeHighlighter +import com.vitorpamplona.amethyst.commons.nip34Git.ui.CodeHighlighter import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip34Git.patch.CharSpan import com.vitorpamplona.quartz.nip34Git.patch.GitDiffFile diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index a5e752d618..d2b10d5910 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -60,6 +60,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note @@ -77,7 +78,6 @@ import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.GitCodeTab import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.GitReadmeTab -import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.GitRepositoryBrowserViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryIssuesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryPatchesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource.RepositoryFilterAssemblerSubscription diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt index 6523965452..741980c4ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt @@ -60,6 +60,8 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.nip34Git.GitBrowseState +import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCommitLog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCommitLog.kt index 90be83a102..f818bb51c1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCommitLog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCommitLog.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.types.GitDiffView import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt index 505c936492..95535b7a94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt @@ -60,6 +60,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel +import com.vitorpamplona.amethyst.commons.nip34Git.ui.CodeHighlighter import com.vitorpamplona.amethyst.ui.components.RichTextViewer import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt index 4bc30479db..63181ec903 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.nip34Git.GitBrowseState +import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding import com.vitorpamplona.amethyst.ui.components.RichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 44cb23f0d3..4c0d5532bd 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -122,6 +122,9 @@ kotlin { // Compose Multiplatform Resources implementation(libs.jetbrains.compose.components.resources) + + // KMP syntax highlighter (Apache-2.0) for the git code browser. + implementation(libs.highlights) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/CodeHighlighter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/CodeHighlighter.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/CodeHighlighter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/CodeHighlighter.kt index 10d0e034b5..e5c566b64d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/CodeHighlighter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/CodeHighlighter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code +package com.vitorpamplona.amethyst.commons.nip34Git.ui import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepositoryBrowserViewModel.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepositoryBrowserViewModel.kt index a4dbe7d846..83749ad420 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitRepositoryBrowserViewModel.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepositoryBrowserViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code +package com.vitorpamplona.amethyst.commons.nip34Git import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider From d6ec4581173ef0345d076eacc70736c4e8d73fe0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 23:55:35 +0000 Subject: [PATCH 12/31] fix(git): close Issues/PR feed top gap; add "Mine" repo feed filter The status feed drew its filter-chip header at the top of the content area (under the disappearing top bar) while the feed's LazyColumn separately re-applied the full bar-height inset as content padding, leaving an empty band above the first item. The header now consumes the scaffold top inset itself and the inner feed renders with the top inset zeroed, matching the Code tab's padding pattern. Also adds a "Mine" option to the git repositories top-nav filter so the feed can show only the logged-in user's own repositories. The feed side already resolves TopFilter.Mine generically; this just exposes it via a dedicated gitRepositoryRoutes catalog (kind3 + Around Me + Global + Mine). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../amethyst/ui/screen/TopNavFilterState.kt | 23 ++++++++++ .../loggedIn/gitRepo/GitRepositoryScreen.kt | 43 ++++++++++++++----- .../gitRepositories/GitRepositoriesTopBar.kt | 2 +- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index b8a78cd5fb..9e05c1d6a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -310,6 +310,24 @@ class TopNavFilterState( ) } + private val _gitRepositoryRoutes = + combineTransform( + livePeopleListsFlow, + liveInterestFlows, + ) { peopleLists, interests -> + checkNotInMainThread() + emit( + listOf( + // Git repository announcements can be narrowed by author, hashtag and geohash, + // so this mirrors the kind3 catalog plus "Mine" — the user's own repositories. + listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow), + peopleLists, + interests, + listOf(muteListFollow), + ).flatten().toImmutableList(), + ) + } + private val _kind3GlobalPeople = livePeopleListsFlow.transform { peopleLists -> checkNotInMainThread() @@ -385,6 +403,11 @@ class TopNavFilterState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + val gitRepositoryRoutes = + _gitRepositoryRoutes + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + fun destroy() { Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index d2b10d5910..546f90b584 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -43,6 +45,7 @@ import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -52,6 +55,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -62,6 +66,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent @@ -394,7 +399,23 @@ private fun StatusSplitFeed( if (selectedLabel != null && selectedLabel !in labels) selectedLabel = null } - Column(Modifier.fillMaxSize()) { + // The filter header is drawn statically below the disappearing top bar, so it must + // consume the scaffold's top inset itself. The inner feed then renders with the top + // inset zeroed — otherwise its LazyColumn re-applies the full bar height as content + // padding on top of the header, leaving the empty band reported above the items. + val scaffoldPadding = LocalDisappearingScaffoldPadding.current + val layoutDirection = LocalLayoutDirection.current + val feedPadding = + remember(scaffoldPadding, layoutDirection) { + PaddingValues( + start = scaffoldPadding.calculateStartPadding(layoutDirection), + top = 0.dp, + end = scaffoldPadding.calculateEndPadding(layoutDirection), + bottom = scaffoldPadding.calculateBottomPadding(), + ) + } + + Column(Modifier.fillMaxSize().padding(top = scaffoldPadding.calculateTopPadding())) { Row( modifier = Modifier @@ -456,15 +477,17 @@ private fun StatusSplitFeed( } } - RefresheableFeedView( - viewModel = if (showClosed) closedViewModel else openViewModel, - routeForLastRead = null, - accountViewModel = accountViewModel, - nav = nav, - onLoaded = { loaded, listState -> - GitItemFeedLoaded(loaded, listState, accountViewModel, nav, labelFilter = selectedLabel) - }, - ) + CompositionLocalProvider(LocalDisappearingScaffoldPadding provides feedPadding) { + RefresheableFeedView( + viewModel = if (showClosed) closedViewModel else openViewModel, + routeForLastRead = null, + accountViewModel = accountViewModel, + nav = nav, + onLoaded = { loaded, listState -> + GitItemFeedLoaded(loaded, listState, accountViewModel, nav, labelFilter = selectedLabel) + }, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesTopBar.kt index d28ff11f96..4c30842a95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesTopBar.kt @@ -58,7 +58,7 @@ private fun GitRepositoriesTopNavFilterBar( accountViewModel: AccountViewModel, onChange: (FeedDefinition) -> Unit, ) { - val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + val allLists by followListsModel.gitRepositoryRoutes.collectAsStateWithLifecycle() FeedFilterSpinner( placeholderCode = listName, From 144f543014c42317d28a61b54405eb26f719c1dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 00:00:31 +0000 Subject: [PATCH 13/31] fix(git): move browser ViewModel factory app-side for KMP lifecycle The KMP lifecycle-viewmodel artifact used by commons doesn't expose the create(Class) ViewModelProvider.Factory override (only the desktop/JVM target hit this), so the factory now lives in amethyst alongside the viewModel() call, mirroring the NestViewModel pattern. The commons ViewModel keeps only platform-agnostic state. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../loggedIn/gitRepo/GitRepositoryScreen.kt | 15 ++++++++++++++- .../nip34Git/GitRepositoryBrowserViewModel.kt | 7 ------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index 546f90b584..27ef82ee55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -59,6 +59,8 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -95,6 +97,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import okhttp3.OkHttpClient @Composable fun GitRepositoryScreen( @@ -113,6 +116,16 @@ fun GitRepositoryScreen( } } +/** + * Builds the [GitRepositoryBrowserViewModel]. The factory lives app-side because the KMP + * lifecycle artifact used in commons doesn't expose the `create(Class)` override. + */ +private class GitRepositoryBrowserViewModelFactory( + private val okHttpClient: (String) -> OkHttpClient, +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T = GitRepositoryBrowserViewModel(okHttpClient) as T +} + @Composable private fun PrepareGitRepositoryScreen( note: AddressableNote, @@ -146,7 +159,7 @@ private fun PrepareGitRepositoryScreen( val browserViewModel: GitRepositoryBrowserViewModel = viewModel( key = note.idHex + "GitRepoBrowser", - factory = GitRepositoryBrowserViewModel.Factory(accountViewModel.httpClientBuilder::okHttpClientForPreview), + factory = GitRepositoryBrowserViewModelFactory(accountViewModel.httpClientBuilder::okHttpClientForPreview), ) GitRepositoryScreen( diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepositoryBrowserViewModel.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepositoryBrowserViewModel.kt index 83749ad420..3a43119a5e 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepositoryBrowserViewModel.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepositoryBrowserViewModel.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.commons.nip34Git import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.vitorpamplona.quartz.nip34Git.git.GitCommit import com.vitorpamplona.quartz.nip34Git.git.GitHttpClient @@ -143,12 +142,6 @@ class GitRepositoryBrowserViewModel( return out.toList() } - class Factory( - private val okHttpClient: (String) -> OkHttpClient, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T = GitRepositoryBrowserViewModel(okHttpClient) as T - } - companion object { const val NO_CLONE_URL = "no-clone-url" } From d2225c73cc41a176abdb2b0e207932f6c542b557 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 00:15:51 +0000 Subject: [PATCH 14/31] feat(git): replace repo tab bar with project home + drill-in screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the five-tab repository screen (Readme/Code/Overview/Issues/Patches) with a scrollable Project Home and three dedicated screens: - GitRepositoryScreen is now the home: repository facts (from the former Overview), Code / Issues / Pull Requests navigation cards, and the README rendered inline — all in one scroll. - New GitRepositoryCode/Issues/Pulls screens, each owning its own disappearing top bar, reached via new addressable routes. This removes tab overflow and the swipe-paging between heavy screens, and fixes the per-tab header issues structurally: the Code browser's branch/tag and file-search header now live inside the scroll list, so they track the disappearing top bar instead of staying pinned below it. README and Overview were refactored into embeddable, non-scrolling sections (GitReadmeSection, GitRepositoryOverviewSections) so the home can compose them in a single scroll container. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../amethyst/ui/navigation/AppNavigation.kt | 6 + .../amethyst/ui/navigation/routes/Routes.kt | 36 ++ .../loggedIn/gitRepo/GitRepositoryOverview.kt | 19 +- .../loggedIn/gitRepo/GitRepositoryScreen.kt | 484 +++++++++++------- .../loggedIn/gitRepo/code/GitCodeTab.kt | 85 +-- .../loggedIn/gitRepo/code/GitReadmeTab.kt | 99 ++-- 6 files changed, 433 insertions(+), 296 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 47c6b4875a..d50c03a1a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -134,6 +134,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPack import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.FollowPacksScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryCodeScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryIssuesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryPullsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.GitRepositoriesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen @@ -486,6 +489,9 @@ fun BuildNavigation( composableFromEndArgs { RelayMembersScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { GitRepositoryScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { GitRepositoryCodeScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { GitRepositoryIssuesScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { GitRepositoryPullsScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.attachment, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 496f63eca7..a4965e1db2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -517,6 +517,42 @@ sealed class Route { ) } + @Serializable data class GitRepositoryCode( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } + + @Serializable data class GitRepositoryIssues( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } + + @Serializable data class GitRepositoryPulls( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } + @Serializable data class FollowPack( val kind: Int, val pubKeyHex: HexKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt index 2420bf175f..4ce87c971f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt @@ -26,15 +26,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -48,7 +45,6 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -67,20 +63,19 @@ private val SectionCardShape = RoundedCornerShape(15.dp) private val SectionSpacing = Arrangement.spacedBy(12.dp) private val LinkSpacing = Arrangement.spacedBy(8.dp) +/** + * The repository's facts (title, about, links, topics, maintainers) as embeddable + * column content — no scroll container or scaffold padding of its own, so the project + * home screen can lay it out above the README inside a single scroll. + */ @Composable -fun GitRepositoryOverview( +fun GitRepositoryOverviewSections( event: GitRepositoryEvent, accountViewModel: AccountViewModel, nav: INav, ) { - val scaffoldPadding = LocalDisappearingScaffoldPadding.current Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(scaffoldPadding) - .padding(horizontal = 12.dp, vertical = 16.dp), + modifier = Modifier.fillMaxWidth(), verticalArrangement = SectionSpacing, ) { TitleHeader(event) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index 27ef82ee55..a97f916623 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -34,15 +35,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilterChip import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SecondaryTabRow -import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -50,11 +50,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -65,6 +65,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState @@ -73,9 +74,9 @@ import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel -import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon @@ -84,21 +85,29 @@ import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.GitCodeTab -import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.GitReadmeTab +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.GitReadmeSection import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryIssuesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryPatchesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource.RepositoryFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import okhttp3.OkHttpClient +// --------------------------------------------------------------------------- +// Project Home + drill-in screens. +// +// The repository is presented as a scrollable "project home" (facts + README + +// navigation cards) rather than a tab bar. Code, Issues and Pull Requests are +// dedicated screens, each owning its own disappearing top bar — so per-section +// headers (branch/search selectors, status filters) track the bar correctly and +// heavy content (the code browser, the feeds) only loads when navigated to. +// --------------------------------------------------------------------------- + @Composable fun GitRepositoryScreen( address: Address, @@ -107,7 +116,7 @@ fun GitRepositoryScreen( ) { LoadAddressableNote(address, accountViewModel) { note -> note?.let { - PrepareGitRepositoryScreen( + GitRepositoryHome( note = it, accountViewModel = accountViewModel, nav = nav, @@ -116,6 +125,39 @@ fun GitRepositoryScreen( } } +@Composable +fun GitRepositoryCodeScreen( + address: Address, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadAddressableNote(address, accountViewModel) { note -> + note?.let { GitRepositoryCode(it, accountViewModel, nav) } + } +} + +@Composable +fun GitRepositoryIssuesScreen( + address: Address, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadAddressableNote(address, accountViewModel) { note -> + note?.let { GitRepositoryIssues(it, accountViewModel, nav) } + } +} + +@Composable +fun GitRepositoryPullsScreen( + address: Address, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadAddressableNote(address, accountViewModel) { note -> + note?.let { GitRepositoryPulls(it, accountViewModel, nav) } + } +} + /** * Builds the [GitRepositoryBrowserViewModel]. The factory lives app-side because the KMP * lifecycle artifact used in commons doesn't expose the `create(Class)` override. @@ -127,92 +169,54 @@ private class GitRepositoryBrowserViewModelFactory( } @Composable -private fun PrepareGitRepositoryScreen( +private fun rememberRepoBrowser( note: AddressableNote, accountViewModel: AccountViewModel, - nav: INav, -) { - val openIssuesViewModel: RepositoryIssuesFeedViewModel = - viewModel( - key = note.idHex + "GitRepoIssuesOpen", - factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false), - ) - - val closedIssuesViewModel: RepositoryIssuesFeedViewModel = - viewModel( - key = note.idHex + "GitRepoIssuesClosed", - factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true), - ) - - val openPatchesViewModel: RepositoryPatchesFeedViewModel = - viewModel( - key = note.idHex + "GitRepoPatchesOpen", - factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false), - ) - - val closedPatchesViewModel: RepositoryPatchesFeedViewModel = - viewModel( - key = note.idHex + "GitRepoPatchesClosed", - factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true), - ) - - val browserViewModel: GitRepositoryBrowserViewModel = - viewModel( - key = note.idHex + "GitRepoBrowser", - factory = GitRepositoryBrowserViewModelFactory(accountViewModel.httpClientBuilder::okHttpClientForPreview), - ) - - GitRepositoryScreen( - note = note, - openIssuesViewModel = openIssuesViewModel, - closedIssuesViewModel = closedIssuesViewModel, - openPatchesViewModel = openPatchesViewModel, - closedPatchesViewModel = closedPatchesViewModel, - browserViewModel = browserViewModel, - accountViewModel = accountViewModel, - nav = nav, +): GitRepositoryBrowserViewModel = + viewModel( + key = note.idHex + "GitRepoBrowser", + factory = GitRepositoryBrowserViewModelFactory(accountViewModel.httpClientBuilder::okHttpClientForPreview), ) + +/** + * Subscribes to the repository's issues/patches/status events while [event] is loaded. + * + * RepositoryContentSubAssembler.updateFilter reads note.event and bails out if it isn't a + * GitRepositoryEvent yet. The compose subscription manager doesn't re-run updateFilter when + * note.event later mutates, so subscribing before the event arrives (cold-start / deep-link) + * leaves an empty filter forever. Gating on event presence makes the subscription composable + * enter composition only once the repo event is loaded. + */ +@Composable +private fun RepoContentSubscription( + note: AddressableNote, + event: GitRepositoryEvent?, + accountViewModel: AccountViewModel, +) { + if (event != null) { + RepositoryFilterAssemblerSubscription(note, accountViewModel.dataSources().gitRepository) + } } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun GitRepositoryScreen( +private fun GitRepositoryHome( note: AddressableNote, - openIssuesViewModel: RepositoryIssuesFeedViewModel, - closedIssuesViewModel: RepositoryIssuesFeedViewModel, - openPatchesViewModel: RepositoryPatchesFeedViewModel, - closedPatchesViewModel: RepositoryPatchesFeedViewModel, - browserViewModel: GitRepositoryBrowserViewModel, accountViewModel: AccountViewModel, nav: INav, ) { - WatchLifecycleAndUpdateModel(openIssuesViewModel) - WatchLifecycleAndUpdateModel(closedIssuesViewModel) - WatchLifecycleAndUpdateModel(openPatchesViewModel) - WatchLifecycleAndUpdateModel(closedPatchesViewModel) - + val browserViewModel = rememberRepoBrowser(note, accountViewModel) val event by observeNoteEvent(note, accountViewModel) - // Start the smart-HTTP browser as soon as the repository announcement arrives, - // so the README and Code tabs can fetch from its clone URL. + // Start the smart-HTTP browser as soon as the announcement arrives, so the README renders. LaunchedEffect(event) { event?.let { browserViewModel.loadOnce(it.clones()) } } val browserState by browserViewModel.state.collectAsStateWithLifecycle() - // RepositoryContentSubAssembler.updateFilter reads note.event and bails out if it isn't - // a GitRepositoryEvent yet. The compose subscription manager doesn't re-run updateFilter - // when note.event later mutates, so subscribing before the event has arrived (cold-start - // / deep-link case) leaves an empty filter forever. Gate the subscription composable on - // event presence so it enters composition (and fires DisposableEffect → subscribe → fresh - // updateFilter) only once the repo event is loaded. - if (event != null) { - RepositoryFilterAssemblerSubscription(note, accountViewModel.dataSources().gitRepository) - } + RepoContentSubscription(note, event, accountViewModel) - val pagerState = rememberForeverPagerState(note.idHex + "GitRepoScreenPagerState") { 5 } var showSettings by rememberSaveable(note.idHex) { mutableStateOf(false) } - val currentEventForSettings = event if (showSettings && currentEventForSettings != null) { GitRepoSettingsDialog(currentEventForSettings, accountViewModel) { showSettings = false } @@ -221,124 +225,234 @@ private fun GitRepositoryScreen( DisappearingScaffold( isInvertedLayout = false, topBar = { - Column { - ShorterTopAppBar( - title = { - TopBarTitle(event = event, fallback = note.dTag()) - }, - navigationIcon = { - Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = nav::popBack) { ArrowBackIcon() } + ShorterTopAppBar( + title = { TopBarTitle(event = event, fallback = note.dTag()) }, + navigationIcon = { + Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = nav::popBack) { ArrowBackIcon() } + } + }, + actions = { + val bookmarkedSet by accountViewModel.account.gitRepositoryListState.publicRepositoryAddressSet + .collectAsStateWithLifecycle() + val isBookmarked = remember(bookmarkedSet, note) { bookmarkedSet.contains(note.address) } + IconButton(onClick = { accountViewModel.toggleRepositoryBookmark(note, isBookmarked) }) { + Icon( + symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkBorder, + contentDescription = stringRes(R.string.git_repo_bookmark), + tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (event != null && accountViewModel.isLoggedUser(event?.pubKey)) { + IconButton(onClick = { showSettings = true }) { + Icon(MaterialSymbols.Edit, contentDescription = stringRes(R.string.git_repo_settings_title)) } - }, - actions = { - val bookmarkedSet by accountViewModel.account.gitRepositoryListState.publicRepositoryAddressSet - .collectAsStateWithLifecycle() - val isBookmarked = remember(bookmarkedSet, note) { bookmarkedSet.contains(note.address) } - IconButton(onClick = { accountViewModel.toggleRepositoryBookmark(note, isBookmarked) }) { - Icon( - symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkBorder, - contentDescription = stringRes(R.string.git_repo_bookmark), - tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (event != null && accountViewModel.isLoggedUser(event?.pubKey)) { - IconButton(onClick = { showSettings = true }) { - Icon(MaterialSymbols.Edit, contentDescription = stringRes(R.string.git_repo_settings_title)) - } - } - }, - ) - - SecondaryTabRow( - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - modifier = TabRowHeight, - selectedTabIndex = pagerState.currentPage, - ) { - val coroutineScope = rememberCoroutineScope() - Tab( - selected = pagerState.currentPage == 0, - text = { Text(stringRes(R.string.git_repo_tab_readme)) }, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, - ) - Tab( - selected = pagerState.currentPage == 1, - text = { Text(stringRes(R.string.git_repo_tab_code)) }, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, - ) - Tab( - selected = pagerState.currentPage == 2, - text = { Text(stringRes(R.string.git_repo_tab_overview)) }, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, - ) - Tab( - selected = pagerState.currentPage == 3, - text = { Text(stringRes(R.string.git_repo_tab_issues)) }, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(3) } }, - ) - Tab( - selected = pagerState.currentPage == 4, - text = { Text(stringRes(R.string.git_repo_tab_patches)) }, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(4) } }, - ) - } - } + } + }, + ) }, accountViewModel = accountViewModel, ) { - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - ) { page -> - when (page) { - 0 -> { - val currentEvent = event - if (currentEvent != null) { - GitReadmeTab(browserState, browserViewModel, currentEvent, accountViewModel, nav) - } else { - EmptyMessage(stringRes(R.string.loading_feed)) - } - } + val scaffoldPadding = LocalDisappearingScaffoldPadding.current + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(scaffoldPadding) + .padding(horizontal = 12.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + val currentEvent = event + if (currentEvent != null) { + GitRepositoryOverviewSections(currentEvent, accountViewModel, nav) + } - 1 -> { - GitCodeTab(browserState, browserViewModel, accountViewModel, nav) - } + RepoNavCards(note, nav) - 2 -> { - val currentEvent = event - if (currentEvent != null) { - GitRepositoryOverview(currentEvent, accountViewModel, nav) - } else { - EmptyMessage(stringRes(R.string.loading_feed)) - } - } - - 3 -> { - GitIssuesTab( - note = note, - event = event, - openViewModel = openIssuesViewModel, - closedViewModel = closedIssuesViewModel, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - 4 -> { - StatusSplitFeed( - persistKey = note.idHex + "GitRepoPatchesStatus", - openViewModel = openPatchesViewModel, - closedViewModel = closedPatchesViewModel, - accountViewModel = accountViewModel, - nav = nav, - ) - } + if (currentEvent != null) { + GitReadmeSection(browserState, browserViewModel, currentEvent, accountViewModel, nav) + } else { + EmptyMessage(stringRes(R.string.loading_feed)) } } } } +@Composable +private fun GitRepositoryCode( + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + val browserViewModel = rememberRepoBrowser(note, accountViewModel) + val event by observeNoteEvent(note, accountViewModel) + LaunchedEffect(event) { + event?.let { browserViewModel.loadOnce(it.clones()) } + } + val browserState by browserViewModel.state.collectAsStateWithLifecycle() + RepoContentSubscription(note, event, accountViewModel) + + GitRepoSubScreenScaffold(event, note.dTag(), accountViewModel, nav) { + GitCodeTab(browserState, browserViewModel, accountViewModel, nav) + } +} + +@Composable +private fun GitRepositoryIssues( + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + val openViewModel: RepositoryIssuesFeedViewModel = + viewModel( + key = note.idHex + "GitRepoIssuesOpen", + factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false), + ) + val closedViewModel: RepositoryIssuesFeedViewModel = + viewModel( + key = note.idHex + "GitRepoIssuesClosed", + factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true), + ) + WatchLifecycleAndUpdateModel(openViewModel) + WatchLifecycleAndUpdateModel(closedViewModel) + + val event by observeNoteEvent(note, accountViewModel) + RepoContentSubscription(note, event, accountViewModel) + + GitRepoSubScreenScaffold(event, note.dTag(), accountViewModel, nav) { + GitIssuesTab( + note = note, + event = event, + openViewModel = openViewModel, + closedViewModel = closedViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +private fun GitRepositoryPulls( + note: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + val openViewModel: RepositoryPatchesFeedViewModel = + viewModel( + key = note.idHex + "GitRepoPatchesOpen", + factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false), + ) + val closedViewModel: RepositoryPatchesFeedViewModel = + viewModel( + key = note.idHex + "GitRepoPatchesClosed", + factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true), + ) + WatchLifecycleAndUpdateModel(openViewModel) + WatchLifecycleAndUpdateModel(closedViewModel) + + val event by observeNoteEvent(note, accountViewModel) + RepoContentSubscription(note, event, accountViewModel) + + GitRepoSubScreenScaffold(event, note.dTag(), accountViewModel, nav) { + StatusSplitFeed( + persistKey = note.idHex + "GitRepoPatchesStatus", + openViewModel = openViewModel, + closedViewModel = closedViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +/** Shared scaffold for the Code / Issues / Pull Requests drill-in screens: a back arrow and the repo title. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GitRepoSubScreenScaffold( + event: GitRepositoryEvent?, + fallbackTitle: String, + accountViewModel: AccountViewModel, + nav: INav, + content: @Composable () -> Unit, +) { + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + ShorterTopAppBar( + title = { TopBarTitle(event = event, fallback = fallbackTitle) }, + navigationIcon = { + Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = nav::popBack) { ArrowBackIcon() } + } + }, + ) + }, + accountViewModel = accountViewModel, + ) { + content() + } +} + +/** The Code / Issues / Pull Requests entry points on the project home. */ +@Composable +private fun RepoNavCards( + note: AddressableNote, + nav: INav, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + RepoNavCard(MaterialSymbols.Code, stringRes(R.string.git_repo_tab_code)) { + nav.nav(Route.GitRepositoryCode(note.address)) + } + RepoNavCard(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_tab_issues)) { + nav.nav(Route.GitRepositoryIssues(note.address)) + } + RepoNavCard(MaterialSymbols.CallMerge, stringRes(R.string.git_repo_tab_patches)) { + nav.nav(Route.GitRepositoryPulls(note.address)) + } + } +} + +@Composable +private fun RepoNavCard( + symbol: MaterialSymbol, + title: String, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(15.dp)) + .background(MaterialTheme.colorScheme.surface) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.3f), + ) + } +} + /** * The Issues tab: the open/closed status feed plus a "New issue" composer reachable * from the filter row once the repository announcement has loaded. @@ -383,8 +497,8 @@ private fun NewIssueButton(onClick: () -> Unit) { * Wraps a feed in an Open / Closed & Resolved status filter, swapping between two * status-scoped feed view models. Each view model already filters by NIP-34 status, so the * selector only chooses which one is rendered. The selection survives configuration changes - * and tab swipes via [persistKey]. An optional [headerAction] (e.g. a "New issue" button) - * is shown at the trailing edge of the filter row. + * via [persistKey]. An optional [headerAction] (e.g. a "New issue" button) is shown at the + * trailing edge of the filter row. */ @Composable private fun StatusSplitFeed( @@ -546,7 +660,7 @@ private fun EmptyMessage(text: String) { Box( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() .background(MaterialTheme.colorScheme.background), contentAlignment = Alignment.Center, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt index 741980c4ca..a6dfaf6fd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt @@ -150,48 +150,57 @@ private fun CodeBrowser( val entries = remember(snapshot, pathString) { snapshot.entriesAt(path).orEmpty() } - Column(Modifier.fillMaxSize().padding(scaffoldPaddingTop)) { - RepoInfoBar( - branch = snapshot.branch, - headCommit = snapshot.headCommit, - entryCount = entries.size, - branches = snapshot.branches, - tags = snapshot.tags, - onSelectRef = { viewModel.switchRef(it) }, - onHistory = { showHistory = true }, - ) - FileSearchField(query = query, onQueryChange = { query = it }) - HorizontalDivider(thickness = 0.5.dp) + // A single scrolling list so the branch/search header rides along with the disappearing + // top bar instead of staying pinned below it. The header rows are the first list items; + // the file/search rows follow, all under one contentPadding for the scaffold inset. + val results = remember(snapshot, query) { if (searching) snapshot.searchFiles(query.trim()) else emptyList() } - if (searching) { - val results = remember(snapshot, query) { snapshot.searchFiles(query.trim()) } - if (results.isEmpty()) { - GitMessageBox(MaterialSymbols.Search, stringRes(R.string.git_repo_no_search_results)) - } else { - LazyColumn(Modifier.fillMaxSize()) { - items(results, key = { it.joinToString("/") }) { result -> - SearchResultRow(result) { openFilePath = result.joinToString("/") } - HorizontalDivider( - modifier = Modifier.padding(start = 40.dp), - thickness = 0.5.dp, - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), - ) - } - } - } - return@Column + LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = scaffoldPaddingTop) { + item(key = "repo-info-bar") { + RepoInfoBar( + branch = snapshot.branch, + headCommit = snapshot.headCommit, + entryCount = entries.size, + branches = snapshot.branches, + tags = snapshot.tags, + onSelectRef = { viewModel.switchRef(it) }, + onHistory = { showHistory = true }, + ) + } + item(key = "file-search") { + FileSearchField(query = query, onQueryChange = { query = it }) + HorizontalDivider(thickness = 0.5.dp) } - Breadcrumb( - path = path, - onNavigate = { depth -> pathString = path.take(depth).joinToString("/") }, - ) - HorizontalDivider(thickness = 0.5.dp) - if (entries.isEmpty()) { - GitMessageBox(MaterialSymbols.Folder, stringRes(R.string.git_repo_empty_folder)) + if (searching) { + if (results.isEmpty()) { + item(key = "no-results") { + GitMessageBox(MaterialSymbols.Search, stringRes(R.string.git_repo_no_search_results), Modifier.fillParentMaxWidth()) + } + } else { + items(results, key = { "result/" + it.joinToString("/") }) { result -> + SearchResultRow(result) { openFilePath = result.joinToString("/") } + HorizontalDivider( + modifier = Modifier.padding(start = 40.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), + ) + } + } } else { - LazyColumn(Modifier.fillMaxSize()) { - items(entries, key = { it.name }) { entry -> + item(key = "breadcrumb") { + Breadcrumb( + path = path, + onNavigate = { depth -> pathString = path.take(depth).joinToString("/") }, + ) + HorizontalDivider(thickness = 0.5.dp) + } + if (entries.isEmpty()) { + item(key = "empty-folder") { + GitMessageBox(MaterialSymbols.Folder, stringRes(R.string.git_repo_empty_folder), Modifier.fillParentMaxWidth()) + } + } else { + items(entries, key = { "entry/" + it.name }) { entry -> EntryRow( entry = entry, onClick = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt index 63181ec903..ac2dd315fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt @@ -21,12 +21,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,13 +31,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.nip34Git.GitBrowseState import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel -import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding import com.vitorpamplona.amethyst.ui.components.RichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -52,32 +47,30 @@ import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import kotlin.coroutines.cancellation.CancellationException /** - * First tab of the repository screen. Renders the repository's README as rich - * markdown when it can be fetched, otherwise falls back to the announcement's - * own description so the tab is never empty. + * The repository's README as embeddable column content — no scroll container or + * scaffold padding of its own, so the project home screen renders it inline below + * the repository facts inside a single scroll. Falls back to the announcement's own + * description when no README file can be fetched, so the section is never empty. */ @Composable -fun GitReadmeTab( +fun GitReadmeSection( state: GitBrowseState, viewModel: GitRepositoryBrowserViewModel, event: GitRepositoryEvent, accountViewModel: AccountViewModel, nav: INav, ) { - val scaffoldPadding = LocalDisappearingScaffoldPadding.current - val snapshot = (state as? GitBrowseState.Loaded)?.snapshot val readme = remember(snapshot) { snapshot?.let { findReadme(it.rootEntries()) } } if (snapshot != null && readme != null) { - ReadmeContent(snapshot, viewModel, readme, accountViewModel, nav, scaffoldPaddingTop = scaffoldPadding) + ReadmeContent(snapshot, viewModel, readme, accountViewModel, nav) } else { ReadmeFallback( event = event, loading = state is GitBrowseState.Loading, accountViewModel = accountViewModel, nav = nav, - scaffoldPaddingTop = scaffoldPadding, ) } } @@ -89,7 +82,6 @@ private fun ReadmeContent( readme: GitTreeEntry, accountViewModel: AccountViewModel, nav: INav, - scaffoldPaddingTop: PaddingValues, ) { val content by produceState(null, readme.oid) { @@ -104,30 +96,21 @@ private fun ReadmeContent( } when (val text = content) { - null -> GitLoadingBox(stringRes(R.string.git_repo_code_loading), Modifier.padding(scaffoldPaddingTop)) - "" -> GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_file_load_error), Modifier.padding(scaffoldPaddingTop)) + null -> StatusLine(stringRes(R.string.git_repo_code_loading)) + "" -> StatusLine(stringRes(R.string.git_repo_file_load_error)) else -> { val background = MaterialTheme.colorScheme.background val backgroundColor = remember { mutableStateOf(background) } - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(scaffoldPaddingTop) - .padding(horizontal = 12.dp, vertical = 12.dp), - ) { - RichTextViewer( - content = text, - canPreview = true, - quotesLeft = 1, - modifier = Modifier.fillMaxWidth(), - tags = EmptyTagList, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav, - ) - } + RichTextViewer( + content = text, + canPreview = true, + quotesLeft = 1, + modifier = Modifier.fillMaxWidth(), + tags = EmptyTagList, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } } } @@ -138,53 +121,47 @@ private fun ReadmeFallback( loading: Boolean, accountViewModel: AccountViewModel, nav: INav, - scaffoldPaddingTop: PaddingValues, ) { val description = event.description()?.takeIf { it.isNotBlank() } if (description == null) { - if (loading) { - GitLoadingBox(stringRes(R.string.git_repo_code_loading), Modifier.padding(scaffoldPaddingTop)) - } else { - GitMessageBox(MaterialSymbols.Description, stringRes(R.string.git_repo_readme_missing), Modifier.padding(scaffoldPaddingTop)) - } + StatusLine( + if (loading) stringRes(R.string.git_repo_code_loading) else stringRes(R.string.git_repo_readme_missing), + ) return } val background = MaterialTheme.colorScheme.background val backgroundColor = remember { mutableStateOf(background) } - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(scaffoldPaddingTop) - .padding(horizontal = 12.dp, vertical = 12.dp), - ) { - Text( - text = event.name() ?: event.dTag(), - style = MaterialTheme.typography.headlineSmall, - ) + Column(Modifier.fillMaxWidth()) { RichTextViewer( content = description, canPreview = true, quotesLeft = 1, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + modifier = Modifier.fillMaxWidth(), tags = EmptyTagList, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav, ) if (loading) { - Text( - text = stringRes(R.string.git_repo_code_loading), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), - modifier = Modifier.padding(top = 16.dp), - ) + StatusLine(stringRes(R.string.git_repo_code_loading), topPadding = 16.dp) } } } +@Composable +private fun StatusLine( + text: String, + topPadding: androidx.compose.ui.unit.Dp = 0.dp, +) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), + modifier = Modifier.padding(top = topPadding), + ) +} + /** Picks the most README-like file in the root, preferring markdown. */ private fun findReadme(entries: List): GitTreeEntry? { val files = entries.filter { !it.isFolder } From 2ad4af2479c1441694840886b2a339db0e990183 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 15:32:47 +0000 Subject: [PATCH 15/31] feat(git): rich project-home dashboard (stats, languages, social pulse) Turns the repository home into a data-rich dashboard combining git facts with the Nostr social layer: - Social row: live zap / reaction / comment counts on the repo announcement (via observeNoteZaps/Reactions/ReplyCount). - Stat tiles: branches, tags, file count, and last-updated relative time. - Language breakdown bar: proportional colored segments computed from the snapshot's file tree by extension (new GitRepoSnapshot.walkFileNames()). - Last-commit strip: tip commit summary, author, time and short SHA, exposed up-front via the new GitRepoSnapshot.tipCommit (no extra fetch). - Nav cards now show open issue / PR counts. - Recent-activity pulse: newest issues/patches merged from the feeds. quartz: GitRepoSnapshot gains tipCommit (parsed in open()) and walkFileNames() to enumerate blob paths for the language bar. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../screen/loggedIn/gitRepo/GitItemListRow.kt | 2 +- .../loggedIn/gitRepo/GitRepositoryHomeUi.kt | 420 ++++++++++++++++++ .../loggedIn/gitRepo/GitRepositoryScreen.kt | 87 +++- amethyst/src/main/res/values/strings.xml | 5 + .../quartz/nip34Git/git/GitHttpClient.kt | 30 ++ 5 files changed, 539 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt index cd4a789797..cdb48b8fd4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt @@ -272,7 +272,7 @@ private fun GitRevisedChip(prIdHex: String) { } } -private fun gitSubjectOf(event: Event?): String? = +internal fun gitSubjectOf(event: Event?): String? = when (event) { is GitIssueEvent -> event.subject()?.takeIf { it.isNotBlank() } is GitPullRequestEvent -> event.subject()?.takeIf { it.isNotBlank() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt new file mode 100644 index 0000000000..f400d32122 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt @@ -0,0 +1,420 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactionCount +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip34Git.git.GitCommit +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent + +private val CardShape = RoundedCornerShape(15.dp) + +// --------------------------------------------------------------------------- +// Social pulse — zaps, reactions and comments on the repository announcement. +// --------------------------------------------------------------------------- + +@Composable +fun RepoSocialRow( + note: AddressableNote, + accountViewModel: AccountViewModel, +) { + val reactionCount by observeNoteReactionCount(note, accountViewModel) + val replyCount by observeNoteReplyCount(note, accountViewModel) + val zapState by observeNoteZaps(note, accountViewModel) + val zapCount = zapState?.note?.zaps?.size ?: note.zaps.size + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SocialStat(MaterialSymbols.Bolt, zapCount, MaterialTheme.colorScheme.primary) + SocialStat(MaterialSymbols.Favorite, reactionCount, MaterialTheme.colorScheme.error) + SocialStat(MaterialSymbols.Forum, replyCount, MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun SocialStat( + symbol: MaterialSymbol, + count: Int, + tint: Color, +) { + Row( + modifier = + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(16.dp), tint = tint) + Text( + text = compactCount(count), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + } +} + +// --------------------------------------------------------------------------- +// Stat tiles — git facts (branches, tags, files, last updated). +// --------------------------------------------------------------------------- + +@Composable +fun RepoStatTiles( + branches: Int, + tags: Int, + files: Int, + updatedEpochSec: Long?, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + StatTile(MaterialSymbols.Commit, branches.toString(), stringRes(R.string.git_repo_stat_branches), Modifier.weight(1f)) + StatTile(MaterialSymbols.Tag, tags.toString(), stringRes(R.string.git_repo_stat_tags), Modifier.weight(1f)) + StatTile(MaterialSymbols.Description, compactCount(files), stringRes(R.string.git_repo_stat_files), Modifier.weight(1f)) + StatTile(MaterialSymbols.Schedule, updatedEpochSec?.let { relativeShort(it) } ?: "—", stringRes(R.string.git_repo_stat_updated), Modifier.weight(1f)) + } +} + +@Composable +private fun StatTile( + symbol: MaterialSymbol, + value: String, + label: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .clip(CardShape) + .background(MaterialTheme.colorScheme.surface) + .padding(vertical = 12.dp, horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary) + Text( + text = value, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + ) + } +} + +// --------------------------------------------------------------------------- +// Language breakdown bar — proportional colours by file count. +// --------------------------------------------------------------------------- + +class LanguageSlice( + val name: String, + val fraction: Float, + val color: Color, +) + +@Composable +fun RepoLanguageBar(slices: List) { + if (slices.isEmpty()) return + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(10.dp) + .clip(CircleShape), + ) { + slices.forEach { slice -> + Box( + modifier = + Modifier + .weight(slice.fraction.coerceAtLeast(0.0001f)) + .height(10.dp) + .background(slice.color), + ) + } + } + FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + slices.forEach { slice -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Box(Modifier.size(9.dp).clip(CircleShape).background(slice.color)) + Text( + text = slice.name + " " + (slice.fraction * 100).toInt() + "%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + } + } + } +} + +private class LangDef( + val name: String, + val color: Long, +) { + override fun equals(other: Any?) = other is LangDef && other.name == name + + override fun hashCode() = name.hashCode() +} + +private val EXT_TO_LANG: Map = + buildMap { + fun add( + def: LangDef, + vararg exts: String, + ) = exts.forEach { put(it, def) } + add(LangDef("Kotlin", 0xFFA97BFF), "kt", "kts") + add(LangDef("Java", 0xFFB07219), "java") + add(LangDef("JavaScript", 0xFFF1E05A), "js", "mjs", "cjs", "jsx") + add(LangDef("TypeScript", 0xFF3178C6), "ts", "tsx") + add(LangDef("Python", 0xFF3572A5), "py") + add(LangDef("Ruby", 0xFF701516), "rb") + add(LangDef("Rust", 0xFFDEA584), "rs") + add(LangDef("Go", 0xFF00ADD8), "go") + add(LangDef("C", 0xFF555555), "c", "h") + add(LangDef("C++", 0xFFF34B7D), "cpp", "cc", "cxx", "hpp", "hh", "hxx") + add(LangDef("C#", 0xFF178600), "cs") + add(LangDef("Swift", 0xFFF05138), "swift") + add(LangDef("PHP", 0xFF4F5D95), "php") + add(LangDef("Shell", 0xFF89E051), "sh", "bash", "zsh", "ksh") + add(LangDef("HTML", 0xFFE34C26), "html", "htm") + add(LangDef("CSS", 0xFF563D7C), "css") + add(LangDef("SCSS", 0xFFC6538C), "scss") + add(LangDef("Markdown", 0xFF083FA1), "md", "markdown") + add(LangDef("JSON", 0xFF40803F), "json") + add(LangDef("YAML", 0xFFCB171E), "yml", "yaml") + add(LangDef("XML", 0xFF0060AC), "xml") + add(LangDef("Gradle", 0xFF02303A), "gradle") + add(LangDef("Dart", 0xFF00B4AB), "dart") + add(LangDef("TOML", 0xFF9C4221), "toml") + } + +private const val OTHER_COLOR = 0xFFBBBBBB + +/** Buckets files by recognised language (by extension), returning the top slices + an "Other" remainder. */ +fun computeLanguageBreakdown(files: List): List { + if (files.isEmpty()) return emptyList() + val counts = HashMap() + var other = 0 + for (f in files) { + val ext = f.substringAfterLast('.', "").lowercase() + val def = EXT_TO_LANG[ext] + if (def == null) other++ else counts[def] = (counts[def] ?: 0) + 1 + } + val total = files.size.toFloat() + val ranked = counts.entries.sortedByDescending { it.value } + val top = ranked.take(6) + val remainder = ranked.drop(6).sumOf { it.value } + other + + val slices = + top.map { LanguageSlice(it.key.name, it.value / total, Color(it.key.color)) }.toMutableList() + if (remainder > 0) { + slices.add(LanguageSlice("Other", remainder / total, Color(OTHER_COLOR))) + } + return slices +} + +// --------------------------------------------------------------------------- +// Last-commit strip. +// --------------------------------------------------------------------------- + +@Composable +fun RepoLastCommit(commit: GitCommit) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(CardShape) + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(MaterialSymbols.Commit, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = commit.summary, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = commit.authorName + " · " + relativeShort(commit.authorTimeSec), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = commit.shortOid, + style = MaterialTheme.typography.labelMedium, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.grayText, + ) + } +} + +// --------------------------------------------------------------------------- +// Recent activity pulse — newest issues / patches / status events. +// --------------------------------------------------------------------------- + +@Composable +fun RepoActivityPulse( + items: List, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (items.isEmpty()) return + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(CardShape) + .background(MaterialTheme.colorScheme.surface) + .padding(vertical = 6.dp), + ) { + Text( + text = stringRes(R.string.git_repo_recent_activity), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.grayText, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp), + ) + items.forEach { ActivityRow(it, accountViewModel, nav) } + } +} + +@Composable +private fun ActivityRow( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = note.event ?: return + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + symbol = activityIcon(event), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = gitSubjectOf(event) ?: stringRes(R.string.git_untitled), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + TimeAgo(note) + } +} + +private fun activityIcon(event: Event): MaterialSymbol = + when (event) { + is GitIssueEvent -> MaterialSymbols.ErrorOutline + is GitPullRequestEvent -> MaterialSymbols.CallMerge + is GitPatchEvent -> MaterialSymbols.CallMerge + else -> MaterialSymbols.Commit + } + +// --------------------------------------------------------------------------- +// Helpers. +// --------------------------------------------------------------------------- + +/** Compacts large counts: 1234 -> "1.2k", 2_500_000 -> "2.5M". */ +private fun compactCount(n: Int): String = + when { + n < 1_000 -> n.toString() + n < 1_000_000 -> trimDecimal(n / 1_000.0) + "k" + else -> trimDecimal(n / 1_000_000.0) + "M" + } + +private fun trimDecimal(v: Double): String { + val rounded = (v * 10).toInt() / 10.0 + return if (rounded % 1.0 == 0.0) rounded.toInt().toString() else rounded.toString() +} + +/** A coarse "time since" label for epoch seconds: "5m", "3h", "2d", "4w", "1y". */ +private fun relativeShort(epochSec: Long): String { + val deltaSec = (System.currentTimeMillis() / 1000) - epochSec + if (deltaSec < 60) return "now" + val mins = deltaSec / 60 + if (mins < 60) return mins.toString() + "m" + val hours = mins / 60 + if (hours < 24) return hours.toString() + "h" + val days = hours / 24 + if (days < 7) return days.toString() + "d" + val weeks = days / 7 + if (weeks < 52) return weeks.toString() + "w" + return (days / 365).toString() + "y" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index a97f916623..f6d2ec0bbd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.nip34Git.GitBrowseState import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding @@ -216,6 +217,47 @@ private fun GitRepositoryHome( RepoContentSubscription(note, event, accountViewModel) + // Feed view models power the issue/PR counts on the nav cards and the recent-activity pulse. + val openIssues: RepositoryIssuesFeedViewModel = + viewModel( + key = note.idHex + "GitRepoIssuesOpen", + factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false), + ) + val closedIssues: RepositoryIssuesFeedViewModel = + viewModel( + key = note.idHex + "GitRepoIssuesClosed", + factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true), + ) + val openPatches: RepositoryPatchesFeedViewModel = + viewModel( + key = note.idHex + "GitRepoPatchesOpen", + factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false), + ) + val closedPatches: RepositoryPatchesFeedViewModel = + viewModel( + key = note.idHex + "GitRepoPatchesClosed", + factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true), + ) + WatchLifecycleAndUpdateModel(openIssues) + WatchLifecycleAndUpdateModel(closedIssues) + WatchLifecycleAndUpdateModel(openPatches) + WatchLifecycleAndUpdateModel(closedPatches) + + val openIssueItems = rememberGitFeedItems(openIssues) + val closedIssueItems = rememberGitFeedItems(closedIssues) + val openPatchItems = rememberGitFeedItems(openPatches) + val closedPatchItems = rememberGitFeedItems(closedPatches) + + val snapshot = (browserState as? GitBrowseState.Loaded)?.snapshot + val fileNames = remember(snapshot) { snapshot?.walkFileNames() ?: emptyList() } + val languageSlices = remember(fileNames) { computeLanguageBreakdown(fileNames) } + val activity = + remember(openIssueItems, closedIssueItems, openPatchItems, closedPatchItems) { + (openIssueItems + closedIssueItems + openPatchItems + closedPatchItems) + .sortedByDescending { it.createdAt() ?: 0L } + .take(6) + } + var showSettings by rememberSaveable(note.idHex) { mutableStateOf(false) } val currentEventForSettings = event if (showSettings && currentEventForSettings != null) { @@ -266,9 +308,25 @@ private fun GitRepositoryHome( val currentEvent = event if (currentEvent != null) { GitRepositoryOverviewSections(currentEvent, accountViewModel, nav) + RepoSocialRow(note, accountViewModel) } - RepoNavCards(note, nav) + if (snapshot != null) { + RepoStatTiles( + branches = snapshot.branches.size, + tags = snapshot.tags.size, + files = fileNames.size, + updatedEpochSec = snapshot.tipCommit?.authorTimeSec, + ) + if (languageSlices.isNotEmpty()) { + RepoLanguageBar(languageSlices) + } + snapshot.tipCommit?.let { RepoLastCommit(it) } + } + + RepoNavCards(note, openIssueItems.size, openPatchItems.size, nav) + + RepoActivityPulse(activity, accountViewModel, nav) if (currentEvent != null) { GitReadmeSection(browserState, browserViewModel, currentEvent, accountViewModel, nav) @@ -397,19 +455,21 @@ private fun GitRepoSubScreenScaffold( @Composable private fun RepoNavCards( note: AddressableNote, + openIssues: Int, + openPulls: Int, nav: INav, ) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - RepoNavCard(MaterialSymbols.Code, stringRes(R.string.git_repo_tab_code)) { + RepoNavCard(MaterialSymbols.Code, stringRes(R.string.git_repo_tab_code), null) { nav.nav(Route.GitRepositoryCode(note.address)) } - RepoNavCard(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_tab_issues)) { + RepoNavCard(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_tab_issues), openIssues) { nav.nav(Route.GitRepositoryIssues(note.address)) } - RepoNavCard(MaterialSymbols.CallMerge, stringRes(R.string.git_repo_tab_patches)) { + RepoNavCard(MaterialSymbols.CallMerge, stringRes(R.string.git_repo_tab_patches), openPulls) { nav.nav(Route.GitRepositoryPulls(note.address)) } } @@ -419,6 +479,7 @@ private fun RepoNavCards( private fun RepoNavCard( symbol: MaterialSymbol, title: String, + count: Int?, onClick: () -> Unit, ) { Row( @@ -444,6 +505,24 @@ private fun RepoNavCard( fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f), ) + if (count != null && count > 0) { + Text( + text = + if (count > 999) { + "999+" + } else { + count.toString() + }, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)) + .padding(horizontal = 9.dp, vertical = 2.dp), + ) + } Icon( symbol = MaterialSymbols.ChevronRight, contentDescription = null, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 609cb66f59..16473b8b30 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2646,6 +2646,11 @@ Closed & Resolved All Bookmark repository + Branches + Tags + Files + Updated + Recent activity Untitled About Links diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt index e0369dd187..8c296f5ea7 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt @@ -93,6 +93,8 @@ class GitHttpClient( } } + val tipCommit = runCatching { GitObjectParser.parseCommit(head.oid, commit.data) }.getOrNull() + return GitRepoSnapshot( cloneUrl = cloneUrl, headCommit = head.oid, @@ -104,6 +106,7 @@ class GitHttpClient( blobs = blobs, transport = transport, caps = caps, + tipCommit = tipCommit, ) } @@ -353,6 +356,8 @@ class GitRepoSnapshot( blobs: Map, private val transport: GitSmartHttpTransport, private val caps: GitCapabilities, + /** The tip commit object, parsed up-front so the UI can show it without another fetch. */ + val tipCommit: GitCommit? = null, ) { private val blobs = HashMap(blobs) private val blobMutex = Mutex() @@ -360,6 +365,31 @@ class GitRepoSnapshot( /** Entries at the repository root, folders first. */ fun rootEntries(): List = sortForDisplay(trees[rootTreeOid].orEmpty()) + /** + * Every blob (file) path reachable from the tip tree, depth-first. Folders and submodules + * are not included. Drives the home screen's language breakdown; the whole tree is already + * in memory (the snapshot is fetched with `blob:none`, so all trees came down). + */ + fun walkFileNames(): List { + val out = ArrayList() + val stack = ArrayDeque>() + stack.addLast(rootTreeOid to "") + val seen = HashSet() + while (stack.isNotEmpty()) { + val (treeOid, prefix) = stack.removeLast() + if (!seen.add(treeOid + "@" + prefix)) continue + val entries = trees[treeOid] ?: continue + for (e in entries) { + if (e.isFolder) { + stack.addLast(e.oid to "$prefix${e.name}/") + } else if (!e.isSubmodule) { + out.add("$prefix${e.name}") + } + } + } + return out + } + /** * Entries inside the directory at [path] (a list of path segments). Returns * null if the path doesn't resolve to a directory. From d270b6a69f171cdf9813ea400d9f70afedcd5bf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 15:38:37 +0000 Subject: [PATCH 16/31] feat(git): make the project-home social row interactive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the read-only zap/reaction/comment counts with the canonical ZapReaction / LikeReaction / ReplyReaction actions, so the repository home now supports one-tap zaps (with the amount dialog), reactions, and replying to the repo announcement — each with its live counter. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../loggedIn/gitRepo/GitRepositoryHomeUi.kt | 43 ++++++------------- .../loggedIn/gitRepo/GitRepositoryScreen.kt | 2 +- 2 files changed, 14 insertions(+), 31 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt index f400d32122..d891be3aa2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt @@ -35,7 +35,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -50,10 +49,11 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactionCount -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplyCount -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo +import com.vitorpamplona.amethyst.ui.note.LikeReaction +import com.vitorpamplona.amethyst.ui.note.ReplyReaction +import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -74,40 +74,23 @@ private val CardShape = RoundedCornerShape(15.dp) fun RepoSocialRow( note: AddressableNote, accountViewModel: AccountViewModel, + nav: INav, ) { - val reactionCount by observeNoteReactionCount(note, accountViewModel) - val replyCount by observeNoteReplyCount(note, accountViewModel) - val zapState by observeNoteZaps(note, accountViewModel) - val zapCount = zapState?.note?.zaps?.size ?: note.zaps.size - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SocialStat(MaterialSymbols.Bolt, zapCount, MaterialTheme.colorScheme.primary) - SocialStat(MaterialSymbols.Favorite, reactionCount, MaterialTheme.colorScheme.error) - SocialStat(MaterialSymbols.Forum, replyCount, MaterialTheme.colorScheme.onSurfaceVariant) - } -} - -@Composable -private fun SocialStat( - symbol: MaterialSymbol, - count: Int, - tint: Color, -) { + val grayTint = MaterialTheme.colorScheme.onSurfaceVariant Row( modifier = Modifier .clip(CircleShape) .background(MaterialTheme.colorScheme.surface) - .padding(horizontal = 12.dp, vertical = 6.dp), + .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(22.dp), ) { - Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(16.dp), tint = tint) - Text( - text = compactCount(count), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - ) + ZapReaction(baseNote = note, grayTint = grayTint, accountViewModel = accountViewModel, nav = nav) + LikeReaction(baseNote = note, grayTint = grayTint, accountViewModel = accountViewModel, nav = nav) + ReplyReaction(baseNote = note, grayTint = grayTint, accountViewModel = accountViewModel) { + nav.nav { routeReplyTo(note, accountViewModel.account) } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index f6d2ec0bbd..87f7c143bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -308,7 +308,7 @@ private fun GitRepositoryHome( val currentEvent = event if (currentEvent != null) { GitRepositoryOverviewSections(currentEvent, accountViewModel, nav) - RepoSocialRow(note, accountViewModel) + RepoSocialRow(note, accountViewModel, nav) } if (snapshot != null) { From b5919841183d195d25aad82af7e37cf807d3b434 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 15:45:33 +0000 Subject: [PATCH 17/31] refactor(git): unify project home into one dashboard design Removes the old boxed "Overview" section block (About/Links/Topics/ Maintainers cards) that was stacked on top of the new dashboard, which made the home read as two designs. Its useful content now lives in the dashboard language: - New RepoHero: owner avatar + "name / project" title (GitHub-style), with description and topic chips. - RepoMaintainersRow: a compact maintainer avatar cluster. - Drops the Links card (clone/web URLs) as low-value. Deletes GitRepositoryOverview.kt. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../loggedIn/gitRepo/GitRepositoryHomeUi.kt | 132 ++++++++++ .../loggedIn/gitRepo/GitRepositoryOverview.kt | 249 ------------------ .../loggedIn/gitRepo/GitRepositoryScreen.kt | 3 +- amethyst/src/main/res/values/strings.xml | 1 + 4 files changed, 135 insertions(+), 250 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt index d891be3aa2..f0a94e8929 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt @@ -35,6 +35,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -48,24 +49,155 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ReplyReaction +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip34Git.git.GitCommit import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent private val CardShape = RoundedCornerShape(15.dp) +// --------------------------------------------------------------------------- +// Hero / identity — name, description and topics in the dashboard language. +// --------------------------------------------------------------------------- + +@Composable +fun RepoHero( + event: GitRepositoryEvent, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + val owner = LocalCache.checkGetOrCreateUser(event.pubKey) + if (owner != null) { + ClickableUserPicture( + baseUser = owner, + size = 40.dp, + accountViewModel = accountViewModel, + onClick = { nav.nav(Route.Profile(it.pubkeyHex)) }, + ) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (owner != null) { + UsernameDisplay(baseUser = owner, accountViewModel = accountViewModel) + Text( + text = " / ", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + Text( + text = event.name() ?: event.dTag(), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } + if (event.isPersonalFork()) { + PillChip(stringRes(R.string.git_repo_personal_fork)) + } + } + } + + val description = event.description()?.takeIf { it.isNotBlank() } + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), + ) + } + + val topics = remember(event) { event.hashtags().filter { it.isNotBlank() } } + if (topics.isNotEmpty()) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + topics.forEach { PillChip("#$it") } + } + } + } +} + +@Composable +private fun PillChip(label: String) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.grayText, + modifier = + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) + .padding(horizontal = 10.dp, vertical = 3.dp), + ) +} + +// --------------------------------------------------------------------------- +// Maintainers — a compact avatar cluster. +// --------------------------------------------------------------------------- + +@Composable +fun RepoMaintainersRow( + event: GitRepositoryEvent, + accountViewModel: AccountViewModel, + nav: INav, +) { + val maintainers = remember(event) { listOfNotNull(event.pubKey).plus(event.maintainers()).distinct() } + if (maintainers.isEmpty()) return + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringRes(R.string.git_repo_maintained_by), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.grayText, + ) + maintainers.take(6).forEach { hex -> MaintainerAvatar(hex, accountViewModel, nav) } + if (maintainers.size > 6) { + Text( + text = "+" + (maintainers.size - 6), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + } +} + +@Composable +private fun MaintainerAvatar( + pubKeyHex: HexKey, + accountViewModel: AccountViewModel, + nav: INav, +) { + val user = LocalCache.checkGetOrCreateUser(pubKeyHex) ?: return + ClickableUserPicture( + baseUser = user, + size = 28.dp, + accountViewModel = accountViewModel, + onClick = { nav.nav(Route.Profile(it.pubkeyHex)) }, + ) +} + // --------------------------------------------------------------------------- // Social pulse — zaps, reactions and comments on the repository announcement. // --------------------------------------------------------------------------- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt deleted file mode 100644 index 4ce87c971f..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt +++ /dev/null @@ -1,249 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.ui.components.ClickableUrl -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.Route -import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture -import com.vitorpamplona.amethyst.ui.note.UsernameDisplay -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Font12SP -import com.vitorpamplona.amethyst.ui.theme.Size16dp -import com.vitorpamplona.amethyst.ui.theme.grayText -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent - -private val SectionCardShape = RoundedCornerShape(15.dp) -private val SectionSpacing = Arrangement.spacedBy(12.dp) -private val LinkSpacing = Arrangement.spacedBy(8.dp) - -/** - * The repository's facts (title, about, links, topics, maintainers) as embeddable - * column content — no scroll container or scaffold padding of its own, so the project - * home screen can lay it out above the README inside a single scroll. - */ -@Composable -fun GitRepositoryOverviewSections( - event: GitRepositoryEvent, - accountViewModel: AccountViewModel, - nav: INav, -) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = SectionSpacing, - ) { - TitleHeader(event) - - val description = event.description() - if (!description.isNullOrBlank()) { - SectionCard(title = stringRes(id = R.string.git_repo_section_about)) { - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - - val webs = event.webs() - val clones = event.clones() - if (webs.isNotEmpty() || clones.isNotEmpty()) { - SectionCard(title = stringRes(id = R.string.git_repo_section_links)) { - Column(verticalArrangement = LinkSpacing) { - webs.forEach { LinkLine(symbol = MaterialSymbols.Public, url = it) } - clones.forEach { LinkLine(symbol = MaterialSymbols.AutoMirrored.OpenInNew, url = it) } - } - } - } - - val topics = event.hashtags() - if (topics.isNotEmpty()) { - SectionCard(title = stringRes(id = R.string.git_repo_section_topics)) { - FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - topics.forEach { TopicChip(it) } - } - } - } - - val maintainers = listOfNotNull(event.pubKey).plus(event.maintainers()).distinct() - if (maintainers.isNotEmpty()) { - SectionCard(title = stringRes(id = R.string.git_repo_section_maintainers)) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - maintainers.forEach { hex -> - MaintainerRow(pubKeyHex = hex, accountViewModel = accountViewModel, nav = nav) - } - } - } - } - } -} - -@Composable -private fun TitleHeader(event: GitRepositoryEvent) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - modifier = - Modifier - .size(40.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Icon( - symbol = MaterialSymbols.Code, - contentDescription = null, - modifier = Modifier.size(22.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } - - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = event.name() ?: event.dTag(), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (event.isPersonalFork()) { - Spacer(Modifier.height(4.dp)) - TopicChip(stringRes(id = R.string.git_repo_personal_fork)) - } - } - } -} - -@Composable -private fun SectionCard( - title: String, - content: @Composable () -> Unit, -) { - Column( - modifier = - Modifier - .fillMaxWidth() - .clip(SectionCardShape) - .background(MaterialTheme.colorScheme.surface) - .padding(16.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.grayText, - fontWeight = FontWeight.SemiBold, - ) - Spacer(Modifier.height(8.dp)) - content() - } -} - -@Composable -private fun LinkLine( - symbol: MaterialSymbol, - url: String, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - symbol = symbol, - contentDescription = null, - modifier = Modifier.size(Size16dp), - tint = MaterialTheme.colorScheme.grayText, - ) - ClickableUrl( - url = url, - urlText = url.removePrefix("https://").removePrefix("http://"), - ) - } -} - -@Composable -private fun TopicChip(label: String) { - Text( - text = label, - style = MaterialTheme.typography.labelSmall.copy(fontSize = Font12SP), - color = MaterialTheme.colorScheme.grayText, - modifier = - Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)) - .padding(horizontal = 10.dp, vertical = 3.dp), - ) -} - -@Composable -private fun MaintainerRow( - pubKeyHex: HexKey, - accountViewModel: AccountViewModel, - nav: INav, -) { - val user = LocalCache.checkGetOrCreateUser(pubKeyHex) ?: return - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - ClickableUserPicture( - baseUser = user, - size = 32.dp, - accountViewModel = accountViewModel, - onClick = { nav.nav(Route.Profile(it.pubkeyHex)) }, - ) - UsernameDisplay( - baseUser = user, - accountViewModel = accountViewModel, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index 87f7c143bb..d3a17f13d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -307,7 +307,8 @@ private fun GitRepositoryHome( ) { val currentEvent = event if (currentEvent != null) { - GitRepositoryOverviewSections(currentEvent, accountViewModel, nav) + RepoHero(currentEvent, accountViewModel, nav) + RepoMaintainersRow(currentEvent, accountViewModel, nav) RepoSocialRow(note, accountViewModel, nav) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 16473b8b30..1767c3ff71 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2651,6 +2651,7 @@ Files Updated Recent activity + Maintained by Untitled About Links From 52f830dd2e9f4e8548a4f7eb6607596d23ea62a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 16:10:10 +0000 Subject: [PATCH 18/31] fix(git): make the "Mine" repo filter show the user's own repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repositories feed never special-cased TopFilter.Mine, so it inherited the shared Mine→all-follows fallback — "Mine" behaved identically to "All Follows", making both selectors look unresponsive when toggled. Mirrors the music/badges/communities/nsites pattern: - GitRepositoriesFeedFilter: when the list is Mine, match repositories authored by the logged-in user (feed + applyFilter). - GitRepositoriesSubAssembler: when the list is Mine, query the user's own repositories by author against their outbox relays (new filterGitRepositoriesMine), bypassing the follow-list machinery. "All Follows" already routed through the standard follows filter; it now visibly differs from "Mine". Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../dal/GitRepositoriesFeedFilter.kt | 30 ++++++++-- .../datasource/GitRepositoriesSubAssembler.kt | 8 +++ .../FilterGitRepositoriesMine.kt | 55 +++++++++++++++++++ 3 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesMine.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/dal/GitRepositoriesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/dal/GitRepositoriesFeedFilter.kt index abdd4b4335..0f383daee6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/dal/GitRepositoriesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/dal/GitRepositoriesFeedFilter.kt @@ -48,18 +48,36 @@ class GitRepositoriesFeedFilter( override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() override fun feed(): List { - val params = buildFilterParams(account) - val notes = - LocalCache.addressables.filterIntoSet(GitRepositoryEvent.KIND) { _, it -> - val noteEvent = it.event - noteEvent is GitRepositoryEvent && params.match(noteEvent, it.relays) + if (followList() == TopFilter.Mine) { + val me = account.userProfile().pubkeyHex + LocalCache.addressables.filterIntoSet(GitRepositoryEvent.KIND) { _, it -> isMine(it, me) } + } else { + val params = buildFilterParams(account) + LocalCache.addressables.filterIntoSet(GitRepositoryEvent.KIND) { _, it -> + val noteEvent = it.event + noteEvent is GitRepositoryEvent && params.match(noteEvent, it.relays) + } } return sort(notes) } - override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + override fun applyFilter(newItems: Set): Set { + if (followList() == TopFilter.Mine) { + val me = account.userProfile().pubkeyHex + return newItems.filterTo(HashSet()) { isMine(it, me) } + } + return innerApplyFilter(newItems) + } + + private fun isMine( + note: Note, + me: String, + ): Boolean { + val noteEvent = note.event + return noteEvent is GitRepositoryEvent && noteEvent.pubKey == me + } fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesSubAssembler.kt index 6fb5fc19c4..1c837ee555 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesSubAssembler.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies.filterGitRepositoriesMine import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription @@ -42,6 +43,13 @@ class GitRepositoriesSubAssembler( key: GitRepositoriesQueryState, since: SincePerRelayMap?, ): List { + // "Mine" bypasses the follow-list machinery: query the user's own repositories by author + // against their outbox relays (same pattern as music/badges), because the shared + // TopFilter.Mine flow falls back to all-follows. + if (key.listName() == TopFilter.Mine) { + val outbox = key.account.outboxRelays.flow.value + return filterGitRepositoriesMine(key.account.userProfile().pubkeyHex, outbox, since) + } val feedSettings = key.followsPerRelay() return makeGitRepositoriesFilter(feedSettings, since, key.feedStates.gitRepositoriesFeed.lastNoteCreatedAtIfFilled()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesMine.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesMine.kt new file mode 100644 index 0000000000..7e6396f168 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesMine.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies + +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +/** + * Builds the relay filters for the "Mine" repository selector: the user's own repository + * announcements, queried by author against their own outbox relays. Mirrors + * `filterNappletsMine` / `filterMusicEventsMine` — the only correct source for "my own" + * content, since the shared `TopFilter.Mine` flow falls back to all-follows. + */ +fun filterGitRepositoriesMine( + pubkey: HexKey, + relays: Set, + since: SincePerRelayMap?, +): List { + if (relays.isEmpty() || pubkey.isEmpty()) return emptyList() + val authors = listOf(pubkey) + return relays.map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(GitRepositoryEvent.KIND), + authors = authors, + limit = 200, + since = since?.get(relay)?.time, + ), + ) + } +} From f0100a35b3d7a248ad2c0e2f7fd2dd101aad0f17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 16:51:51 +0000 Subject: [PATCH 19/31] refactor(git): move repo identity into the top bar title The owner avatar + project name was duplicated as the home's first content line and the top-bar title. Consolidates it into the top bar: a small owner avatar (tappable to the profile) + project name. The home hero now carries only the description and topic/fork chips. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../loggedIn/gitRepo/GitRepositoryHomeUi.kt | 75 +++++++++---------- .../loggedIn/gitRepo/GitRepositoryScreen.kt | 4 +- 2 files changed, 36 insertions(+), 43 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt index f0a94e8929..38ee00f997 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt @@ -57,7 +57,6 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ReplyReaction -import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -77,49 +76,44 @@ private val CardShape = RoundedCornerShape(15.dp) // Hero / identity — name, description and topics in the dashboard language. // --------------------------------------------------------------------------- +/** The top-bar title: owner avatar + project name. */ @Composable -fun RepoHero( - event: GitRepositoryEvent, +fun RepoTitleBar( + event: GitRepositoryEvent?, + fallback: String, accountViewModel: AccountViewModel, nav: INav, ) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - val owner = LocalCache.checkGetOrCreateUser(event.pubKey) - if (owner != null) { - ClickableUserPicture( - baseUser = owner, - size = 40.dp, - accountViewModel = accountViewModel, - onClick = { nav.nav(Route.Profile(it.pubkeyHex)) }, - ) - } - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (owner != null) { - UsernameDisplay(baseUser = owner, accountViewModel = accountViewModel) - Text( - text = " / ", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.grayText, - ) - } - Text( - text = event.name() ?: event.dTag(), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - } - if (event.isPersonalFork()) { - PillChip(stringRes(R.string.git_repo_personal_fork)) - } - } + val owner = event?.pubKey?.let { LocalCache.checkGetOrCreateUser(it) } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (owner != null) { + ClickableUserPicture( + baseUser = owner, + size = 22.dp, + accountViewModel = accountViewModel, + onClick = { nav.nav(Route.Profile(it.pubkeyHex)) }, + ) } + Text( + text = event?.name() ?: fallback, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + } +} - val description = event.description()?.takeIf { it.isNotBlank() } +/** The repository's description and topic chips (identity lives in the top bar). */ +@Composable +fun RepoHero(event: GitRepositoryEvent) { + val description = event.description()?.takeIf { it.isNotBlank() } + val topics = remember(event) { event.hashtags().filter { it.isNotBlank() } } + val isFork = event.isPersonalFork() + if (description == null && topics.isEmpty() && !isFork) return + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { if (description != null) { Text( text = description, @@ -127,10 +121,9 @@ fun RepoHero( color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), ) } - - val topics = remember(event) { event.hashtags().filter { it.isNotBlank() } } - if (topics.isNotEmpty()) { + if (isFork || topics.isNotEmpty()) { FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (isFork) PillChip(stringRes(R.string.git_repo_personal_fork)) topics.forEach { PillChip("#$it") } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index d3a17f13d2..d047a14719 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -268,7 +268,7 @@ private fun GitRepositoryHome( isInvertedLayout = false, topBar = { ShorterTopAppBar( - title = { TopBarTitle(event = event, fallback = note.dTag()) }, + title = { RepoTitleBar(event = event, fallback = note.dTag(), accountViewModel = accountViewModel, nav = nav) }, navigationIcon = { Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) { IconButton(onClick = nav::popBack) { ArrowBackIcon() } @@ -307,7 +307,7 @@ private fun GitRepositoryHome( ) { val currentEvent = event if (currentEvent != null) { - RepoHero(currentEvent, accountViewModel, nav) + RepoHero(currentEvent) RepoMaintainersRow(currentEvent, accountViewModel, nav) RepoSocialRow(note, accountViewModel, nav) } From 45f36a8159551c84a36a214d4977e9b79c680265 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 16:56:24 +0000 Subject: [PATCH 20/31] refactor(git): use the standard ReactionsRow; tighten file rows - Social bar now renders the app's canonical ReactionsRow (reply, boost, like, zap, zapraiser, reaction gallery) instead of a bespoke subset, so the repository announcement behaves exactly like any other note. - Code browser file rows: replace the heavy 32dp boxed icon with a plain 20dp icon and tighten spacing/padding, reducing the oversized horizontal gap before the file name. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../loggedIn/gitRepo/GitRepositoryHomeUi.kt | 31 +++++++------------ .../loggedIn/gitRepo/code/GitCodeTab.kt | 28 ++++++----------- 2 files changed, 20 insertions(+), 39 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt index 38ee00f997..b97ebc9ef0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt @@ -53,11 +53,8 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route -import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture -import com.vitorpamplona.amethyst.ui.note.LikeReaction -import com.vitorpamplona.amethyst.ui.note.ReplyReaction -import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -201,22 +198,16 @@ fun RepoSocialRow( accountViewModel: AccountViewModel, nav: INav, ) { - val grayTint = MaterialTheme.colorScheme.onSurfaceVariant - Row( - modifier = - Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surface) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(22.dp), - ) { - ZapReaction(baseNote = note, grayTint = grayTint, accountViewModel = accountViewModel, nav = nav) - LikeReaction(baseNote = note, grayTint = grayTint, accountViewModel = accountViewModel, nav = nav) - ReplyReaction(baseNote = note, grayTint = grayTint, accountViewModel = accountViewModel) { - nav.nav { routeReplyTo(note, accountViewModel.account) } - } - } + // The standard note footer: reply, boost, like, zap (+ zapraiser and the reaction gallery), + // so the repository announcement gets the exact same interactions as any other note. + ReactionsRow( + baseNote = note, + showReactionDetail = true, + addPadding = false, + editState = null, + accountViewModel = accountViewModel, + nav = nav, + ) } // --------------------------------------------------------------------------- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt index a6dfaf6fd1..629c268833 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -209,7 +208,7 @@ private fun CodeBrowser( }, ) HorizontalDivider( - modifier = Modifier.padding(start = 56.dp), + modifier = Modifier.padding(start = 44.dp), thickness = 0.5.dp, color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), ) @@ -359,9 +358,9 @@ private fun EntryRow( Modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 12.dp, vertical = 12.dp), + .padding(horizontal = 14.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { val symbol = when { @@ -370,21 +369,12 @@ private fun EntryRow( else -> MaterialSymbols.Description } val tint = if (entry.isFolder) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - Box( - modifier = - Modifier - .size(32.dp) - .clip(RoundedCornerShape(8.dp)) - .background(tint.copy(alpha = 0.1f)), - contentAlignment = Alignment.Center, - ) { - Icon( - symbol = symbol, - contentDescription = null, - modifier = Modifier.size(19.dp), - tint = tint, - ) - } + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = tint, + ) Text( text = entry.name, style = MaterialTheme.typography.bodyMedium, From 09633abae5bdfa078ab50221b292a78a3473bcde Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 17:45:18 +0000 Subject: [PATCH 21/31] feat(git): home polish + repo-card dashboard in the feed Home screen: - Nav cards: tighter vertical spacing; badges now count only OPEN issues/PRs, derived from the live GitStatusIndex (started on the home so the split is correct without visiting the Issues screen first). - Moved the reaction row to after the recent-activity pulse. - Added the standard 3-dot note menu (MoreOptionsButton) to the top bar. Feed card (RenderGitRepositoryEvent): - Replaced the web/clone links with the same stat tiles + language bar + last-commit strip used on the home, loaded from a lazily-fetched shallow snapshot. (Factory made internal so the card can build the browser VM.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../amethyst/ui/note/types/Git.kt | 70 +++++++++++++------ .../loggedIn/gitRepo/GitRepositoryScreen.kt | 29 ++++++-- 2 files changed, 73 insertions(+), 26 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index fb5b0d986e..6d640d3c89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -52,12 +52,15 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.commons.nip34Git.GitBrowseState +import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.GitPullRequestUpdateIndex import com.vitorpamplona.amethyst.model.Note @@ -71,6 +74,11 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryBrowserViewModelFactory +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoLanguageBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoLastCommit +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoStatTiles +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.computeLanguageBreakdown import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer @@ -785,8 +793,6 @@ private fun RenderGitRepositoryEvent( ) { val title = noteEvent.name() ?: noteEvent.dTag() val summary = noteEvent.description() - val web = noteEvent.web() - val clone = noteEvent.clone() val topics = remember(noteEvent) { noteEvent.hashtags().filter { it.isNotBlank() } } val isPersonalFork = remember(noteEvent) { noteEvent.isPersonalFork() } @@ -840,25 +846,7 @@ private fun RenderGitRepositoryEvent( ) } - if (web != null || clone != null) { - Spacer(modifier = HalfDoubleVertSpacer) - Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) { - web?.let { - LinkRow( - symbol = MaterialSymbols.Public, - contentDescription = stringRes(id = R.string.git_web_address), - url = it, - ) - } - clone?.let { - LinkRow( - symbol = MaterialSymbols.AutoMirrored.OpenInNew, - contentDescription = stringRes(id = R.string.git_clone_address), - url = it, - ) - } - } - } + RepoSnapshotDashboard(noteEvent, note, accountViewModel) if (topics.isNotEmpty()) { Spacer(modifier = HalfDoubleVertSpacer) @@ -877,3 +865,43 @@ private fun RenderGitRepositoryEvent( } } } + +/** + * Loads a shallow snapshot of the repository over smart-HTTP and renders the same + * stat tiles / language bar / last-commit strip used on the project home, in place of + * the old web/clone links. Fetches lazily (once) when the card is composed. + */ +@Composable +private fun RepoSnapshotDashboard( + noteEvent: GitRepositoryEvent, + note: Note, + accountViewModel: AccountViewModel, +) { + val browser: GitRepositoryBrowserViewModel = + viewModel( + key = note.idHex + "GitRepoCardBrowser", + factory = GitRepositoryBrowserViewModelFactory(accountViewModel.httpClientBuilder::okHttpClientForPreview), + ) + LaunchedEffect(noteEvent) { browser.loadOnce(noteEvent.clones()) } + val browserState by browser.state.collectAsStateWithLifecycle() + val snapshot = (browserState as? GitBrowseState.Loaded)?.snapshot ?: return + + val fileNames = remember(snapshot) { snapshot.walkFileNames() } + val slices = remember(fileNames) { computeLanguageBreakdown(fileNames) } + + Spacer(modifier = HalfDoubleVertSpacer) + RepoStatTiles( + branches = snapshot.branches.size, + tags = snapshot.tags.size, + files = fileNames.size, + updatedEpochSec = snapshot.tipCommit?.authorTimeSec, + ) + if (slices.isNotEmpty()) { + Spacer(modifier = HalfDoubleVertSpacer) + RepoLanguageBar(slices) + } + snapshot.tipCommit?.let { + Spacer(modifier = HalfDoubleVertSpacer) + RepoLastCommit(it) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index d047a14719..52d6cf36be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.GitStatusIndex import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel @@ -82,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -163,7 +165,7 @@ fun GitRepositoryPullsScreen( * Builds the [GitRepositoryBrowserViewModel]. The factory lives app-side because the KMP * lifecycle artifact used in commons doesn't expose the `create(Class)` override. */ -private class GitRepositoryBrowserViewModelFactory( +internal class GitRepositoryBrowserViewModelFactory( private val okHttpClient: (String) -> OkHttpClient, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T = GitRepositoryBrowserViewModel(okHttpClient) as T @@ -258,6 +260,20 @@ private fun GitRepositoryHome( .take(6) } + // Nav-card badges count only the OPEN issues/PRs. The open/closed split needs the status + // index (kinds 1630-1633), which is started here so the home reflects it without visiting + // the Issues screen first; the count is then derived directly from the live index. + LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() } + val statusMap by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle() + val openIssueCount = + remember(openIssueItems, closedIssueItems, statusMap) { + (openIssueItems + closedIssueItems).distinctBy { it.idHex }.count { !GitStatusIndex.isClosedOrResolved(it.idHex, statusMap) } + } + val openPullCount = + remember(openPatchItems, closedPatchItems, statusMap) { + (openPatchItems + closedPatchItems).distinctBy { it.idHex }.count { !GitStatusIndex.isClosedOrResolved(it.idHex, statusMap) } + } + var showSettings by rememberSaveable(note.idHex) { mutableStateOf(false) } val currentEventForSettings = event if (showSettings && currentEventForSettings != null) { @@ -290,6 +306,9 @@ private fun GitRepositoryHome( Icon(MaterialSymbols.Edit, contentDescription = stringRes(R.string.git_repo_settings_title)) } } + Row(Modifier.padding(end = 6.dp), verticalAlignment = Alignment.CenterVertically) { + MoreOptionsButton(note, accountViewModel = accountViewModel, nav = nav) + } }, ) }, @@ -309,7 +328,6 @@ private fun GitRepositoryHome( if (currentEvent != null) { RepoHero(currentEvent) RepoMaintainersRow(currentEvent, accountViewModel, nav) - RepoSocialRow(note, accountViewModel, nav) } if (snapshot != null) { @@ -325,11 +343,12 @@ private fun GitRepositoryHome( snapshot.tipCommit?.let { RepoLastCommit(it) } } - RepoNavCards(note, openIssueItems.size, openPatchItems.size, nav) + RepoNavCards(note, openIssueCount, openPullCount, nav) RepoActivityPulse(activity, accountViewModel, nav) if (currentEvent != null) { + RepoSocialRow(note, accountViewModel, nav) GitReadmeSection(browserState, browserViewModel, currentEvent, accountViewModel, nav) } else { EmptyMessage(stringRes(R.string.loading_feed)) @@ -462,7 +481,7 @@ private fun RepoNavCards( ) { Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { RepoNavCard(MaterialSymbols.Code, stringRes(R.string.git_repo_tab_code), null) { nav.nav(Route.GitRepositoryCode(note.address)) @@ -490,7 +509,7 @@ private fun RepoNavCard( .clip(RoundedCornerShape(15.dp)) .background(MaterialTheme.colorScheme.surface) .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 16.dp), + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), ) { From 29303b18c2d92f1075fa96d0420711043121367c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 17:50:03 +0000 Subject: [PATCH 22/31] feat(git): make recent-activity rows and the last-commit line clickable - Recent-activity rows navigate to the issue/PR they represent (routeFor). - The last-commit strip is now tappable: on the home it opens the Code screen; on the feed repo card it opens the repository. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../vitorpamplona/amethyst/ui/note/types/Git.kt | 7 ++++--- .../screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt | 14 ++++++++++++-- .../screen/loggedIn/gitRepo/GitRepositoryScreen.kt | 4 +++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index 6d640d3c89..4c5ec4d566 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -846,7 +846,7 @@ private fun RenderGitRepositoryEvent( ) } - RepoSnapshotDashboard(noteEvent, note, accountViewModel) + RepoSnapshotDashboard(noteEvent, note, accountViewModel, nav) if (topics.isNotEmpty()) { Spacer(modifier = HalfDoubleVertSpacer) @@ -876,6 +876,7 @@ private fun RepoSnapshotDashboard( noteEvent: GitRepositoryEvent, note: Note, accountViewModel: AccountViewModel, + nav: INav, ) { val browser: GitRepositoryBrowserViewModel = viewModel( @@ -900,8 +901,8 @@ private fun RepoSnapshotDashboard( Spacer(modifier = HalfDoubleVertSpacer) RepoLanguageBar(slices) } - snapshot.tipCommit?.let { + snapshot.tipCommit?.let { commit -> Spacer(modifier = HalfDoubleVertSpacer) - RepoLastCommit(it) + RepoLastCommit(commit) { nav.nav(Route.GitRepository(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt index b97ebc9ef0..dbf0bb6ec1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -53,6 +54,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo @@ -382,13 +384,17 @@ fun computeLanguageBreakdown(files: List): List { // --------------------------------------------------------------------------- @Composable -fun RepoLastCommit(commit: GitCommit) { +fun RepoLastCommit( + commit: GitCommit, + onClick: (() -> Unit)? = null, +) { Row( modifier = Modifier .fillMaxWidth() .clip(CardShape) .background(MaterialTheme.colorScheme.surface) + .let { if (onClick != null) it.clickable(onClick = onClick) else it } .padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -457,7 +463,11 @@ private fun ActivityRow( ) { val event = note.event ?: return Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav { routeFor(note, accountViewModel.account) } } + .padding(horizontal = 14.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index 52d6cf36be..b1a60a937e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -340,7 +340,9 @@ private fun GitRepositoryHome( if (languageSlices.isNotEmpty()) { RepoLanguageBar(languageSlices) } - snapshot.tipCommit?.let { RepoLastCommit(it) } + snapshot.tipCommit?.let { commit -> + RepoLastCommit(commit) { nav.nav(Route.GitRepositoryCode(note.address)) } + } } RepoNavCards(note, openIssueCount, openPullCount, nav) From a4bd541522e4bb188705180d735503728334c729 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 20:50:07 +0000 Subject: [PATCH 23/31] feat(git): snapshot cache, FAB, disappearing-bar fix, title subtitle, spacing - Snapshot cache: a process-wide GitRepoSnapshotCache keyed by repo address. The browser ViewModel serves an already-fetched default-branch snapshot synchronously, so the stats render in share-to-image and don't re-fetch when switching screens. - Issues/PR screens: filter chips now live inside the disappearing top bar (via the scaffold's belowBar slot) so they hide with it instead of leaving a static black band; the feed uses normal content padding. - New issue is now an extended FAB on the Issues screen. - Top bar shows the repo description as a single-line subtitle under the name; removed the duplicate description from the home body. - Tighter spacing: home sections, code header rows (branch row / search / breadcrumb). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../amethyst/ui/note/types/Git.kt | 9 +- .../loggedIn/gitRepo/GitRepositoryHomeUi.kt | 53 ++-- .../loggedIn/gitRepo/GitRepositoryScreen.kt | 237 +++++++++--------- .../loggedIn/gitRepo/code/GitBrowseUi.kt | 2 +- .../loggedIn/gitRepo/code/GitCodeTab.kt | 4 +- .../commons/nip34Git/GitRepoSnapshotCache.kt | 47 ++++ .../nip34Git/GitRepositoryBrowserViewModel.kt | 19 +- 7 files changed, 216 insertions(+), 155 deletions(-) create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepoSnapshotCache.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index 4c5ec4d566..94f7d59d02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.commons.nip34Git.GitBrowseState +import com.vitorpamplona.amethyst.commons.nip34Git.GitRepoSnapshotCache import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.GitPullRequestUpdateIndex @@ -878,14 +879,18 @@ private fun RepoSnapshotDashboard( accountViewModel: AccountViewModel, nav: INav, ) { + val cacheKey = remember(noteEvent) { noteEvent.address().toValue() } val browser: GitRepositoryBrowserViewModel = viewModel( key = note.idHex + "GitRepoCardBrowser", factory = GitRepositoryBrowserViewModelFactory(accountViewModel.httpClientBuilder::okHttpClientForPreview), ) - LaunchedEffect(noteEvent) { browser.loadOnce(noteEvent.clones()) } + LaunchedEffect(noteEvent) { browser.loadOnce(noteEvent.clones(), cacheKey) } val browserState by browser.state.collectAsStateWithLifecycle() - val snapshot = (browserState as? GitBrowseState.Loaded)?.snapshot ?: return + // Prefer the live state, but fall back to a cached snapshot so an already-fetched repo + // renders synchronously — including in the share-to-image capture, which won't wait for a + // fresh clone. + val snapshot = (browserState as? GitBrowseState.Loaded)?.snapshot ?: remember(cacheKey) { GitRepoSnapshotCache.get(cacheKey) } ?: return val fileNames = remember(snapshot) { snapshot.walkFileNames() } val slices = remember(fileNames) { computeLanguageBreakdown(fileNames) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt index dbf0bb6ec1..0dfc0fc900 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt @@ -75,7 +75,7 @@ private val CardShape = RoundedCornerShape(15.dp) // Hero / identity — name, description and topics in the dashboard language. // --------------------------------------------------------------------------- -/** The top-bar title: owner avatar + project name. */ +/** The top-bar title: owner avatar + project name, with the repo description as a subtitle. */ @Composable fun RepoTitleBar( event: GitRepositoryEvent?, @@ -84,48 +84,47 @@ fun RepoTitleBar( nav: INav, ) { val owner = event?.pubKey?.let { LocalCache.checkGetOrCreateUser(it) } + val description = event?.description()?.takeIf { it.isNotBlank() } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (owner != null) { ClickableUserPicture( baseUser = owner, - size = 22.dp, + size = 28.dp, accountViewModel = accountViewModel, onClick = { nav.nav(Route.Profile(it.pubkeyHex)) }, ) } - Text( - text = event?.name() ?: fallback, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) + Column(Modifier.weight(1f, fill = false)) { + Text( + text = event?.name() ?: fallback, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } } -/** The repository's description and topic chips (identity lives in the top bar). */ +/** The repository's topic chips and personal-fork badge (name + description live in the top bar). */ @Composable fun RepoHero(event: GitRepositoryEvent) { - val description = event.description()?.takeIf { it.isNotBlank() } val topics = remember(event) { event.hashtags().filter { it.isNotBlank() } } val isFork = event.isPersonalFork() - if (description == null && topics.isEmpty() && !isFork) return + if (topics.isEmpty() && !isFork) return - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - if (description != null) { - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f), - ) - } - if (isFork || topics.isNotEmpty()) { - FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - if (isFork) PillChip(stringRes(R.string.git_repo_personal_fork)) - topics.forEach { PillChip("#$it") } - } - } + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (isFork) PillChip(stringRes(R.string.git_repo_personal_fork)) + topics.forEach { PillChip("#$it") } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index b1a60a937e..2853a810d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -26,11 +26,7 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -39,13 +35,12 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -55,7 +50,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -68,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.nip34Git.GitBrowseState +import com.vitorpamplona.amethyst.commons.nip34Git.GitRepoSnapshotCache import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding @@ -210,10 +205,11 @@ private fun GitRepositoryHome( ) { val browserViewModel = rememberRepoBrowser(note, accountViewModel) val event by observeNoteEvent(note, accountViewModel) + val cacheKey = remember(note) { note.address.toValue() } // Start the smart-HTTP browser as soon as the announcement arrives, so the README renders. LaunchedEffect(event) { - event?.let { browserViewModel.loadOnce(it.clones()) } + event?.let { browserViewModel.loadOnce(it.clones(), cacheKey) } } val browserState by browserViewModel.state.collectAsStateWithLifecycle() @@ -250,7 +246,7 @@ private fun GitRepositoryHome( val openPatchItems = rememberGitFeedItems(openPatches) val closedPatchItems = rememberGitFeedItems(closedPatches) - val snapshot = (browserState as? GitBrowseState.Loaded)?.snapshot + val snapshot = (browserState as? GitBrowseState.Loaded)?.snapshot ?: remember(cacheKey) { GitRepoSnapshotCache.get(cacheKey) } val fileNames = remember(snapshot) { snapshot?.walkFileNames() ?: emptyList() } val languageSlices = remember(fileNames) { computeLanguageBreakdown(fileNames) } val activity = @@ -322,7 +318,7 @@ private fun GitRepositoryHome( .verticalScroll(rememberScrollState()) .padding(scaffoldPadding) .padding(horizontal = 12.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { val currentEvent = event if (currentEvent != null) { @@ -367,8 +363,9 @@ private fun GitRepositoryCode( ) { val browserViewModel = rememberRepoBrowser(note, accountViewModel) val event by observeNoteEvent(note, accountViewModel) + val cacheKey = remember(note) { note.address.toValue() } LaunchedEffect(event) { - event?.let { browserViewModel.loadOnce(it.clones()) } + event?.let { browserViewModel.loadOnce(it.clones(), cacheKey) } } val browserState by browserViewModel.state.collectAsStateWithLifecycle() RepoContentSubscription(note, event, accountViewModel) @@ -400,15 +397,21 @@ private fun GitRepositoryIssues( val event by observeNoteEvent(note, accountViewModel) RepoContentSubscription(note, event, accountViewModel) - GitRepoSubScreenScaffold(event, note.dTag(), accountViewModel, nav) { - GitIssuesTab( - note = note, - event = event, - openViewModel = openViewModel, - closedViewModel = closedViewModel, - accountViewModel = accountViewModel, - nav = nav, - ) + var showNewIssue by rememberSaveable(note.idHex) { mutableStateOf(false) } + + StatusFeedScreen( + persistKey = note.idHex + "GitRepoIssuesStatus", + event = event, + fallbackTitle = note.dTag(), + openViewModel = openViewModel, + closedViewModel = closedViewModel, + accountViewModel = accountViewModel, + nav = nav, + floatingButton = if (event != null) ({ NewIssueFab { showNewIssue = true } }) else null, + ) + + if (showNewIssue && event != null) { + GitNewIssueDialog(repoNote = note, accountViewModel = accountViewModel, onDismiss = { showNewIssue = false }) } } @@ -434,18 +437,23 @@ private fun GitRepositoryPulls( val event by observeNoteEvent(note, accountViewModel) RepoContentSubscription(note, event, accountViewModel) - GitRepoSubScreenScaffold(event, note.dTag(), accountViewModel, nav) { - StatusSplitFeed( - persistKey = note.idHex + "GitRepoPatchesStatus", - openViewModel = openViewModel, - closedViewModel = closedViewModel, - accountViewModel = accountViewModel, - nav = nav, - ) - } + StatusFeedScreen( + persistKey = note.idHex + "GitRepoPatchesStatus", + event = event, + fallbackTitle = note.dTag(), + openViewModel = openViewModel, + closedViewModel = closedViewModel, + accountViewModel = accountViewModel, + nav = nav, + ) } -/** Shared scaffold for the Code / Issues / Pull Requests drill-in screens: a back arrow and the repo title. */ +/** + * Shared scaffold for the Code / Issues / Pull Requests drill-in screens: a back arrow and the + * repo title. [belowBar] (e.g. status-filter chips) is rendered inside the disappearing top bar + * so it hides with it instead of leaving a static band, and [floatingButton] feeds the scaffold's + * FAB slot. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun GitRepoSubScreenScaffold( @@ -453,20 +461,26 @@ private fun GitRepoSubScreenScaffold( fallbackTitle: String, accountViewModel: AccountViewModel, nav: INav, + belowBar: (@Composable () -> Unit)? = null, + floatingButton: (@Composable () -> Unit)? = null, content: @Composable () -> Unit, ) { DisappearingScaffold( isInvertedLayout = false, topBar = { - ShorterTopAppBar( - title = { TopBarTitle(event = event, fallback = fallbackTitle) }, - navigationIcon = { - Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = nav::popBack) { ArrowBackIcon() } - } - }, - ) + Column { + ShorterTopAppBar( + title = { TopBarTitle(event = event, fallback = fallbackTitle) }, + navigationIcon = { + Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = nav::popBack) { ArrowBackIcon() } + } + }, + ) + belowBar?.invoke() + } }, + floatingButton = floatingButton, accountViewModel = accountViewModel, ) { content() @@ -555,60 +569,21 @@ private fun RepoNavCard( } /** - * The Issues tab: the open/closed status feed plus a "New issue" composer reachable - * from the filter row once the repository announcement has loaded. + * A drill-in screen showing an Open / Closed & Resolved status feed. The status + label + * filter chips live inside the disappearing top bar (so they hide with it and the feed scrolls + * cleanly under them), while the feed body uses the scaffold's normal content padding. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun GitIssuesTab( - note: AddressableNote, - event: GitRepositoryEvent?, - openViewModel: RepositoryIssuesFeedViewModel, - closedViewModel: RepositoryIssuesFeedViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - var showNewIssue by rememberSaveable(note.idHex) { mutableStateOf(false) } - - StatusSplitFeed( - persistKey = note.idHex + "GitRepoIssuesStatus", - openViewModel = openViewModel, - closedViewModel = closedViewModel, - accountViewModel = accountViewModel, - nav = nav, - headerAction = if (event != null) ({ NewIssueButton { showNewIssue = true } }) else null, - ) - - if (showNewIssue && event != null) { - GitNewIssueDialog(repoNote = note, accountViewModel = accountViewModel, onDismiss = { showNewIssue = false }) - } -} - -@Composable -private fun NewIssueButton(onClick: () -> Unit) { - FilledTonalButton( - onClick = onClick, - contentPadding = PaddingValues(horizontal = 14.dp, vertical = 6.dp), - ) { - Icon(MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.git_new_issue_button), modifier = Modifier.padding(start = 6.dp)) - } -} - -/** - * Wraps a feed in an Open / Closed & Resolved status filter, swapping between two - * status-scoped feed view models. Each view model already filters by NIP-34 status, so the - * selector only chooses which one is rendered. The selection survives configuration changes - * via [persistKey]. An optional [headerAction] (e.g. a "New issue" button) is shown at the - * trailing edge of the filter row. - */ -@Composable -private fun StatusSplitFeed( +private fun StatusFeedScreen( persistKey: String, + event: GitRepositoryEvent?, + fallbackTitle: String, openViewModel: FeedViewModel, closedViewModel: FeedViewModel, accountViewModel: AccountViewModel, nav: INav, - headerAction: (@Composable () -> Unit)? = null, + floatingButton: (@Composable () -> Unit)? = null, ) { var showClosed by rememberSaveable(persistKey) { mutableStateOf(false) } var selectedLabel by rememberSaveable(persistKey) { mutableStateOf(null) } @@ -627,23 +602,48 @@ private fun StatusSplitFeed( if (selectedLabel != null && selectedLabel !in labels) selectedLabel = null } - // The filter header is drawn statically below the disappearing top bar, so it must - // consume the scaffold's top inset itself. The inner feed then renders with the top - // inset zeroed — otherwise its LazyColumn re-applies the full bar height as content - // padding on top of the header, leaving the empty band reported above the items. - val scaffoldPadding = LocalDisappearingScaffoldPadding.current - val layoutDirection = LocalLayoutDirection.current - val feedPadding = - remember(scaffoldPadding, layoutDirection) { - PaddingValues( - start = scaffoldPadding.calculateStartPadding(layoutDirection), - top = 0.dp, - end = scaffoldPadding.calculateEndPadding(layoutDirection), - bottom = scaffoldPadding.calculateBottomPadding(), + GitRepoSubScreenScaffold( + event = event, + fallbackTitle = fallbackTitle, + accountViewModel = accountViewModel, + nav = nav, + belowBar = { + StatusFilterChips( + showClosed = showClosed, + onShowClosed = { showClosed = it }, + openCount = openItems.size, + closedCount = closedItems.size, + selectedLabel = selectedLabel, + onSelectLabel = { selectedLabel = it }, + labels = labels, ) - } + }, + floatingButton = floatingButton, + ) { + RefresheableFeedView( + viewModel = if (showClosed) closedViewModel else openViewModel, + routeForLastRead = null, + accountViewModel = accountViewModel, + nav = nav, + onLoaded = { loaded, listState -> + GitItemFeedLoaded(loaded, listState, accountViewModel, nav, labelFilter = selectedLabel) + }, + ) + } +} - Column(Modifier.fillMaxSize().padding(top = scaffoldPadding.calculateTopPadding())) { +/** The Open/Closed status chips plus the optional label-filter row, shown inside the top bar. */ +@Composable +private fun StatusFilterChips( + showClosed: Boolean, + onShowClosed: (Boolean) -> Unit, + openCount: Int, + closedCount: Int, + selectedLabel: String?, + onSelectLabel: (String?) -> Unit, + labels: List, +) { + Column(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.background)) { Row( modifier = Modifier @@ -654,8 +654,8 @@ private fun StatusSplitFeed( ) { FilterChip( selected = !showClosed, - onClick = { showClosed = false }, - label = { Text(countedLabel(stringRes(R.string.git_repo_filter_open), openItems.size)) }, + onClick = { onShowClosed(false) }, + label = { Text(countedLabel(stringRes(R.string.git_repo_filter_open), openCount)) }, leadingIcon = if (!showClosed) { { Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp)) } @@ -665,8 +665,8 @@ private fun StatusSplitFeed( ) FilterChip( selected = showClosed, - onClick = { showClosed = true }, - label = { Text(countedLabel(stringRes(R.string.git_repo_filter_closed), closedItems.size)) }, + onClick = { onShowClosed(true) }, + label = { Text(countedLabel(stringRes(R.string.git_repo_filter_closed), closedCount)) }, leadingIcon = if (showClosed) { { Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp)) } @@ -674,10 +674,6 @@ private fun StatusSplitFeed( null }, ) - if (headerAction != null) { - Spacer(Modifier.weight(1f)) - headerAction() - } } if (labels.isNotEmpty()) { @@ -692,33 +688,30 @@ private fun StatusSplitFeed( ) { FilterChip( selected = selectedLabel == null, - onClick = { selectedLabel = null }, + onClick = { onSelectLabel(null) }, label = { Text(stringRes(R.string.git_repo_label_all)) }, ) labels.forEach { label -> FilterChip( selected = selectedLabel == label, - onClick = { selectedLabel = if (selectedLabel == label) null else label }, + onClick = { onSelectLabel(if (selectedLabel == label) null else label) }, label = { Text("#$label") }, ) } } } - - CompositionLocalProvider(LocalDisappearingScaffoldPadding provides feedPadding) { - RefresheableFeedView( - viewModel = if (showClosed) closedViewModel else openViewModel, - routeForLastRead = null, - accountViewModel = accountViewModel, - nav = nav, - onLoaded = { loaded, listState -> - GitItemFeedLoaded(loaded, listState, accountViewModel, nav, labelFilter = selectedLabel) - }, - ) - } } } +@Composable +private fun NewIssueFab(onClick: () -> Unit) { + ExtendedFloatingActionButton( + onClick = onClick, + icon = { Icon(MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(20.dp)) }, + text = { Text(stringRes(R.string.git_new_issue_button)) }, + ) +} + /** Appends a count to a chip label, e.g. "Open · 3". Hidden while the feed is still empty/loading. */ private fun countedLabel( base: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt index 7bdc521e6a..ae03a381ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt @@ -145,7 +145,7 @@ fun RepoInfoBar( onHistory: (() -> Unit)? = null, ) { Row( - modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 2.dp, bottom = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { Row( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt index 629c268833..543be49e94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt @@ -246,7 +246,7 @@ private fun FileSearchField( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 10.dp, vertical = 4.dp) + .padding(horizontal = 10.dp, vertical = 2.dp) .clip(RoundedCornerShape(10.dp)), ) } @@ -302,7 +302,7 @@ private fun Breadcrumb( Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()) - .padding(horizontal = 10.dp, vertical = 6.dp), + .padding(horizontal = 10.dp, vertical = 3.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), ) { diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepoSnapshotCache.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepoSnapshotCache.kt new file mode 100644 index 0000000000..ab085f73d7 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepoSnapshotCache.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.nip34Git + +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot + +/** + * Process-wide cache of fetched default-branch repository snapshots, keyed by repository + * address (`kind:pubkey:dTag`). + * + * A snapshot is a shallow clone fetched over the network, so re-cloning on every screen — or + * during the share-to-image capture, which only waits ~1s for content to settle — produces + * empty stats. Caching the default-branch snapshot lets the project home, the feed repo card + * and the image renderer reuse it synchronously and avoids the repeated fetch the user sees + * when switching screens. Bounded so a few large repos can't grow memory without limit. + */ +object GitRepoSnapshotCache { + private val cache = LruCache(8) + + fun get(key: String?): GitRepoSnapshot? = key?.let { cache.get(it) } + + fun put( + key: String?, + snapshot: GitRepoSnapshot, + ) { + if (key != null) cache.put(key, snapshot) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepositoryBrowserViewModel.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepositoryBrowserViewModel.kt index 3a43119a5e..09312909d8 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepositoryBrowserViewModel.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepositoryBrowserViewModel.kt @@ -62,6 +62,7 @@ class GitRepositoryBrowserViewModel( private var cloneUrls: List = emptyList() private var started = false + private var cacheKey: String? = null /** The branch/tag currently displayed (null = the server's default branch). */ var currentRef: String? = null @@ -70,11 +71,25 @@ class GitRepositoryBrowserViewModel( /** * Loads the repository once, using the announcement's clone URLs. Safe to call * on every recomposition; only the first call (after the event has arrived) runs. + * + * When [cacheKey] (the repository address) has an already-fetched default-branch snapshot + * in [GitRepoSnapshotCache], it is served synchronously and no new clone is performed — so + * revisiting a repo across screens, and the share-to-image renderer, reuse it instantly. */ - fun loadOnce(cloneUrls: List) { + fun loadOnce( + cloneUrls: List, + cacheKey: String? = null, + ) { if (started) return started = true this.cloneUrls = cloneUrls + this.cacheKey = cacheKey + + val cached = GitRepoSnapshotCache.get(cacheKey) + if (cached != null) { + _state.value = GitBrowseState.Loaded(cached) + return + } reload() } @@ -97,6 +112,8 @@ class GitRepositoryBrowserViewModel( for (url in candidates) { try { val snapshot = client.open(url, currentRef) + // Only the default branch is cached; named refs are transient selections. + if (currentRef == null) GitRepoSnapshotCache.put(cacheKey, snapshot) _state.value = GitBrowseState.Loaded(snapshot) return@launch } catch (e: CancellationException) { From 3e0c688b8c64a1404046f06171b4c0c7c458c50b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 21:06:24 +0000 Subject: [PATCH 24/31] feat(git): htree open-in-browser fallback; remove maintainers; tighten gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Repos that can't be cloned over http(s) (e.g. Iris's htree://) now show a "Hosted externally" notice with an open-in-browser link instead of an empty dashboard — on the home, the Code screen, and the feed repo card. - Removed the "Maintained by" row from the project home. - Tighter home section spacing (12 → 8dp) and a smaller bottom padding on the feed repo card so the last-commit line sits closer to the reaction row. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../amethyst/ui/note/types/Git.kt | 15 ++- .../loggedIn/gitRepo/GitRepositoryHomeUi.kt | 92 +++++++++---------- .../loggedIn/gitRepo/GitRepositoryScreen.kt | 21 ++++- amethyst/src/main/res/values/strings.xml | 3 + 4 files changed, 78 insertions(+), 53 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index 94f7d59d02..e660a4131c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -76,10 +76,12 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryBrowserViewModelFactory +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoExternalNotice import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoLanguageBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoLastCommit import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoStatTiles import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.computeLanguageBreakdown +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.repoHasFetchableClone import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer @@ -102,7 +104,7 @@ import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent private val CardShape = QuoteBorder private val ChipShape = RoundedCornerShape(8.dp) -private val CardPadding = PaddingValues(Size10dp) +private val CardPadding = PaddingValues(start = Size10dp, top = Size10dp, end = Size10dp, bottom = Size5dp) private val HeaderSpacing = Arrangement.spacedBy(Size8dp) private val LinkRowSpacing = Arrangement.spacedBy(Size8dp) @@ -890,7 +892,16 @@ private fun RepoSnapshotDashboard( // Prefer the live state, but fall back to a cached snapshot so an already-fetched repo // renders synchronously — including in the share-to-image capture, which won't wait for a // fresh clone. - val snapshot = (browserState as? GitBrowseState.Loaded)?.snapshot ?: remember(cacheKey) { GitRepoSnapshotCache.get(cacheKey) } ?: return + val snapshot = (browserState as? GitBrowseState.Loaded)?.snapshot ?: remember(cacheKey) { GitRepoSnapshotCache.get(cacheKey) } + if (snapshot == null) { + // Repos we can't clone over http(s) (e.g. Iris's htree://) get an open-in-browser notice + // instead of an indefinitely-empty dashboard. + if (!repoHasFetchableClone(noteEvent)) { + Spacer(modifier = HalfDoubleVertSpacer) + RepoExternalNotice(noteEvent) + } + return + } val fileNames = remember(snapshot) { snapshot.walkFileNames() } val slices = remember(fileNames) { computeLanguageBreakdown(fileNames) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt index 0dfc0fc900..62d2fccb8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt @@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -62,7 +63,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip34Git.git.GitCommit import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent @@ -142,53 +142,6 @@ private fun PillChip(label: String) { ) } -// --------------------------------------------------------------------------- -// Maintainers — a compact avatar cluster. -// --------------------------------------------------------------------------- - -@Composable -fun RepoMaintainersRow( - event: GitRepositoryEvent, - accountViewModel: AccountViewModel, - nav: INav, -) { - val maintainers = remember(event) { listOfNotNull(event.pubKey).plus(event.maintainers()).distinct() } - if (maintainers.isEmpty()) return - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringRes(R.string.git_repo_maintained_by), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.grayText, - ) - maintainers.take(6).forEach { hex -> MaintainerAvatar(hex, accountViewModel, nav) } - if (maintainers.size > 6) { - Text( - text = "+" + (maintainers.size - 6), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.grayText, - ) - } - } -} - -@Composable -private fun MaintainerAvatar( - pubKeyHex: HexKey, - accountViewModel: AccountViewModel, - nav: INav, -) { - val user = LocalCache.checkGetOrCreateUser(pubKeyHex) ?: return - ClickableUserPicture( - baseUser = user, - size = 28.dp, - accountViewModel = accountViewModel, - onClick = { nav.nav(Route.Profile(it.pubkeyHex)) }, - ) -} - // --------------------------------------------------------------------------- // Social pulse — zaps, reactions and comments on the repository announcement. // --------------------------------------------------------------------------- @@ -378,6 +331,49 @@ fun computeLanguageBreakdown(files: List): List { return slices } +// --------------------------------------------------------------------------- +// External-host notice — for repos we can't clone over http(s) (htree://, nostr://, …). +// --------------------------------------------------------------------------- + +/** True when the repo can be cloned over http(s), i.e. the smart-HTTP browser can fetch it. */ +fun repoHasFetchableClone(event: GitRepositoryEvent): Boolean = event.clones().any { it.startsWith("http://") || it.startsWith("https://") } + +/** + * Shown instead of the snapshot dashboard for repos whose only clone URLs use a transport the + * smart-HTTP browser can't fetch (e.g. Iris's `htree://`). Offers the web URL as an + * open-in-browser fallback when one is present. + */ +@Composable +fun RepoExternalNotice(event: GitRepositoryEvent) { + val web = remember(event) { event.webs().firstOrNull { it.startsWith("http://") || it.startsWith("https://") } } + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(CardShape) + .background(MaterialTheme.colorScheme.surface) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon(MaterialSymbols.Public, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary) + Text( + text = stringRes(R.string.git_repo_external_host_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + } + Text( + text = stringRes(R.string.git_repo_external_host_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + if (web != null) { + ClickableUrl(url = web, urlText = stringRes(R.string.git_repo_open_in_browser)) + } + } +} + // --------------------------------------------------------------------------- // Last-commit strip. // --------------------------------------------------------------------------- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index 2853a810d0..9644a74a2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -318,12 +318,11 @@ private fun GitRepositoryHome( .verticalScroll(rememberScrollState()) .padding(scaffoldPadding) .padding(horizontal = 12.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { val currentEvent = event if (currentEvent != null) { RepoHero(currentEvent) - RepoMaintainersRow(currentEvent, accountViewModel, nav) } if (snapshot != null) { @@ -339,6 +338,8 @@ private fun GitRepositoryHome( snapshot.tipCommit?.let { commit -> RepoLastCommit(commit) { nav.nav(Route.GitRepositoryCode(note.address)) } } + } else if (currentEvent != null && !repoHasFetchableClone(currentEvent)) { + RepoExternalNotice(currentEvent) } RepoNavCards(note, openIssueCount, openPullCount, nav) @@ -371,7 +372,21 @@ private fun GitRepositoryCode( RepoContentSubscription(note, event, accountViewModel) GitRepoSubScreenScaffold(event, note.dTag(), accountViewModel, nav) { - GitCodeTab(browserState, browserViewModel, accountViewModel, nav) + val ev = event + if (ev != null && !repoHasFetchableClone(ev)) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(LocalDisappearingScaffoldPadding.current) + .padding(12.dp), + ) { + RepoExternalNotice(ev) + } + } else { + GitCodeTab(browserState, browserViewModel, accountViewModel, nav) + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6712c3174c..759e094a7b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2680,6 +2680,9 @@ Updated Recent activity Maintained by + Hosted externally + This repository can\'t be cloned over http(s), so the code view isn\'t available here. + Open in browser Untitled About Links From 2f9162a96fb6901409d1ab1a87761109166dea04 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 22:06:02 +0000 Subject: [PATCH 25/31] feat(git): New Issue is a full screen; fix square FAB shape - Converts the New Issue composer from an AlertDialog to a dedicated screen (new Route.GitRepositoryNewIssue + GitNewIssueScreen with its own top bar and a Create action). The Issues FAB now navigates to it. - The extended FAB rendered square because the app theme sets shapes.large (the extended-FAB default shape) to 0.dp; pin an explicit RoundedCornerShape. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 12 ++ .../ui/screen/loggedIn/gitRepo/GitNewIssue.kt | 146 +++++++++++------- .../loggedIn/gitRepo/GitRepositoryScreen.kt | 11 +- 4 files changed, 109 insertions(+), 62 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 4cfcabfefe..b0d5445a6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -134,6 +134,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPack import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.FollowPacksScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitNewIssueScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryCodeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryIssuesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryPullsScreen @@ -496,6 +497,7 @@ fun BuildNavigation( composableFromEndArgs { GitRepositoryCodeScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { GitRepositoryIssuesScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { GitRepositoryPullsScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { GitNewIssueScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.attachment, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index ed9d6b152d..261ed4eabc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -559,6 +559,18 @@ sealed class Route { ) } + @Serializable data class GitRepositoryNewIssue( + val kind: Int, + val pubKeyHex: HexKey, + val dTag: String, + ) : Route() { + constructor(address: Address) : this( + kind = address.kind, + pubKeyHex = address.pubKeyHex, + dTag = address.dTag, + ) + } + @Serializable data class FollowPack( val kind: Int, val pubKeyHex: HexKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitNewIssue.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitNewIssue.kt index e88f50736b..cdf10576bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitNewIssue.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitNewIssue.kt @@ -22,9 +22,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.material3.AlertDialog +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -33,78 +38,109 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent -/** - * A minimal "New issue" composer: a subject and a markdown body. On submit it - * builds a NIP-34 [GitIssueEvent] addressed to [repoNote], signs it with the - * account signer and broadcasts it. The repository owner is notified via the `a` - * tag the builder adds automatically. - */ @Composable -fun GitNewIssueDialog( +fun GitNewIssueScreen( + address: Address, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadAddressableNote(address, accountViewModel) { note -> + note?.let { GitNewIssueForm(it, accountViewModel, nav) } + } +} + +/** + * Full-screen "New issue" composer: a subject and a markdown body. On submit it builds a + * NIP-34 [GitIssueEvent] addressed to [repoNote], signs it with the account signer and + * broadcasts it, then returns to the issues list. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GitNewIssueForm( repoNote: AddressableNote, accountViewModel: AccountViewModel, - onDismiss: () -> Unit, + nav: INav, ) { var subject by rememberSaveable { mutableStateOf("") } var body by rememberSaveable { mutableStateOf("") } var labels by rememberSaveable { mutableStateOf("") } - AlertDialog( - onDismissRequest = onDismiss, - icon = { Icon(MaterialSymbols.Description, contentDescription = null, modifier = Modifier.size(24.dp)) }, - title = { Text(stringRes(R.string.git_new_issue_title)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = subject, - onValueChange = { subject = it }, - label = { Text(stringRes(R.string.git_new_issue_subject)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = body, - onValueChange = { body = it }, - label = { Text(stringRes(R.string.git_new_issue_body)) }, - minLines = 4, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = labels, - onValueChange = { labels = it }, - label = { Text(stringRes(R.string.git_new_issue_labels)) }, - placeholder = { Text(stringRes(R.string.git_new_issue_labels_hint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - TextButton( - enabled = subject.isNotBlank(), - onClick = { - sendGitIssue(accountViewModel, repoNote, subject.trim(), body.trim(), parseLabels(labels)) - onDismiss() + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + ShorterTopAppBar( + title = { Text(stringRes(R.string.git_new_issue_title)) }, + navigationIcon = { + Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = nav::popBack) { ArrowBackIcon() } + } }, - ) { - Text(stringRes(R.string.git_new_issue_create)) - } + actions = { + TextButton( + enabled = subject.isNotBlank(), + onClick = { + sendGitIssue(accountViewModel, repoNote, subject.trim(), body.trim(), parseLabels(labels)) + nav.popBack() + }, + ) { + Text(stringRes(R.string.git_new_issue_create)) + } + }, + ) }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringRes(R.string.git_new_issue_cancel)) } - }, - ) + accountViewModel = accountViewModel, + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(LocalDisappearingScaffoldPadding.current) + .padding(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = subject, + onValueChange = { subject = it }, + label = { Text(stringRes(R.string.git_new_issue_subject)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = body, + onValueChange = { body = it }, + label = { Text(stringRes(R.string.git_new_issue_body)) }, + minLines = 6, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = labels, + onValueChange = { labels = it }, + label = { Text(stringRes(R.string.git_new_issue_labels)) }, + placeholder = { Text(stringRes(R.string.git_new_issue_labels_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } } /** Splits a free-text label field on commas/whitespace, stripping any leading `#`. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index 9644a74a2c..6d9a97560d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -412,8 +412,6 @@ private fun GitRepositoryIssues( val event by observeNoteEvent(note, accountViewModel) RepoContentSubscription(note, event, accountViewModel) - var showNewIssue by rememberSaveable(note.idHex) { mutableStateOf(false) } - StatusFeedScreen( persistKey = note.idHex + "GitRepoIssuesStatus", event = event, @@ -422,12 +420,8 @@ private fun GitRepositoryIssues( closedViewModel = closedViewModel, accountViewModel = accountViewModel, nav = nav, - floatingButton = if (event != null) ({ NewIssueFab { showNewIssue = true } }) else null, + floatingButton = if (event != null) ({ NewIssueFab { nav.nav(Route.GitRepositoryNewIssue(note.address)) } }) else null, ) - - if (showNewIssue && event != null) { - GitNewIssueDialog(repoNote = note, accountViewModel = accountViewModel, onDismiss = { showNewIssue = false }) - } } @Composable @@ -720,8 +714,11 @@ private fun StatusFilterChips( @Composable private fun NewIssueFab(onClick: () -> Unit) { + // The app theme overrides shapes.large (the extended-FAB default) to 0.dp, which makes the + // FAB square — so pin an explicit rounded shape here. ExtendedFloatingActionButton( onClick = onClick, + shape = RoundedCornerShape(16.dp), icon = { Icon(MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(20.dp)) }, text = { Text(stringRes(R.string.git_new_issue_button)) }, ) From f14ddd9e45b05ac88d1686616359ab21a2a560ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 22:44:14 +0000 Subject: [PATCH 26/31] fix(git): import KMP Dispatchers.IO in GitRepositoryListState The commonMain GitRepositoryListState used Dispatchers.IO without importing the multiplatform kotlinx.coroutines.IO extension, so it resolved to the JVM-only member and broke the iOS native compile (:commons:compileKotlinIosSimulatorArm64). Matches BookmarkListState. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../amethyst/commons/model/nip51Lists/GitRepositoryListState.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/GitRepositoryListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/GitRepositoryListState.kt index 16d02ba621..c69822397f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/GitRepositoryListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/GitRepositoryListState.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip51Lists.remove import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce From 2316e725733060a05c46d834c8cdd29487f1d39a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 23:01:16 +0000 Subject: [PATCH 27/31] fix(git): bookmark feedback, social divider, and disappearing-bar reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Top-bar repo bookmark toggle now switches between BookmarkAdd (with +) and Bookmark glyphs instead of two identical  glyphs, so the icon visibly changes shape (not just tint) when starred/unstarred. - Add a thin HorizontalDivider after the ReactionsRow on the repo home. - Code browser: opening/closing a file or changing folders swaps the scrollable in place, landing the new view at the top with no scroll delta, which left the disappearing top bar stranded at its hidden offset over a blank band. Expose the scaffold bar state via LocalDisappearingBarState and reset it to visible on each in-place view change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../amethyst/ui/layouts/DisappearingScaffold.kt | 6 +++++- .../ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt | 3 +++ .../ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt | 2 +- .../ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt | 10 ++++++++++ .../amethyst/commons/ui/layouts/PaddingMerge.kt | 9 +++++++++ 5 files changed, 28 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 79209d5824..6f36f0e63f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -54,6 +54,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingBarNestedScroll import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingBarState +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingBarState import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding import com.vitorpamplona.amethyst.commons.ui.layouts.rememberDisappearingBarState import com.vitorpamplona.amethyst.ui.components.getActivityWindow @@ -199,7 +200,10 @@ private fun ScaffoldLayout( val contentPlaceable = subcompose(DisappearingSlot.Content) { - CompositionLocalProvider(LocalDisappearingScaffoldPadding provides contentPadding) { + CompositionLocalProvider( + LocalDisappearingScaffoldPadding provides contentPadding, + LocalDisappearingBarState provides state, + ) { mainContent(contentPadding) } }.firstOrNull()?.measure( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt index 62d2fccb8e..2800ab6d48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -61,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip34Git.git.GitCommit @@ -162,6 +164,7 @@ fun RepoSocialRow( accountViewModel = accountViewModel, nav = nav, ) + HorizontalDivider(thickness = DividerThickness) } // --------------------------------------------------------------------------- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index 6d9a97560d..9994b9b795 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -292,7 +292,7 @@ private fun GitRepositoryHome( val isBookmarked = remember(bookmarkedSet, note) { bookmarkedSet.contains(note.address) } IconButton(onClick = { accountViewModel.toggleRepositoryBookmark(note, isBookmarked) }) { Icon( - symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkBorder, + symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd, contentDescription = stringRes(R.string.git_repo_bookmark), tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt index 543be49e94..b322552713 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt @@ -43,6 +43,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -61,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.nip34Git.GitBrowseState import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingBarState import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon @@ -108,6 +110,14 @@ private fun CodeBrowser( var openFilePath by rememberSaveable(snapshot.headCommit) { mutableStateOf(null) } var showHistory by rememberSaveable(snapshot.headCommit) { mutableStateOf(false) } + // Each of these toggles swaps the scrollable content in place, which lands the new view at + // the top with no scroll delta. Pull the disappearing top bar back into view so it doesn't + // stay stranded at its hidden offset, leaving a blank band over the fresh content. + val barState = LocalDisappearingBarState.current + LaunchedEffect(pathString, openFilePath, showHistory) { + barState?.resetToVisible() + } + if (showHistory) { Column(Modifier.fillMaxSize().padding(scaffoldPaddingTop)) { GitCommitLog(snapshot, viewModel, onBack = { showHistory = false }) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt index 2094e04a70..eb2b94bd87 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt @@ -39,6 +39,15 @@ import androidx.compose.ui.unit.dp */ val LocalDisappearingScaffoldPadding = compositionLocalOf { PaddingValues(0.dp) } +/** + * The surrounding [DisappearingScaffold]'s bar state, exposed so inner content that swaps its + * scrollable in place — and thereby resets the scroll position to the top without emitting a + * scroll delta — can pull the bar back into view. Without this the bar can stay stuck at its + * hidden offset over fresh top-of-list content, leaving a blank band. Null when no scaffold + * provides it. + */ +val LocalDisappearingBarState = compositionLocalOf { null } + /** * Merges two [PaddingValues] component-wise, resolving start/end against the current * [LocalLayoutDirection]. From 0d8936e8d3cc7c181b46bac887ce7c8e6c249d1e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 23:06:11 +0000 Subject: [PATCH 28/31] refactor(git): index PR updates via LocalCache.observeEvents GitPullRequestUpdateIndex now subscribes to a kind-1619-filtered LocalCache.observeEvents instead of LocalCache.live.newEventBundles. The indexed observable seeds its matching set from the cache index (via init()) and re-emits the full list on each new PR update, so the manual onStart full-cache scan and the per-bundle type filtering over every event of every kind are both gone. The collector just reduces the list to the latest-per-parent map. PR updates are rare, so recomputing the whole map per emission is cheaper than scanning every bundle. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../model/GitPullRequestUpdateIndex.kt | 45 +++++++++---------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt index f937515040..f4b80eecce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.model.LocalCache.observeEvents import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -28,17 +30,24 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicBoolean /** * Cross-screen index of the most recent NIP-34 pull-request update event * (kind 1619) per parent pull-request id, kept up to date from - * [LocalCache.live.newEventBundles]. A PR update *revises* its parent PR with a + * [LocalCache.observeEvents]. A PR update *revises* its parent PR with a * newer commit / merge base, so the UI folds the latest one into the PR rather * than listing updates separately. Like [GitStatusIndex], updates aren't tracked * in `Note.replies`, so a per-row cache scan would otherwise be required. + * + * The kind-indexed [observeEvents] subscription replaces both the full-cache + * `onStart` scan and the per-bundle type filtering the old + * `LocalCache.live.newEventBundles` flow needed: the observable's `init()` seeds + * the matching set from the index and re-emits the whole list on every new 1619, + * so we just reduce it to the latest-per-parent map each time. PR updates are + * rare, so recomputing the full map per emission is cheaper than it scanning + * every event of every kind. */ object GitPullRequestUpdateIndex { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -50,35 +59,21 @@ object GitPullRequestUpdateIndex { fun startIfNeeded() { if (!started.compareAndSet(false, true)) return scope.launch { - LocalCache.live.newEventBundles - .onStart { - val initial = HashMap() - LocalCache.notes.forEach { _, note -> - val event = note.event as? GitPullRequestUpdateEvent ?: return@forEach - val target = event.parentPullRequestId() ?: return@forEach - val current = initial[target] - if (current == null || event.createdAt > current.createdAt) { - initial[target] = event - } - } - mutableLatestByPullRequest.value = initial - }.collect { bundle -> processBundle(bundle) } + LocalCache + .observeEvents(Filter(kinds = listOf(GitPullRequestUpdateEvent.KIND))) + .collect { events -> mutableLatestByPullRequest.value = latestByParent(events) } } } - private fun processBundle(bundle: Set) { - val snapshot = mutableLatestByPullRequest.value ?: emptyMap() - var modified: HashMap? = null - for (note in bundle) { - val event = note.event as? GitPullRequestUpdateEvent ?: continue + private fun latestByParent(events: List): Map { + val latest = HashMap() + for (event in events) { val target = event.parentPullRequestId() ?: continue - val map = modified ?: snapshot - val current = map[target] + val current = latest[target] if (current == null || event.createdAt > current.createdAt) { - if (modified == null) modified = HashMap(snapshot) - modified[target] = event + latest[target] = event } } - modified?.let { mutableLatestByPullRequest.value = it } + return latest } } From a2f918c1eba46a9929035631c242c87c5a09f1bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 23:20:19 +0000 Subject: [PATCH 29/31] feat(git): bookmarked repos tab + observeEvents for GitStatusIndex - GitStatusIndex now subscribes to a kind-1630..1633-filtered LocalCache.observeEvents instead of LocalCache.live.newEventBundles, matching the GitPullRequestUpdateIndex change: the indexed observable seeds from the cache index and re-emits the full list on each new status event, so the manual onStart full-cache scan and per-bundle type filtering are gone and the collector just reduces to latest-per-target. - Bookmark screen gains a third "Repositories" tab listing the user's bookmarked (NIP-51 kind 10018) git repositories. New BookmarkRepositoriesFeedFilter resolves the public repository address set to addressable notes (newest first); the screen invalidates it on publicRepositoryAddressSet changes and preloads any uncached repo announcements via the EventFinder. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../amethyst/model/GitStatusIndex.kt | 65 +++++++++---------- .../default/BookmarkListScreen.kt | 45 +++++++++++-- .../dal/BookmarkRepositoriesFeedFilter.kt | 45 +++++++++++++ .../dal/BookmarkRepositoriesFeedViewModel.kt | 39 +++++++++++ amethyst/src/main/res/values/strings.xml | 1 + 5 files changed, 156 insertions(+), 39 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt index 90ab75bcb1..ed48469c6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.model.LocalCache.observeEvents import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent @@ -30,45 +32,58 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicBoolean /** * Cross-screen index of the most recent NIP-34 status event (kinds * 1630-1633) per target id, kept up to date from - * [LocalCache.live.newEventBundles]. Status events are not tracked in + * [LocalCache.observeEvents]. Status events are not tracked in * `Note.replies` (see `LocalCache.computeReplyTo`), so the only way to * find them otherwise would be a full cache scan per row. + * + * The kind-indexed [observeEvents] subscription replaces both the full-cache + * `onStart` scan and the per-bundle type filtering the old + * `LocalCache.live.newEventBundles` flow needed: the observable's `init()` seeds + * the matching set from the index and re-emits the whole list on every new + * status event, so we just reduce it to the latest-per-target map each time. */ object GitStatusIndex { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val started = AtomicBoolean(false) + private val statusKinds = + listOf( + GitStatusEvent.KIND_OPEN, + GitStatusEvent.KIND_APPLIED, + GitStatusEvent.KIND_CLOSED, + GitStatusEvent.KIND_DRAFT, + ) + private val mutableLatestByTarget = MutableStateFlow?>(null) val latestByTarget: StateFlow?> = mutableLatestByTarget.asStateFlow() fun startIfNeeded() { if (!started.compareAndSet(false, true)) return scope.launch { - // Subscribe to bundle updates BEFORE the initial scan via onStart, so any - // events that arrive between scan start and collector attach are picked up. - LocalCache.live.newEventBundles - .onStart { - val initial = HashMap() - LocalCache.notes.forEach { _, note -> - val event = note.event as? GitStatusEvent ?: return@forEach - val target = event.rootEventId() ?: return@forEach - val current = initial[target] - if (current == null || event.createdAt > current.createdAt) { - initial[target] = event - } - } - mutableLatestByTarget.value = initial - }.collect { bundle -> processBundle(bundle) } + LocalCache + .observeEvents(Filter(kinds = statusKinds)) + .collect { events -> mutableLatestByTarget.value = latestByTarget(events) } } } + private fun latestByTarget(events: List): Map { + val latest = HashMap() + for (event in events) { + val target = event.rootEventId() ?: continue + val current = latest[target] + if (current == null || event.createdAt > current.createdAt) { + latest[target] = event + } + } + return latest + } + /** * Whether the latest status for [targetId] marks it as closed (kind 1632) * or applied/resolved/merged (kind 1631). Items with no status event, or @@ -84,20 +99,4 @@ object GitStatusIndex { val event = map?.get(targetId) ?: return false return event is GitStatusClosedEvent || event is GitStatusAppliedEvent } - - private fun processBundle(bundle: Set) { - val snapshot = mutableLatestByTarget.value ?: emptyMap() - var modified: HashMap? = null - for (note in bundle) { - val event = note.event as? GitStatusEvent ?: continue - val target = event.rootEventId() ?: continue - val map = modified ?: snapshot - val current = map[target] - if (current == null || event.createdAt > current.createdAt) { - if (modified == null) modified = HashMap(snapshot) - modified[target] = event - } - } - modified?.let { mutableLatestByTarget.value = it } - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt index 9dd55c401d..b66e6d5b55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt @@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPrivateFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPublicFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkRepositoriesFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import kotlinx.coroutines.launch @@ -76,18 +77,31 @@ fun BookmarkListScreen( factory = BookmarkPrivateFeedViewModel.Factory(accountViewModel.account), ) + val repositoriesFeedViewModel: BookmarkRepositoriesFeedViewModel = + viewModel( + key = "NostrBookmarkRepositoriesFeedViewModel", + factory = BookmarkRepositoriesFeedViewModel.Factory(accountViewModel.account), + ) + val bookmarkState by accountViewModel.account.bookmarkState.bookmarks .collectAsStateWithLifecycle(null) + val repositoryBookmarks by accountViewModel.account.gitRepositoryListState.publicRepositoryAddressSet + .collectAsStateWithLifecycle() + LaunchedEffect(bookmarkState) { publicFeedViewModel.invalidateData() privateFeedViewModel.invalidateData() } - // Preload all bookmarked events so they don't load one-by-one when scrolling - PreloadBookmarkEvents(bookmarkState, accountViewModel) + LaunchedEffect(repositoryBookmarks) { + repositoriesFeedViewModel.invalidateData() + } - RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, bookmarkState, accountViewModel, nav) + // Preload all bookmarked events so they don't load one-by-one when scrolling + PreloadBookmarkEvents(bookmarkState, repositoryBookmarks, accountViewModel) + + RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, repositoriesFeedViewModel, bookmarkState, accountViewModel, nav) } @Composable @@ -95,11 +109,12 @@ fun BookmarkListScreen( private fun RenderBookmarkScreen( publicFeedViewModel: BookmarkPublicFeedViewModel, privateFeedViewModel: BookmarkPrivateFeedViewModel, + repositoriesFeedViewModel: BookmarkRepositoriesFeedViewModel, bookmarkState: com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState.BookmarkList?, accountViewModel: AccountViewModel, nav: INav, ) { - val pagerState = rememberPagerState { 2 } + val pagerState = rememberPagerState { 3 } val coroutineScope = rememberCoroutineScope() val cache = accountViewModel.account.cache @@ -146,6 +161,11 @@ private fun RenderBookmarkScreen( onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, text = { Text(text = stringRes(R.string.public_bookmarks)) }, ) + Tab( + selected = pagerState.currentPage == 2, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, + text = { Text(text = stringRes(R.string.repository_bookmarks)) }, + ) } } }, @@ -171,6 +191,15 @@ private fun RenderBookmarkScreen( nav = nav, ) } + + 2 -> { + RefresheableFeedView( + repositoriesFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } @@ -198,14 +227,18 @@ private fun RenderBookmarkScreen( @Composable private fun PreloadBookmarkEvents( bookmarkState: com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState.BookmarkList?, + repositoryBookmarks: Set, accountViewModel: AccountViewModel, ) { val eventFinder = accountViewModel.dataSources().eventFinder val account = accountViewModel.account val queries = - remember(bookmarkState) { - val allNotes = bookmarkState?.public.orEmpty() + bookmarkState?.private.orEmpty() + remember(bookmarkState, repositoryBookmarks) { + val allNotes = + bookmarkState?.public.orEmpty() + + bookmarkState?.private.orEmpty() + + repositoryBookmarks.map { account.cache.getOrCreateAddressableNote(it) } allNotes .filter { it.event == null } .map { EventFinderQueryState(it, account) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedFilter.kt new file mode 100644 index 0000000000..efc085e715 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedFilter.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.FeedFilter + +/** + * The user's bookmarked (starred) git repositories — the public NIP-51 kind 10018 + * [com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState] addresses + * resolved to their addressable notes, newest first. Mirrors [BookmarkPublicFeedFilter] but + * sources its list from the repository bookmark state instead of the kind-10003 note bookmarks. + */ +class BookmarkRepositoriesFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = + account.gitRepositoryListState.publicRepositoryAddressSet.value + .hashCode() + .toString() + + override fun feed(): List = + account.gitRepositoryListState.publicRepositoryAddressSet.value + .map { account.cache.getOrCreateAddressableNote(it) } + .sortedByDescending { it.createdAt() ?: 0L } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedViewModel.kt new file mode 100644 index 0000000000..ea793be1d6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedViewModel.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +@Stable +class BookmarkRepositoriesFeedViewModel( + val account: Account, +) : AndroidFeedViewModel(BookmarkRepositoriesFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = BookmarkRepositoriesFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 759e094a7b..2435ef4c32 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1016,6 +1016,7 @@ Private Bookmarks Public Bookmarks + Repositories Add to Private Bookmarks Add to Public Bookmarks Remove from Private Bookmarks From 9a6c535cbd060a3669396fe4bfcd622956907dba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 23:30:40 +0000 Subject: [PATCH 30/31] feat(git): bookmarked repositories as a standalone screen Replace the third "Repositories" tab on the default bookmark screen with a dedicated entry on the bookmark-lists screen, mirroring how Pinned Notes works: a row in ListOfBookmarkGroupsFeedView that opens its own BookmarkedRepositoriesScreen via the new Route.BookmarkedRepositories. The row shows the bookmarked-repo count from gitRepositoryListState.publicRepositoryAddressSet; the screen renders the BookmarkRepositoriesFeedViewModel feed (moved to a repositories/ package), invalidates on bookmark changes, and preloads any uncached repo announcements via the EventFinder. Reverts the tab added to BookmarkListScreen. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../default/BookmarkListScreen.kt | 43 +------- .../list/ListOfBookmarkGroupsFeedView.kt | 52 +++++++++ .../list/ListOfBookmarkGroupsScreen.kt | 7 ++ .../BookmarkedRepositoriesScreen.kt | 100 ++++++++++++++++++ .../dal/BookmarkRepositoriesFeedFilter.kt | 5 +- .../dal/BookmarkRepositoriesFeedViewModel.kt | 2 +- amethyst/src/main/res/values/strings.xml | 1 + 9 files changed, 172 insertions(+), 42 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/BookmarkedRepositoriesScreen.kt rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/{default => repositories}/dal/BookmarkRepositoriesFeedFilter.kt (90%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/{default => repositories}/dal/BookmarkRepositoriesFeedViewModel.kt (98%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b0d5445a6f..79ce1181cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen @@ -444,6 +445,7 @@ fun BuildNavigation( composableFromEnd { BookmarkListScreen(accountViewModel, nav) } composableFromEnd { OldBookmarkListScreen(accountViewModel, nav) } composableFromEnd { PinnedNotesScreen(accountViewModel, nav) } + composableFromEnd { BookmarkedRepositoriesScreen(accountViewModel, nav) } composableFromEnd { WebBookmarksScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } composableFromEnd { ScheduledPostsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 261ed4eabc..42760f3437 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -302,6 +302,8 @@ sealed class Route { @Serializable object PinnedNotes : Route() + @Serializable object BookmarkedRepositories : Route() + @Serializable object BookmarkGroups : Route() @Serializable object InterestSets : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt index b66e6d5b55..9dd55c401d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/BookmarkListScreen.kt @@ -55,7 +55,6 @@ import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPrivateFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkPublicFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal.BookmarkRepositoriesFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import kotlinx.coroutines.launch @@ -77,31 +76,18 @@ fun BookmarkListScreen( factory = BookmarkPrivateFeedViewModel.Factory(accountViewModel.account), ) - val repositoriesFeedViewModel: BookmarkRepositoriesFeedViewModel = - viewModel( - key = "NostrBookmarkRepositoriesFeedViewModel", - factory = BookmarkRepositoriesFeedViewModel.Factory(accountViewModel.account), - ) - val bookmarkState by accountViewModel.account.bookmarkState.bookmarks .collectAsStateWithLifecycle(null) - val repositoryBookmarks by accountViewModel.account.gitRepositoryListState.publicRepositoryAddressSet - .collectAsStateWithLifecycle() - LaunchedEffect(bookmarkState) { publicFeedViewModel.invalidateData() privateFeedViewModel.invalidateData() } - LaunchedEffect(repositoryBookmarks) { - repositoriesFeedViewModel.invalidateData() - } - // Preload all bookmarked events so they don't load one-by-one when scrolling - PreloadBookmarkEvents(bookmarkState, repositoryBookmarks, accountViewModel) + PreloadBookmarkEvents(bookmarkState, accountViewModel) - RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, repositoriesFeedViewModel, bookmarkState, accountViewModel, nav) + RenderBookmarkScreen(publicFeedViewModel, privateFeedViewModel, bookmarkState, accountViewModel, nav) } @Composable @@ -109,12 +95,11 @@ fun BookmarkListScreen( private fun RenderBookmarkScreen( publicFeedViewModel: BookmarkPublicFeedViewModel, privateFeedViewModel: BookmarkPrivateFeedViewModel, - repositoriesFeedViewModel: BookmarkRepositoriesFeedViewModel, bookmarkState: com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState.BookmarkList?, accountViewModel: AccountViewModel, nav: INav, ) { - val pagerState = rememberPagerState { 3 } + val pagerState = rememberPagerState { 2 } val coroutineScope = rememberCoroutineScope() val cache = accountViewModel.account.cache @@ -161,11 +146,6 @@ private fun RenderBookmarkScreen( onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, text = { Text(text = stringRes(R.string.public_bookmarks)) }, ) - Tab( - selected = pagerState.currentPage == 2, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, - text = { Text(text = stringRes(R.string.repository_bookmarks)) }, - ) } } }, @@ -191,15 +171,6 @@ private fun RenderBookmarkScreen( nav = nav, ) } - - 2 -> { - RefresheableFeedView( - repositoriesFeedViewModel, - null, - accountViewModel = accountViewModel, - nav = nav, - ) - } } } @@ -227,18 +198,14 @@ private fun RenderBookmarkScreen( @Composable private fun PreloadBookmarkEvents( bookmarkState: com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState.BookmarkList?, - repositoryBookmarks: Set, accountViewModel: AccountViewModel, ) { val eventFinder = accountViewModel.dataSources().eventFinder val account = accountViewModel.account val queries = - remember(bookmarkState, repositoryBookmarks) { - val allNotes = - bookmarkState?.public.orEmpty() + - bookmarkState?.private.orEmpty() + - repositoryBookmarks.map { account.cache.getOrCreateAddressableNote(it) } + remember(bookmarkState) { + val allNotes = bookmarkState?.public.orEmpty() + bookmarkState?.private.orEmpty() allNotes .filter { it.event == null } .map { EventFinderQueryState(it, account) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt index af5c4bcf59..37caa116b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt @@ -42,6 +42,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState @@ -59,10 +60,12 @@ fun ListOfBookmarkGroupsFeedView( defaultBookmarks: BookmarkListState, oldBookmarks: OldBookmarkListState, pinnedNotes: PinListState, + repositories: GitRepositoryListState, groupListFeedSource: StateFlow>, openDefaultBookmarks: () -> Unit, openOldBookmarks: () -> Unit, openPinnedNotes: () -> Unit, + openRepositories: () -> Unit, onOpenItem: (String, BookmarkType) -> Unit, onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit, onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -91,6 +94,11 @@ fun ListOfBookmarkGroupsFeedView( HorizontalDivider(thickness = DividerThickness) } + item { + RepositoriesBookmarkList(repositories, openRepositories) + HorizontalDivider(thickness = DividerThickness) + } + itemsIndexed( bookmarkGroupFeedState, key = { _: Int, item: LabeledBookmarkList -> item.identifier }, @@ -197,6 +205,50 @@ fun PinnedNotesList( ) } +@Composable +fun RepositoriesBookmarkList( + repositories: GitRepositoryListState, + openRepositories: () -> Unit, +) { + val repositoryAddresses by repositories.publicRepositoryAddressSet.collectAsStateWithLifecycle() + + ListItem( + modifier = Modifier.clickable(onClick = openRepositories), + headlineContent = { + Text(stringRes(R.string.repository_bookmarks), maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + supportingContent = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringRes(R.string.repository_bookmarks_explainer), + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + } + }, + leadingContent = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + symbol = MaterialSymbols.Code, + contentDescription = stringRes(R.string.bookmark_list_icon_label), + modifier = Size40Modifier, + ) + Spacer(StdVertSpacer) + BookmarkMembershipStatusAndNumberDisplay( + modifier = Modifier.align(Alignment.CenterHorizontally), + postBookmarksSize = repositoryAddresses.size, + articleBookmarksSize = 0, + ) + } + }, + ) +} + @Composable fun OldBookmarkList( oldBookmarks: OldBookmarkListState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt index 2ac0c207cd..e1aa912ebd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState @@ -59,10 +60,12 @@ fun ListOfBookmarkGroupsScreen( defaultBookmarks = accountViewModel.account.bookmarkState, oldBookmarks = accountViewModel.account.oldBookmarkState, pinnedNotes = accountViewModel.account.pinState, + repositories = accountViewModel.account.gitRepositoryListState, listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow, openDefaultBookmarks = { nav.nav(Route.Bookmarks) }, openOldBookmarks = { nav.nav(Route.OldBookmarks) }, openPinnedNotes = { nav.nav(Route.PinnedNotes) }, + openRepositories = { nav.nav(Route.BookmarkedRepositories) }, addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) }, openBookmarkGroup = { identifier, bookmarkType -> nav.nav(Route.BookmarkGroupView(identifier, bookmarkType)) @@ -101,10 +104,12 @@ fun ListOfBookmarkGroupsFeed( defaultBookmarks: BookmarkListState, oldBookmarks: OldBookmarkListState, pinnedNotes: PinListState, + repositories: GitRepositoryListState, listSource: StateFlow>, openDefaultBookmarks: () -> Unit, openOldBookmarks: () -> Unit, openPinnedNotes: () -> Unit, + openRepositories: () -> Unit, addBookmarkGroup: () -> Unit, openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit, renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -148,10 +153,12 @@ fun ListOfBookmarkGroupsFeed( defaultBookmarks = defaultBookmarks, oldBookmarks = oldBookmarks, pinnedNotes = pinnedNotes, + repositories = repositories, groupListFeedSource = listSource, openDefaultBookmarks = openDefaultBookmarks, openOldBookmarks = openOldBookmarks, openPinnedNotes = openPinnedNotes, + openRepositories = openRepositories, onOpenItem = openBookmarkGroup, onRenameItem = renameBookmarkGroup, onItemDescriptionChange = changeBookmarkGroupDescription, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/BookmarkedRepositoriesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/BookmarkedRepositoriesScreen.kt new file mode 100644 index 0000000000..9f5749cd77 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/BookmarkedRepositoriesScreen.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.dal.BookmarkRepositoriesFeedViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Address + +@Composable +fun BookmarkedRepositoriesScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val repositoriesFeedViewModel: BookmarkRepositoriesFeedViewModel = + viewModel( + key = "NostrBookmarkRepositoriesFeedViewModel", + factory = BookmarkRepositoriesFeedViewModel.Factory(accountViewModel.account), + ) + + val repositoryBookmarks by accountViewModel.account.gitRepositoryListState.publicRepositoryAddressSet + .collectAsStateWithLifecycle() + + LaunchedEffect(repositoryBookmarks) { + repositoriesFeedViewModel.invalidateData() + } + + // Preload any bookmarked repo announcements not yet in cache so they don't pop in one by one. + PreloadRepositoryEvents(repositoryBookmarks, accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + TopBarWithBackButton(stringRes(id = R.string.repository_bookmarks), nav) + }, + accountViewModel = accountViewModel, + ) { + RefresheableFeedView( + repositoriesFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +private fun PreloadRepositoryEvents( + repositoryBookmarks: Set
, + accountViewModel: AccountViewModel, +) { + val eventFinder = accountViewModel.dataSources().eventFinder + val account = accountViewModel.account + + val queries = + remember(repositoryBookmarks) { + repositoryBookmarks + .map { account.cache.getOrCreateAddressableNote(it) } + .filter { it.event == null } + .map { EventFinderQueryState(it, account) } + } + + DisposableEffect(queries) { + eventFinder.subscribe(queries) + onDispose { + eventFinder.unsubscribe(queries) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedFilter.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedFilter.kt index efc085e715..a04feb1f8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedFilter.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note @@ -27,8 +27,7 @@ import com.vitorpamplona.amethyst.ui.dal.FeedFilter /** * The user's bookmarked (starred) git repositories — the public NIP-51 kind 10018 * [com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState] addresses - * resolved to their addressable notes, newest first. Mirrors [BookmarkPublicFeedFilter] but - * sources its list from the repository bookmark state instead of the kind-10003 note bookmarks. + * resolved to their addressable notes, newest first. */ class BookmarkRepositoriesFeedFilter( val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedViewModel.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedViewModel.kt index ea793be1d6..cbef857aec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/default/dal/BookmarkRepositoriesFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.dal +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.dal import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2435ef4c32..a80072af2b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1017,6 +1017,7 @@ Private Bookmarks Public Bookmarks Repositories + Your bookmarked git repositories Add to Private Bookmarks Add to Public Bookmarks Remove from Private Bookmarks From fdcfc05ba2469b229239e2c1c1b349ad87643f30 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 23:43:25 +0000 Subject: [PATCH 31/31] refactor(git): collapse status/PR-update indexes to map().stateIn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that observeEvents re-emits the whole matching list each time, GitStatusIndex and GitPullRequestUpdateIndex no longer need the imperative launch/collect-into-MutableStateFlow wrapper carried over from the old newEventBundles version. Each is now a single observeEvents().map { reduce } .stateIn(scope, Eagerly, null) — dropping startIfNeeded(), the AtomicBoolean double-start guard, and the MutableStateFlow/asStateFlow pair. Eagerly (not WhileSubscribed) is required: callers read .value synchronously (isClosedOrResolved, the feed filters, the home open-count derivations) and must not see a stale map when nobody is collecting. All startIfNeeded() call sites removed; stateIn shares one upstream subscription across collectors. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr --- .../model/GitPullRequestUpdateIndex.kt | 38 +++++++----------- .../amethyst/model/GitStatusIndex.kt | 39 ++++++++----------- .../amethyst/ui/note/types/Git.kt | 1 - .../ui/note/types/GitStatusActions.kt | 2 - .../amethyst/ui/note/types/GitStatusPill.kt | 2 - .../screen/loggedIn/gitRepo/GitItemListRow.kt | 2 - .../loggedIn/gitRepo/GitRepositoryScreen.kt | 5 +-- .../dal/RepositoryIssuesFeedViewModel.kt | 1 - .../dal/RepositoryPatchesFeedViewModel.kt | 1 - 9 files changed, 34 insertions(+), 57 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt index f4b80eecce..476a95563e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt @@ -27,11 +27,11 @@ import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn /** * Cross-screen index of the most recent NIP-34 pull-request update event @@ -41,29 +41,21 @@ import java.util.concurrent.atomic.AtomicBoolean * than listing updates separately. Like [GitStatusIndex], updates aren't tracked * in `Note.replies`, so a per-row cache scan would otherwise be required. * - * The kind-indexed [observeEvents] subscription replaces both the full-cache - * `onStart` scan and the per-bundle type filtering the old - * `LocalCache.live.newEventBundles` flow needed: the observable's `init()` seeds - * the matching set from the index and re-emits the whole list on every new 1619, - * so we just reduce it to the latest-per-parent map each time. PR updates are - * rare, so recomputing the full map per emission is cheaper than it scanning - * every event of every kind. + * The kind-indexed [observeEvents] re-emits the whole matching list on every new + * 1619 (and seeds it from the cache index via `init()`), so [latestByPullRequest] + * is just that list reduced to the latest-per-parent map. Shared [SharingStarted.Eagerly] + * — never `WhileSubscribed` — because callers read `.value` synchronously and must + * not see a stale map when no one is actively collecting. `null` means "not loaded yet". */ object GitPullRequestUpdateIndex { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val started = AtomicBoolean(false) - private val mutableLatestByPullRequest = MutableStateFlow?>(null) - val latestByPullRequest: StateFlow?> = mutableLatestByPullRequest.asStateFlow() - - fun startIfNeeded() { - if (!started.compareAndSet(false, true)) return - scope.launch { - LocalCache - .observeEvents(Filter(kinds = listOf(GitPullRequestUpdateEvent.KIND))) - .collect { events -> mutableLatestByPullRequest.value = latestByParent(events) } - } - } + val latestByPullRequest: StateFlow?> = + LocalCache + .observeEvents(Filter(kinds = listOf(GitPullRequestUpdateEvent.KIND))) + .map { latestByParent(it) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, null) private fun latestByParent(events: List): Map { val latest = HashMap() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt index ed48469c6a..a19dce396f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt @@ -29,11 +29,11 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn /** * Cross-screen index of the most recent NIP-34 status event (kinds @@ -42,15 +42,15 @@ import java.util.concurrent.atomic.AtomicBoolean * `Note.replies` (see `LocalCache.computeReplyTo`), so the only way to * find them otherwise would be a full cache scan per row. * - * The kind-indexed [observeEvents] subscription replaces both the full-cache - * `onStart` scan and the per-bundle type filtering the old - * `LocalCache.live.newEventBundles` flow needed: the observable's `init()` seeds - * the matching set from the index and re-emits the whole list on every new - * status event, so we just reduce it to the latest-per-target map each time. + * The kind-indexed [observeEvents] re-emits the whole matching list on every new + * status event (and seeds it from the cache index via `init()`), so [latestByTarget] + * is just that list reduced to the latest-per-target map. Shared [SharingStarted.Eagerly] + * — never `WhileSubscribed` — because callers (e.g. [isClosedOrResolved] and the feed + * filters) read `.value` synchronously and must not see a stale map when no one is + * actively collecting. `null` means "not loaded yet". */ object GitStatusIndex { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val started = AtomicBoolean(false) private val statusKinds = listOf( @@ -60,19 +60,14 @@ object GitStatusIndex { GitStatusEvent.KIND_DRAFT, ) - private val mutableLatestByTarget = MutableStateFlow?>(null) - val latestByTarget: StateFlow?> = mutableLatestByTarget.asStateFlow() + val latestByTarget: StateFlow?> = + LocalCache + .observeEvents(Filter(kinds = statusKinds)) + .map { reduceLatestByTarget(it) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, null) - fun startIfNeeded() { - if (!started.compareAndSet(false, true)) return - scope.launch { - LocalCache - .observeEvents(Filter(kinds = statusKinds)) - .collect { events -> mutableLatestByTarget.value = latestByTarget(events) } - } - } - - private fun latestByTarget(events: List): Map { + private fun reduceLatestByTarget(events: List): Map { val latest = HashMap() for (event in events) { val target = event.rootEventId() ?: continue diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index e660a4131c..99fb0abf00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -582,7 +582,6 @@ private fun RenderGitPullRequestEvent( ) { // A later pull-request update (kind 1619) revises this PR with a newer commit / // merge base. Fold the most recent one in so the card reflects the current state. - LaunchedEffect(Unit) { GitPullRequestUpdateIndex.startIfNeeded() } val updateIndex by GitPullRequestUpdateIndex.latestByPullRequest.collectAsStateWithLifecycle() val update = updateIndex?.get(note.idHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt index 83b8b2e9d2..ea8cc7075a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -75,7 +74,6 @@ fun GitStatusActions( val canModerate = remember(event, note) { canModerate(repoAddress, note, accountViewModel) } if (!canModerate) return - LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() } val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle() if (index == null) return val current = index?.get(note.idHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt index 2425dfd6fc..443ca59ad1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -74,7 +73,6 @@ fun GitStatusPill( modifier: Modifier = Modifier, defaultIfMissing: StatusKind? = null, ) { - LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() } val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle() val map = index ?: return // hide pill until the initial scan completes — avoids a default-then-real flicker val kind = map[targetIdHex]?.statusKind() ?: defaultIfMissing ?: return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt index cdb48b8fd4..5b5f3c91e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt @@ -37,7 +37,6 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -244,7 +243,6 @@ private fun LabelChip(label: String) { /** Small "Revised" badge shown when a pull request has a later kind-1619 update. */ @Composable private fun GitRevisedChip(prIdHex: String) { - LaunchedEffect(Unit) { GitPullRequestUpdateIndex.startIfNeeded() } val index by GitPullRequestUpdateIndex.latestByPullRequest.collectAsStateWithLifecycle() if (index?.get(prIdHex) == null) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index 9994b9b795..6298397653 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -257,9 +257,8 @@ private fun GitRepositoryHome( } // Nav-card badges count only the OPEN issues/PRs. The open/closed split needs the status - // index (kinds 1630-1633), which is started here so the home reflects it without visiting - // the Issues screen first; the count is then derived directly from the live index. - LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() } + // index (kinds 1630-1633); it's an eagerly-shared StateFlow, so the home reflects it without + // visiting the Issues screen first. The count is derived directly from the live index. val statusMap by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle() val openIssueCount = remember(openIssueItems, closedIssueItems, statusMap) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt index 7794864652..a3df977d5f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt @@ -39,7 +39,6 @@ class RepositoryIssuesFeedViewModel( // Status events (kinds 1630-1633) don't mutate the issue note, so the additive // feed update can't move an item between the Open/Closed buckets on its own. // Watch the status index and force a full re-partition whenever it changes. - GitStatusIndex.startIfNeeded() viewModelScope.launch(Dispatchers.IO) { GitStatusIndex.latestByTarget.collect { invalidateData() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt index 2231f60009..e4cd1ec15d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt @@ -39,7 +39,6 @@ class RepositoryPatchesFeedViewModel( // Status events (kinds 1630-1633) don't mutate the patch/PR note, so the additive // feed update can't move an item between the Open/Closed buckets on its own. // Watch the status index and force a full re-partition whenever it changes. - GitStatusIndex.startIfNeeded() viewModelScope.launch(Dispatchers.IO) { GitStatusIndex.latestByTarget.collect { invalidateData() } }