Merge pull request #3415 from vitorpamplona/claude/git-repo-readme-code-tabs-e4uf6c

Add git smart-HTTP browser for NIP-34 repositories
This commit is contained in:
Vitor Pamplona
2026-06-29 19:50:24 -04:00
committed by GitHub
65 changed files with 7332 additions and 489 deletions
+3
View File
@@ -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)
@@ -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,
@@ -0,0 +1,71 @@
/*
* 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.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
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
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
* (kind 1619) per parent pull-request id, kept up to date from
* [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] 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())
val latestByPullRequest: StateFlow<Map<HexKey, GitPullRequestUpdateEvent>?> =
LocalCache
.observeEvents<GitPullRequestUpdateEvent>(Filter(kinds = listOf(GitPullRequestUpdateEvent.KIND)))
.map { latestByParent(it) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, null)
private fun latestByParent(events: List<GitPullRequestUpdateEvent>): Map<HexKey, GitPullRequestUpdateEvent> {
val latest = HashMap<HexKey, GitPullRequestUpdateEvent>()
for (event in events) {
val target = event.parentPullRequestId() ?: continue
val current = latest[target]
if (current == null || event.createdAt > current.createdAt) {
latest[target] = event
}
}
return latest
}
}
@@ -20,53 +20,63 @@
*/
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
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.flow.onStart
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
* 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] 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 mutableLatestByTarget = MutableStateFlow<Map<HexKey, GitStatusEvent>?>(null)
val latestByTarget: StateFlow<Map<HexKey, GitStatusEvent>?> = mutableLatestByTarget.asStateFlow()
private val statusKinds =
listOf(
GitStatusEvent.KIND_OPEN,
GitStatusEvent.KIND_APPLIED,
GitStatusEvent.KIND_CLOSED,
GitStatusEvent.KIND_DRAFT,
)
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<HexKey, GitStatusEvent>()
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) }
val latestByTarget: StateFlow<Map<HexKey, GitStatusEvent>?> =
LocalCache
.observeEvents<GitStatusEvent>(Filter(kinds = statusKinds))
.map { reduceLatestByTarget(it) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, null)
private fun reduceLatestByTarget(events: List<GitStatusEvent>): Map<HexKey, GitStatusEvent> {
val latest = HashMap<HexKey, GitStatusEvent>()
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
}
/**
@@ -84,20 +94,4 @@ object GitStatusIndex {
val event = map?.get(targetId) ?: return false
return event is GitStatusClosedEvent || event is GitStatusAppliedEvent
}
private fun processBundle(bundle: Set<Note>) {
val snapshot = mutableLatestByTarget.value ?: emptyMap()
var modified: HashMap<HexKey, GitStatusEvent>? = 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 }
}
}
@@ -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
@@ -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(
@@ -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
@@ -134,6 +135,10 @@ 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
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
@@ -440,6 +445,7 @@ fun BuildNavigation(
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.OldBookmarks> { OldBookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.PinnedNotes> { PinnedNotesScreen(accountViewModel, nav) }
composableFromEnd<Route.BookmarkedRepositories> { BookmarkedRepositoriesScreen(accountViewModel, nav) }
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
composableFromEnd<Route.ScheduledPosts> { ScheduledPostsScreen(accountViewModel, nav) }
@@ -490,6 +496,10 @@ fun BuildNavigation(
composableFromEndArgs<Route.RelayMembers> { RelayMembersScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.GitRepository> { GitRepositoryScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.GitRepositoryCode> { GitRepositoryCodeScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.GitRepositoryIssues> { GitRepositoryIssuesScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.GitRepositoryPulls> { GitRepositoryPullsScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.GitRepositoryNewIssue> { GitNewIssueScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.FollowPack> { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.attachment, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
@@ -302,6 +302,8 @@ sealed class Route {
@Serializable object PinnedNotes : Route()
@Serializable object BookmarkedRepositories : Route()
@Serializable object BookmarkGroups : Route()
@Serializable object InterestSets : Route()
@@ -523,6 +525,54 @@ 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 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,
@@ -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,13 +51,19 @@ 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 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.GitRepoSnapshotCache
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
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
@@ -68,6 +75,13 @@ 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.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
@@ -83,13 +97,14 @@ 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
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)
@@ -433,7 +448,26 @@ 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)
}
if (!makeItShort) {
GitStatusActions(note, accountViewModel)
}
}
}
@@ -504,6 +538,10 @@ private fun RenderGitIssueEvent(
Spacer(modifier = HalfDoubleVertSpacer)
GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
if (!makeItShort) {
GitStatusActions(note, accountViewModel)
}
}
}
@@ -542,6 +580,11 @@ 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.
val updateIndex by GitPullRequestUpdateIndex.latestByPullRequest.collectAsStateWithLifecycle()
val update = updateIndex?.get(note.idHex)
GitCardContainer {
val repository = remember(noteEvent) { noteEvent.repositoryAddress() }
if (repository != null) {
@@ -565,6 +608,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() } }
@@ -573,9 +625,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)
@@ -609,6 +670,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)
}
}
}
@@ -729,8 +795,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() }
@@ -784,25 +848,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, nav)
if (topics.isNotEmpty()) {
Spacer(modifier = HalfDoubleVertSpacer)
@@ -821,3 +867,57 @@ 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,
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(), cacheKey) }
val browserState by browser.state.collectAsStateWithLifecycle()
// 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) }
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) }
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 { commit ->
Spacer(modifier = HalfDoubleVertSpacer)
RepoLastCommit(commit) { nav.nav(Route.GitRepository(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())) }
}
}
@@ -0,0 +1,330 @@
/*
* 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.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
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.commons.nip34Git.ui.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
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)
val emphasis = remember(hunk) { IntralineDiff.emphasis(hunk.lines) }
hunk.lines.forEachIndexed { index, line ->
DiffLineRow(line, gutterWidth, language.takeIf { highlightEnabled }, darkMode, emphasis[index])
}
}
}
}
}
}
}
@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,
emphasis: CharSpan?,
) {
val background =
when (line.type) {
GitDiffLineType.ADD -> AddColor.copy(alpha = 0.12f)
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 -> "+"
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, 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 {
base
}
}
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<MaterialSymbol, Color> =
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
}
@@ -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<String>,
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>(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<String>,
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<String>): List<String> {
val out = LinkedHashSet<String>()
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()
}
@@ -0,0 +1,146 @@
/*
* 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.FlowRow
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.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.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 }
/**
* 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 GitStatusActions(
note: Note,
accountViewModel: AccountViewModel,
) {
val event = note.event ?: return
val repoAddress = repositoryAddressOf(event) ?: return
val isPatchOrPr = event is GitPatchEvent || event is GitPullRequestEvent
val canModerate = remember(event, note) { canModerate(repoAddress, note, accountViewModel) }
if (!canModerate) return
val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle()
if (index == null) return
val current = index?.get(note.idHex)
val closedOrApplied = current is GitStatusClosedEvent || current is GitStatusAppliedEvent
FlowRow(
modifier = Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (closedOrApplied) {
FilledTonalButton(onClick = { sendStatus(accountViewModel, note, StatusTarget.OPEN) }) {
Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(18.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 = { 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_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(
repoAddress: Address,
note: Note,
accountViewModel: AccountViewModel,
): Boolean {
val myHex = accountViewModel.account.signer.pubKey
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 sendStatus(
accountViewModel: AccountViewModel,
note: Note,
target: StatusTarget,
) {
val hint = note.toEventHint<Event>() ?: return
accountViewModel.launchSigner {
val template =
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)
}
}
@@ -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
@@ -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}" }
}
@@ -1159,6 +1159,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,
@@ -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<List<LabeledBookmarkList>>,
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,
@@ -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<List<LabeledBookmarkList>>,
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,
@@ -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<Address>,
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)
}
}
}
@@ -0,0 +1,44 @@
/*
* 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.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.
*/
class BookmarkRepositoriesFeedFilter(
val account: Account,
) : FeedFilter<Note>() {
override fun feedKey(): String =
account.gitRepositoryListState.publicRepositoryAddressSet.value
.hashCode()
.toString()
override fun feed(): List<Note> =
account.gitRepositoryListState.publicRepositoryAddressSet.value
.map { account.cache.getOrCreateAddressableNote(it) }
.sortedByDescending { it.createdAt() ?: 0L }
}
@@ -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.repositories.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 <T : ViewModel> create(modelClass: Class<T>): T = BookmarkRepositoriesFeedViewModel(account) as T
}
}
@@ -21,14 +21,18 @@
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.layout.size
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,13 +41,17 @@ 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
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
@@ -87,15 +95,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 ->
@@ -196,15 +209,78 @@ 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),
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) }
}
}
}
}
private fun gitSubjectOf(event: Event?): String? =
@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),
)
}
/** Small "Revised" badge shown when a pull request has a later kind-1619 update. */
@Composable
private fun GitRevisedChip(prIdHex: String) {
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,
)
}
}
internal fun gitSubjectOf(event: Event?): String? =
when (event) {
is GitIssueEvent -> event.subject()?.takeIf { it.isNotBlank() }
is GitPullRequestEvent -> event.subject()?.takeIf { it.isNotBlank() }
is GitPatchEvent -> event.subject()?.takeIf { it.isNotBlank() }
else -> null
}
internal fun gitLabelsOf(event: Event?): List<String> =
when (event) {
is GitIssueEvent -> event.topics()
is GitPullRequestEvent -> event.labels()
else -> emptyList()
}.filter { it.isNotBlank() }.distinct()
@@ -0,0 +1,167 @@
/*
* 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.Row
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.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
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
@Composable
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,
nav: INav,
) {
var subject by rememberSaveable { mutableStateOf("") }
var body by rememberSaveable { mutableStateOf("") }
var labels by rememberSaveable { mutableStateOf("") }
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() }
}
},
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))
}
},
)
},
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 `#`. */
private fun parseLabels(raw: String): List<String> =
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<String>,
) {
val repositoryHint = repoNote.toEventHint<GitRepositoryEvent>() ?: return
accountViewModel.launchSigner {
val template = GitIssueEvent.build(subject, body, repositoryHint, emptyList(), labels)
val signed = accountViewModel.account.signer.sign(template)
accountViewModel.account.sendAutomatic(signed)
}
}
@@ -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<String> =
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)
}
}
@@ -0,0 +1,527 @@
/*
* 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.clickable
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.HorizontalDivider
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
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.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
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
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
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.
// ---------------------------------------------------------------------------
/** The top-bar title: owner avatar + project name, with the repo description as a subtitle. */
@Composable
fun RepoTitleBar(
event: GitRepositoryEvent?,
fallback: String,
accountViewModel: AccountViewModel,
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 = 28.dp,
accountViewModel = accountViewModel,
onClick = { nav.nav(Route.Profile(it.pubkeyHex)) },
)
}
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 topic chips and personal-fork badge (name + description live in the top bar). */
@Composable
fun RepoHero(event: GitRepositoryEvent) {
val topics = remember(event) { event.hashtags().filter { it.isNotBlank() } }
val isFork = event.isPersonalFork()
if (topics.isEmpty() && !isFork) return
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") }
}
}
@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),
)
}
// ---------------------------------------------------------------------------
// Social pulse — zaps, reactions and comments on the repository announcement.
// ---------------------------------------------------------------------------
@Composable
fun RepoSocialRow(
note: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
// 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,
)
HorizontalDivider(thickness = DividerThickness)
}
// ---------------------------------------------------------------------------
// 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<LanguageSlice>) {
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<String, LangDef> =
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<String>): List<LanguageSlice> {
if (files.isEmpty()) return emptyList()
val counts = HashMap<LangDef, Int>()
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
}
// ---------------------------------------------------------------------------
// 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.
// ---------------------------------------------------------------------------
@Composable
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),
) {
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<Note>,
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()
.clickable { nav.nav { routeFor(note, accountViewModel.account) } }
.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"
}
@@ -1,254 +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.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
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.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
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)
@Composable
fun GitRepositoryOverview(
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),
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,
)
}
}
@@ -21,6 +21,8 @@
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
import androidx.compose.foundation.layout.Column
@@ -28,50 +30,81 @@ 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.pager.HorizontalPager
import androidx.compose.foundation.layout.size
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.ExtendedFloatingActionButton
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
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
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.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
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
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
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
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
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.GitCodeTab
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.launch
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
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(
@@ -81,7 +114,7 @@ fun GitRepositoryScreen(
) {
LoadAddressableNote(address, accountViewModel) { note ->
note?.let {
PrepareGitRepositoryScreen(
GitRepositoryHome(
note = it,
accountViewModel = accountViewModel,
nav = nav,
@@ -91,149 +124,586 @@ fun GitRepositoryScreen(
}
@Composable
private fun PrepareGitRepositoryScreen(
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<T>)` override.
*/
internal class GitRepositoryBrowserViewModelFactory(
private val okHttpClient: (String) -> OkHttpClient,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T = GitRepositoryBrowserViewModel(okHttpClient) as T
}
@Composable
private fun rememberRepoBrowser(
note: AddressableNote,
accountViewModel: AccountViewModel,
): 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 GitRepositoryHome(
note: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
val openIssuesViewModel: RepositoryIssuesFeedViewModel =
val browserViewModel = rememberRepoBrowser(note, accountViewModel)
val event by observeNoteEvent<GitRepositoryEvent>(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(), cacheKey) }
}
val browserState by browserViewModel.state.collectAsStateWithLifecycle()
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 closedIssuesViewModel: RepositoryIssuesFeedViewModel =
val closedIssues: RepositoryIssuesFeedViewModel =
viewModel(
key = note.idHex + "GitRepoIssuesClosed",
factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true),
)
val openPatchesViewModel: RepositoryPatchesFeedViewModel =
val openPatches: RepositoryPatchesFeedViewModel =
viewModel(
key = note.idHex + "GitRepoPatchesOpen",
factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false),
)
val closedPatchesViewModel: RepositoryPatchesFeedViewModel =
val closedPatches: RepositoryPatchesFeedViewModel =
viewModel(
key = note.idHex + "GitRepoPatchesClosed",
factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true),
)
WatchLifecycleAndUpdateModel(openIssues)
WatchLifecycleAndUpdateModel(closedIssues)
WatchLifecycleAndUpdateModel(openPatches)
WatchLifecycleAndUpdateModel(closedPatches)
GitRepositoryScreen(
note = note,
openIssuesViewModel = openIssuesViewModel,
closedIssuesViewModel = closedIssuesViewModel,
openPatchesViewModel = openPatchesViewModel,
closedPatchesViewModel = closedPatchesViewModel,
val openIssueItems = rememberGitFeedItems(openIssues)
val closedIssueItems = rememberGitFeedItems(closedIssues)
val openPatchItems = rememberGitFeedItems(openPatches)
val closedPatchItems = rememberGitFeedItems(closedPatches)
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 =
remember(openIssueItems, closedIssueItems, openPatchItems, closedPatchItems) {
(openIssueItems + closedIssueItems + openPatchItems + closedPatchItems)
.sortedByDescending { it.createdAt() ?: 0L }
.take(6)
}
// Nav-card badges count only the OPEN issues/PRs. The open/closed split needs the status
// 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) {
(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) {
GitRepoSettingsDialog(currentEventForSettings, accountViewModel) { showSettings = false }
}
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
ShorterTopAppBar(
title = { RepoTitleBar(event = event, fallback = note.dTag(), accountViewModel = accountViewModel, nav = nav) },
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.BookmarkAdd,
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))
}
}
Row(Modifier.padding(end = 6.dp), verticalAlignment = Alignment.CenterVertically) {
MoreOptionsButton(note, accountViewModel = accountViewModel, nav = nav)
}
},
)
},
accountViewModel = accountViewModel,
) {
val scaffoldPadding = LocalDisappearingScaffoldPadding.current
Column(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(scaffoldPadding)
.padding(horizontal = 12.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val currentEvent = event
if (currentEvent != null) {
RepoHero(currentEvent)
}
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 { commit ->
RepoLastCommit(commit) { nav.nav(Route.GitRepositoryCode(note.address)) }
}
} else if (currentEvent != null && !repoHasFetchableClone(currentEvent)) {
RepoExternalNotice(currentEvent)
}
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))
}
}
}
}
@Composable
private fun GitRepositoryCode(
note: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
val browserViewModel = rememberRepoBrowser(note, accountViewModel)
val event by observeNoteEvent<GitRepositoryEvent>(note, accountViewModel)
val cacheKey = remember(note) { note.address.toValue() }
LaunchedEffect(event) {
event?.let { browserViewModel.loadOnce(it.clones(), cacheKey) }
}
val browserState by browserViewModel.state.collectAsStateWithLifecycle()
RepoContentSubscription(note, event, accountViewModel)
GitRepoSubScreenScaffold(event, note.dTag(), 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)
}
}
}
@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<GitRepositoryEvent>(note, accountViewModel)
RepoContentSubscription(note, event, accountViewModel)
StatusFeedScreen(
persistKey = note.idHex + "GitRepoIssuesStatus",
event = event,
fallbackTitle = note.dTag(),
openViewModel = openViewModel,
closedViewModel = closedViewModel,
accountViewModel = accountViewModel,
nav = nav,
floatingButton = if (event != null) ({ NewIssueFab { nav.nav(Route.GitRepositoryNewIssue(note.address)) } }) else null,
)
}
@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<GitRepositoryEvent>(note, accountViewModel)
RepoContentSubscription(note, event, accountViewModel)
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. [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 GitRepositoryScreen(
note: AddressableNote,
openIssuesViewModel: RepositoryIssuesFeedViewModel,
closedIssuesViewModel: RepositoryIssuesFeedViewModel,
openPatchesViewModel: RepositoryPatchesFeedViewModel,
closedPatchesViewModel: RepositoryPatchesFeedViewModel,
private fun GitRepoSubScreenScaffold(
event: GitRepositoryEvent?,
fallbackTitle: String,
accountViewModel: AccountViewModel,
nav: INav,
belowBar: (@Composable () -> Unit)? = null,
floatingButton: (@Composable () -> Unit)? = null,
content: @Composable () -> Unit,
) {
WatchLifecycleAndUpdateModel(openIssuesViewModel)
WatchLifecycleAndUpdateModel(closedIssuesViewModel)
WatchLifecycleAndUpdateModel(openPatchesViewModel)
WatchLifecycleAndUpdateModel(closedPatchesViewModel)
val event by observeNoteEvent<GitRepositoryEvent>(note, accountViewModel)
// 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)
}
val pagerState = rememberForeverPagerState(note.idHex + "GitRepoScreenPagerState") { 3 }
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
Column {
ShorterTopAppBar(
title = {
TopBarTitle(event = event, fallback = note.dTag())
},
title = { TopBarTitle(event = event, fallback = fallbackTitle) },
navigationIcon = {
Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = nav::popBack) { ArrowBackIcon() }
}
},
)
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_overview)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
)
Tab(
selected = pagerState.currentPage == 1,
text = { Text(stringRes(R.string.git_repo_tab_issues)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
)
Tab(
selected = pagerState.currentPage == 2,
text = { Text(stringRes(R.string.git_repo_tab_patches)) },
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } },
)
}
belowBar?.invoke()
}
},
floatingButton = floatingButton,
accountViewModel = accountViewModel,
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
) { page ->
when (page) {
0 -> {
val currentEvent = event
if (currentEvent != null) {
GitRepositoryOverview(currentEvent, accountViewModel, nav)
content()
}
}
/** The Code / Issues / Pull Requests entry points on the project home. */
@Composable
private fun RepoNavCards(
note: AddressableNote,
openIssues: Int,
openPulls: Int,
nav: INav,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
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), openIssues) {
nav.nav(Route.GitRepositoryIssues(note.address))
}
RepoNavCard(MaterialSymbols.CallMerge, stringRes(R.string.git_repo_tab_patches), openPulls) {
nav.nav(Route.GitRepositoryPulls(note.address))
}
}
}
@Composable
private fun RepoNavCard(
symbol: MaterialSymbol,
title: String,
count: Int?,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(15.dp))
.background(MaterialTheme.colorScheme.surface)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.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),
)
if (count != null && count > 0) {
Text(
text =
if (count > 999) {
"999+"
} else {
EmptyMessage(stringRes(R.string.loading_feed))
}
}
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,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.3f),
)
}
}
1 -> {
StatusSplitFeed(
persistKey = note.idHex + "GitRepoIssuesStatus",
openViewModel = openIssuesViewModel,
closedViewModel = closedIssuesViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
}
/**
* A drill-in screen showing an Open / Closed &amp; 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 StatusFeedScreen(
persistKey: String,
event: GitRepositoryEvent?,
fallbackTitle: String,
openViewModel: FeedViewModel,
closedViewModel: FeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
floatingButton: (@Composable () -> Unit)? = null,
) {
var showClosed by rememberSaveable(persistKey) { mutableStateOf(false) }
var selectedLabel by rememberSaveable(persistKey) { mutableStateOf<String?>(null) }
2 -> {
StatusSplitFeed(
persistKey = note.idHex + "GitRepoPatchesStatus",
openViewModel = openPatchesViewModel,
closedViewModel = closedPatchesViewModel,
accountViewModel = accountViewModel,
nav = nav,
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
}
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)
},
)
}
}
/** 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<String>,
) {
Column(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.background)) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = !showClosed,
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)) }
} else {
null
},
)
FilterChip(
selected = showClosed,
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)) }
} else {
null
},
)
}
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 = { onSelectLabel(null) },
label = { Text(stringRes(R.string.git_repo_label_all)) },
)
labels.forEach { label ->
FilterChip(
selected = selectedLabel == label,
onClick = { onSelectLabel(if (selectedLabel == label) null else label) },
label = { Text("#$label") },
)
}
}
@@ -241,55 +711,39 @@ private fun GitRepositoryScreen(
}
}
/**
* Wraps a feed in an Open / Closed &amp; Resolved segmented selector, 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].
*/
@Composable
private fun StatusSplitFeed(
persistKey: String,
openViewModel: FeedViewModel,
closedViewModel: FeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
var showClosed by rememberSaveable(persistKey) { mutableStateOf(false) }
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)) },
)
}
Column(Modifier.fillMaxSize()) {
SingleChoiceSegmentedButtonRow(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 4.dp),
) {
SegmentedButton(
selected = !showClosed,
onClick = { showClosed = false },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
) {
Text(stringRes(R.string.git_repo_filter_open))
}
SegmentedButton(
selected = showClosed,
onClick = { showClosed = true },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
) {
Text(stringRes(R.string.git_repo_filter_closed))
/** 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<Note> {
val flow =
remember(viewModel) {
viewModel.feedState.feedContent.flatMapLatest { state ->
if (state is FeedState.Loaded) state.feed.map { it.list } else flowOf(emptyList())
}
}
RefresheableFeedView(
viewModel = if (showClosed) closedViewModel else openViewModel,
routeForLastRead = null,
accountViewModel = accountViewModel,
nav = nav,
onLoaded = { loaded, listState ->
GitItemFeedLoaded(loaded, listState, accountViewModel, nav)
},
)
}
val items by flow.collectAsStateWithLifecycle(emptyList())
return items
}
@Composable
@@ -311,7 +765,7 @@ private fun EmptyMessage(text: String) {
Box(
modifier =
Modifier
.fillMaxSize()
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center,
) {
@@ -0,0 +1,295 @@
/*
* 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.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.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.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
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
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: 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<String> = emptyList(),
tags: List<String> = emptyList(),
onSelectRef: ((String?) -> Unit)? = null,
onHistory: (() -> Unit)? = null,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 2.dp, bottom = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
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,
)
}
}
}
}
@Composable
private fun BranchSelector(
current: String?,
branches: List<String>,
tags: List<String>,
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,
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,
)
}
}
@@ -0,0 +1,428 @@
/*
* 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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
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.foundation.shape.RoundedCornerShape
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.LaunchedEffect
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.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.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
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 ->
GitLoadingBox(stringRes(R.string.git_repo_code_loading), Modifier.padding(scaffoldPadding))
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 (noClone) 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<String?>(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 })
}
return
}
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) {
GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_file_load_error))
} else {
GitFileViewer(
snapshot = snapshot,
viewModel = viewModel,
entry = entry,
accountViewModel = accountViewModel,
nav = nav,
modifier = Modifier.fillMaxSize(),
)
}
}
return
}
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() }
// 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() }
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)
}
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 {
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 = {
val child = (path + entry.name).joinToString("/")
if (entry.isFolder) pathString = child else openFilePath = child
},
)
HorizontalDivider(
modifier = Modifier.padding(start = 44.dp),
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f),
)
}
}
}
}
}
@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 = 2.dp)
.clip(RoundedCornerShape(10.dp)),
)
}
@Composable
private fun SearchResultRow(
path: List<String>,
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<String>,
onNavigate: (depth: Int) -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 10.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp),
) {
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),
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.35f),
)
Crumb(label = segment, current = index == path.lastIndex) { onNavigate(index + 1) }
}
}
}
@Composable
private fun Crumb(
label: String,
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 (current) FontWeight.SemiBold else FontWeight.Normal,
color = textColor,
maxLines = 1,
modifier =
Modifier
.clip(RoundedCornerShape(8.dp))
.let { if (!current) it.clickable(onClick = onClick) else it }
.background(background)
.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
@Composable
private fun EntryRow(
entry: GitTreeEntry,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
val symbol =
when {
entry.isFolder -> MaterialSymbols.Folder
entry.isSubmodule -> MaterialSymbols.Code
else -> MaterialSymbols.Description
}
val tint = if (entry.isFolder) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
Icon(
symbol = symbol,
contentDescription = null,
modifier = Modifier.size(20.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),
)
}
}
}
@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,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -0,0 +1,260 @@
/*
* 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.commons.nip34Git.GitRepositoryBrowserViewModel
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<String?>(null) }
val history by
produceState<Result<List<GitCommit>>?>(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<Result<ParsedPatch>?>(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()
}
@@ -0,0 +1,352 @@
/*
* 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.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
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
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 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
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
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 a line-number gutter and 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<Result<ByteArray>?>(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 -> 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 {
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,
)
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 language = remember(fileName) { CodeHighlighter.languageForFile(fileName) }
val annotated by
produceState(AnnotatedString(code), code, fileName, darkMode) {
value =
withContext(Dispatchers.Default) {
if (language == null) AnnotatedString(code) else CodeHighlighter.highlight(code, language, darkMode)
}
}
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 CodeBar(
languageLabel: String,
code: String,
) {
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 = 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<Pair<Int, Int>> {
val ranges = ArrayList<Pair<Int, Int>>()
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() }
}
@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)
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"
}
@@ -0,0 +1,175 @@
/*
* 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.fillMaxWidth
import androidx.compose.foundation.layout.padding
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 androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
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.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
/**
* 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 GitReadmeSection(
state: GitBrowseState,
viewModel: GitRepositoryBrowserViewModel,
event: GitRepositoryEvent,
accountViewModel: AccountViewModel,
nav: INav,
) {
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)
} else {
ReadmeFallback(
event = event,
loading = state is GitBrowseState.Loading,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@Composable
private fun ReadmeContent(
snapshot: GitRepoSnapshot,
viewModel: GitRepositoryBrowserViewModel,
readme: GitTreeEntry,
accountViewModel: AccountViewModel,
nav: INav,
) {
val content by
produceState<String?>(null, readme.oid) {
value =
try {
viewModel.readBlob(snapshot, readme.oid).decodeToString()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
""
}
}
when (val text = content) {
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) }
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,
) {
val description = event.description()?.takeIf { it.isNotBlank() }
if (description == null) {
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.fillMaxWidth()) {
RichTextViewer(
content = description,
canPreview = true,
quotesLeft = 1,
modifier = Modifier.fillMaxWidth(),
tags = EmptyTagList,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav,
)
if (loading) {
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>): 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()
}
@@ -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() }
}
@@ -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() }
}
@@ -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,
@@ -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,
@@ -48,18 +48,36 @@ class GitRepositoriesFeedFilter(
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
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<Note>): Set<Note> = innerApplyFilter(newItems)
override fun applyFilter(newItems: Set<Note>): Set<Note> {
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(
@@ -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<RelayBasedFilter> {
// "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())
@@ -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<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
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,
),
)
}
}
+67
View File
@@ -1016,6 +1016,8 @@
</plurals>
<string name="private_bookmarks">Private Bookmarks</string>
<string name="public_bookmarks">Public Bookmarks</string>
<string name="repository_bookmarks">Repositories</string>
<string name="repository_bookmarks_explainer">Your bookmarked git repositories</string>
<string name="add_to_private_bookmarks">Add to Private Bookmarks</string>
<string name="add_to_public_bookmarks">Add to Public Bookmarks</string>
<string name="remove_from_private_bookmarks">Remove from Private Bookmarks</string>
@@ -2672,12 +2674,77 @@
<string name="git_repo_tab_patches">Patches &amp; PRs</string>
<string name="git_repo_filter_open">Open</string>
<string name="git_repo_filter_closed">Closed &amp; Resolved</string>
<string name="git_repo_label_all">All</string>
<string name="git_repo_bookmark">Bookmark repository</string>
<string name="git_repo_stat_branches">Branches</string>
<string name="git_repo_stat_tags">Tags</string>
<string name="git_repo_stat_files">Files</string>
<string name="git_repo_stat_updated">Updated</string>
<string name="git_repo_recent_activity">Recent activity</string>
<string name="git_repo_maintained_by">Maintained by</string>
<string name="git_repo_external_host_title">Hosted externally</string>
<string name="git_repo_external_host_body">This repository can\'t be cloned over http(s), so the code view isn\'t available here.</string>
<string name="git_repo_open_in_browser">Open in browser</string>
<string name="git_untitled">Untitled</string>
<string name="git_repo_section_about">About</string>
<string name="git_repo_section_links">Links</string>
<string name="git_repo_section_maintainers">Maintainers</string>
<string name="git_repo_section_topics">Topics</string>
<string name="git_repo_personal_fork">Personal fork</string>
<string name="git_repo_tab_readme">Readme</string>
<string name="git_repo_tab_code">Code</string>
<string name="git_repo_code_loading">Loading repository…</string>
<string name="git_repo_code_error">Could not load the repository from its clone URL.</string>
<string name="git_repo_no_clone_url">This repository announcement has no http(s) clone URL to browse.</string>
<string name="git_repo_readme_missing">This repository has no README file.</string>
<string name="git_repo_file_load_error">Could not load this file.</string>
<string name="git_repo_binary_file">Binary file (%1$s) — preview not available.</string>
<string name="git_repo_empty_folder">This folder is empty.</string>
<string name="git_repo_root">root</string>
<string name="git_repo_retry">Retry</string>
<string name="git_repo_default_branch">default</string>
<string name="git_repo_branches">Branches</string>
<string name="git_repo_tags">Tags</string>
<string name="git_repo_search_files">Search files…</string>
<string name="git_repo_no_search_results">No matching files.</string>
<string name="git_repo_commits">Commits</string>
<string name="git_repo_no_commits">No commit history available.</string>
<string name="git_repo_copy_file">Copy file contents</string>
<string name="git_repo_plain_text">Text</string>
<plurals name="git_repo_item_count">
<item quantity="one">%1$d item</item>
<item quantity="other">%1$d items</item>
</plurals>
<plurals name="git_diff_files_changed">
<item quantity="one">%1$d file changed</item>
<item quantity="other">%1$d files changed</item>
</plurals>
<string name="git_diff_binary">Binary file not shown.</string>
<string name="git_new_issue_button">New</string>
<string name="git_new_issue_title">New issue</string>
<string name="git_new_issue_subject">Title</string>
<string name="git_new_issue_body">Description</string>
<string name="git_new_issue_create">Create</string>
<string name="git_new_issue_cancel">Cancel</string>
<string name="git_new_issue_labels">Labels</string>
<string name="git_new_issue_labels_hint">bug, enhancement…</string>
<string name="git_issue_close">Close issue</string>
<string name="git_issue_reopen">Reopen</string>
<string name="git_status_close">Close</string>
<string name="git_status_reopen">Reopen</string>
<string name="git_status_mark_merged">Mark merged</string>
<string name="git_pr_view_changes">View changes</string>
<string name="git_pr_loading_changes">Loading changes…</string>
<string name="git_pr_no_changes">No file changes found.</string>
<string name="git_pr_changes_retry">Retry loading changes</string>
<string name="git_pr_revised">Revised</string>
<string name="git_repo_settings_title">Edit repository</string>
<string name="git_repo_settings_name">Name</string>
<string name="git_repo_settings_description">Description</string>
<string name="git_repo_settings_clone_urls">Clone URLs (one per line)</string>
<string name="git_repo_settings_web_urls">Web URLs (one per line)</string>
<string name="git_repo_settings_topics">Topics (comma separated)</string>
<string name="git_repo_settings_save">Save</string>
<string name="git_repositories">Git Repositories</string>
<string name="nsite_title">nSite: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
+3
View File
@@ -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)
}
}
@@ -115,6 +115,7 @@ object MaterialSymbols {
val FileOpen = MaterialSymbol("\uEAF3")
val FilterAlt = MaterialSymbol("\uEF4F")
val FitnessCenter = MaterialSymbol("\uEB43")
val Folder = MaterialSymbol("\uE2C7")
val FolderZip = MaterialSymbol("\uEB2C")
val FormatBold = MaterialSymbol("\uE238")
val FormatItalic = MaterialSymbol("\uE23F")
@@ -0,0 +1,98 @@
/*
* 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.IO
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<NoteState> = repositoryList.flow().metadata.stateFlow
fun getList(): GitRepositoryListEvent? = repositoryList.event as? GitRepositoryListEvent
private fun publicAddresses(note: Note): Set<Address> = (note.event as? GitRepositoryListEvent)?.publicRepositories()?.map { it.address }?.toSet() ?: emptySet()
@OptIn(FlowPreview::class)
val publicRepositoryAddressSet: StateFlow<Set<Address>> =
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)
}
}
@@ -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.commons.nip34Git.ui
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)
}
}
}
}
}
@@ -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<DisappearingBarState?> { null }
/**
* Merges two [PaddingValues] component-wise, resolving start/end against the current
* [LocalLayoutDirection].
@@ -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<String, GitRepoSnapshot>(8)
fun get(key: String?): GitRepoSnapshot? = key?.let { cache.get(it) }
fun put(
key: String?,
snapshot: GitRepoSnapshot,
) {
if (key != null) cache.put(key, snapshot)
}
}
@@ -0,0 +1,165 @@
/*
* 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.lifecycle.ViewModel
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
/** 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>(GitBrowseState.Loading)
val state = _state.asStateFlow()
private var cloneUrls: List<String> = 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
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.
*
* 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<String>,
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()
}
/** 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) {
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, 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) {
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)
/** Recent commits ending at the snapshot's tip, most recent first. */
suspend fun loadHistory(snapshot: GitRepoSnapshot): List<GitCommit> = 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<String> {
val out = LinkedHashSet<String>()
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()
}
companion object {
const val NO_CLONE_URL = "no-clone-url"
}
}
+2
View File
@@ -47,6 +47,7 @@ lifecycleRuntimeKtx = "2.11.0"
lightcompressor-enhanced = "2.2.1"
jlatexmath = "1.4"
markdown = "f92ef49c9d"
highlights = "1.1.0"
material3 = "1.9.0"
media3 = "1.10.1"
mockk = "1.14.11"
@@ -191,6 +192,7 @@ jlatexmath-font-cyrillic = { module = "com.github.rikkahub.jlatexmath-android:jl
markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-commonmark", version.ref = "markdown" }
markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" }
markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" }
highlights = { group = "dev.snipme", name = "highlights", version.ref = "highlights" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"}
@@ -0,0 +1,87 @@
# Git smart-HTTP browser for NIP-34 repositories
Date: 2026-06-28
Module: `quartz` (protocol client) + `amethyst` (UI)
## Goal
On the git repository screen, render the repo's `README` in the first tab and add
a **Code** tab that browses the repository's file tree and renders source files
(syntax-highlighted), reading directly from the repo's git `clone` URL.
NIP-34 (`GitRepositoryEvent`, kind 30617) only guarantees a git `clone` URL — a
plain git-over-HTTP(S) endpoint. To work with **every** server in the Nostr git
ecosystem (GRASP / `ngit` bare servers as well as GitHub/GitLab/Gitea), we read
files via the **git smart-HTTP protocol v2** rather than host-specific web APIs.
## Survey (what already exists — reused, not duplicated)
- `GitRepositoryEvent.clones()` / `webs()` — quartz, kept as-is. Source of the
endpoint URLs.
- `OkHttpClientFactory` + the `(String) -> 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 (`<mode> <name>\0<20-byte oid>` tree entries; `tree <oid>`
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("<type> <len>\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 <HEAD> 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 <oid> 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.
@@ -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<CharSpan, CharSpan> {
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<GitDiffLine>): Map<Int, CharSpan> {
val result = HashMap<Int, CharSpan>()
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
}
}
@@ -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<String>,
newLines: List<String>,
context: Int = 3,
): List<GitDiffHunk> {
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<String>,
b: List<String>,
): List<Op> {
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<IntArray>()
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<Op>()
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<Op>,
oldLines: List<String>,
newLines: List<String>,
context: Int,
): List<GitDiffHunk> {
if (ops.none { it.type != GitDiffLineType.CONTEXT }) return emptyList()
val changeIndexes = ops.indices.filter { ops[it].type != GitDiffLineType.CONTEXT }
val hunks = ArrayList<GitDiffHunk>()
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<GitDiffLine>()
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
}
}
@@ -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<GitDiffLine>,
)
class GitDiffFile(
val oldPath: String?,
val newPath: String?,
val change: GitFileChange,
val isBinary: Boolean,
val hunks: List<GitDiffHunk>,
) {
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<GitDiffFile>,
) {
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<String>,
from: Int,
): List<GitDiffFile> {
// Split into per-file blocks at each "diff --git" line.
val blocks = ArrayList<IntRange>()
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<String>,
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<String>,
from: Int,
last: Int,
): List<GitDiffHunk> {
val hunks = ArrayList<GitDiffHunk>()
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<GitDiffLine>()
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>): 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()
}
}
@@ -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<Int, Int> {
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
}
}
@@ -0,0 +1,472 @@
/*
* 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 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
import java.util.PriorityQueue
/**
* 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/") ?: 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(
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.
filter = "blob:none",
)
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<String, List<GitTreeEntry>>()
val blobs = HashMap<String, ByteArray>()
for ((oid, obj) in objects) {
when (obj.type) {
GitObjectType.TREE -> trees[oid] = GitObjectParser.parseTree(obj.data)
GitObjectType.BLOB -> blobs[oid] = obj.data
else -> {}
}
}
val tipCommit = runCatching { GitObjectParser.parseCommit(head.oid, commit.data) }.getOrNull()
return GitRepoSnapshot(
cloneUrl = cloneUrl,
headCommit = head.oid,
branch = branch,
branches = branches,
tags = tags,
rootTreeOid = rootTreeOid,
trees = trees,
blobs = blobs,
transport = transport,
caps = caps,
tipCommit = tipCommit,
)
}
/**
* 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<GitCommit> {
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<String, GitCommit>()
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<GitCommit>()
val visited = HashSet<String>()
val frontier = PriorityQueue<GitCommit>(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
* 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,
filter = "blob:none",
)
val objects = Packfile.parse(treePack)
val headTreeOid = commitTreeOf(objects, headCommit)
val baseTreeOid = commitTreeOf(objects, base)
val trees = HashMap<String, List<GitTreeEntry>>()
val blobs = HashMap<String, ByteArray>()
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<Change>()
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, filter = "blob:none")
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<String, GitObject>,
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<String, List<GitTreeEntry>>,
rootTreeOid: String,
): Map<String, String> {
val out = HashMap<String, String>()
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<String> {
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<GitRef>,
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?,
val branches: List<String> = emptyList(),
val tags: List<String> = emptyList(),
private val rootTreeOid: String,
private val trees: Map<String, List<GitTreeEntry>>,
blobs: Map<String, ByteArray>,
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<String, ByteArray>(blobs)
private val blobMutex = Mutex()
/** Entries at the repository root, folders first. */
fun rootEntries(): List<GitTreeEntry> = 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<String> {
val out = ArrayList<String>()
val stack = ArrayDeque<Pair<String, String>>()
stack.addLast(rootTreeOid to "")
val seen = HashSet<String>()
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.
*/
fun entriesAt(path: List<String>): List<GitTreeEntry>? {
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<String>): GitTreeEntry? {
if (path.isEmpty()) return null
val parent = entriesAt(path.dropLast(1)) ?: return null
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<List<String>> {
if (query.isBlank()) return emptyList()
val needle = query.lowercase()
val result = ArrayList<List<String>>()
fun walk(
treeOid: String,
prefix: List<String>,
) {
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. */
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,
filter = "blob:none",
)
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<GitTreeEntry>): List<GitTreeEntry> =
entries.sortedWith(
compareByDescending<GitTreeEntry> { it.isFolder }.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
)
}
@@ -0,0 +1,151 @@
/*
* 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<String>,
) {
/** 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<GitRef> =
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 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<String>,
deepen: Int?,
filter: String?,
): 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,
filter = filter?.takeIf { 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)
@@ -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<String>,
deepen: Int?,
filter: String?,
): 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 (filter != null) write(PktLineCodec.dataLine("filter $filter\n"))
write(PktLineCodec.dataLine("done\n"))
write(PktLineCodec.FLUSH)
}
fun parseCapabilities(lines: List<PktLine>): GitCapabilities {
var agent: String? = null
var objectFormat = "sha1"
var supportsLsRefs = false
var fetchFeatures = emptySet<String>()
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<PktLine>): List<GitRef> {
val refs = ArrayList<GitRef>()
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<PktLine>): 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
}
}
@@ -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<String, GitObject> {
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<Raw>(count)
val byOffset = HashMap<Int, Raw>(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<Raw>,
byOffset: Map<Int, Raw>,
): Map<String, GitObject> {
val memo = HashMap<Int, GitObject>(raws.size * 2)
val oidToOffset = HashMap<String, Int>(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<String, GitObject>(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<ByteArray, Int> {
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()
}
@@ -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<PktLine> {
val result = ArrayList<PktLine>()
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()
}
@@ -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)
}
}
@@ -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 <a@b.c> 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() }
}
@@ -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())
}
}
@@ -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))
}
}
@@ -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))
}
}
}
@@ -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())
}
}