diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index ae681168a5..1c6dc4e336 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -457,6 +457,9 @@ dependencies { implementation(libs.markdown.ui.material3) implementation(libs.markdown.commonmark) + // Syntax highlighting for the git repository code browser (Apache-2.0) + implementation(libs.highlights) + // LaTeX math rendering ($...$ and $$...$$ inline equations) implementation(libs.jlatexmath.android) implementation(libs.jlatexmath.font.greek) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 56ad5aad15..98db5ad4ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState +import com.vitorpamplona.amethyst.model.nip51Lists.GitRepositoryListState import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.PinListState @@ -397,6 +398,7 @@ class Account( val appRecommendations = AppRecommendationsState(signer, cache, scope) val oldBookmarkState = OldBookmarkListState(signer, cache, scope) val bookmarkState = BookmarkListState(signer, cache, scope) + val gitRepositoryListState = GitRepositoryListState(signer, cache, scope) val pinState = PinListState(signer, cache, scope) val emoji = EmojiPackState(signer, cache, scope) val ownedEmojiPacks = OwnedEmojiPacksState(signer, cache, scope) @@ -3010,6 +3012,16 @@ class Account( delete(note) } + suspend fun addGitRepositoryBookmark(note: AddressableNote) { + if (!isWriteable()) return + sendMyPublicAndPrivateOutbox(gitRepositoryListState.addRepository(note)) + } + + suspend fun removeGitRepositoryBookmark(note: AddressableNote) { + if (!isWriteable()) return + gitRepositoryListState.removeRepository(note)?.let { sendMyPublicAndPrivateOutbox(it) } + } + suspend fun addBookmark( note: Note, isPrivate: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt new file mode 100644 index 0000000000..476a95563e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitPullRequestUpdateIndex.kt @@ -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?> = + LocalCache + .observeEvents(Filter(kinds = listOf(GitPullRequestUpdateEvent.KIND))) + .map { latestByParent(it) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, null) + + private fun latestByParent(events: List): Map { + val latest = HashMap() + 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt index 90ab75bcb1..a19dce396f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/GitStatusIndex.kt @@ -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?>(null) - val latestByTarget: StateFlow?> = 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() - 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?> = + LocalCache + .observeEvents(Filter(kinds = statusKinds)) + .map { reduceLatestByTarget(it) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, null) + + private fun reduceLatestByTarget(events: List): Map { + val latest = HashMap() + for (event in events) { + val target = event.rootEventId() ?: continue + val current = latest[target] + if (current == null || event.createdAt > current.createdAt) { + latest[target] = event + } } + return latest } /** @@ -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) { - val snapshot = mutableLatestByTarget.value ?: emptyMap() - var modified: HashMap? = null - for (note in bundle) { - val event = note.event as? GitStatusEvent ?: continue - val target = event.rootEventId() ?: continue - val map = modified ?: snapshot - val current = map[target] - if (current == null || event.createdAt > current.createdAt) { - if (modified == null) modified = HashMap(snapshot) - modified[target] = event - } - } - modified?.let { mutableLatestByTarget.value = it } - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt index fb13e4d527..42ef3e2ae1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BookmarkListState.kt @@ -23,3 +23,5 @@ package com.vitorpamplona.amethyst.model.nip51Lists typealias BookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState typealias OldBookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState + +typealias GitRepositoryListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 79209d5824..6f36f0e63f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -54,6 +54,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingBarNestedScroll import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingBarState +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingBarState import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding import com.vitorpamplona.amethyst.commons.ui.layouts.rememberDisappearingBarState import com.vitorpamplona.amethyst.ui.components.getActivityWindow @@ -199,7 +200,10 @@ private fun ScaffoldLayout( val contentPlaceable = subcompose(DisappearingSlot.Content) { - CompositionLocalProvider(LocalDisappearingScaffoldPadding provides contentPadding) { + CompositionLocalProvider( + LocalDisappearingScaffoldPadding provides contentPadding, + LocalDisappearingBarState provides state, + ) { mainContent(contentPadding) } }.firstOrNull()?.measure( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 8a977675fe..79ce1181cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen @@ -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 { BookmarkListScreen(accountViewModel, nav) } composableFromEnd { OldBookmarkListScreen(accountViewModel, nav) } composableFromEnd { PinnedNotesScreen(accountViewModel, nav) } + composableFromEnd { BookmarkedRepositoriesScreen(accountViewModel, nav) } composableFromEnd { WebBookmarksScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } composableFromEnd { ScheduledPostsScreen(accountViewModel, nav) } @@ -490,6 +496,10 @@ fun BuildNavigation( composableFromEndArgs { RelayMembersScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { GitRepositoryScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { GitRepositoryCodeScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { GitRepositoryIssuesScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { GitRepositoryPullsScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEndArgs { GitNewIssueScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { ChatroomScreen(it.toKey(), it.message, it.attachment, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 6bad216548..42760f3437 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -302,6 +302,8 @@ sealed class Route { @Serializable object PinnedNotes : Route() + @Serializable object BookmarkedRepositories : Route() + @Serializable object BookmarkGroups : Route() @Serializable object InterestSets : Route() @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index b1fc54e0bf..99fb0abf00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -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())) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt new file mode 100644 index 0000000000..e612e7bbef --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitDiffView.kt @@ -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 = + when (change) { + GitFileChange.ADD -> MaterialSymbols.AddCircle to AddColor + GitFileChange.DELETE -> MaterialSymbols.Cancel to DeleteColor + GitFileChange.RENAME -> MaterialSymbols.AltRoute to MaterialTheme.colorScheme.primary + GitFileChange.MODIFY -> MaterialSymbols.Edit to MaterialTheme.colorScheme.primary + } + +private fun gutterWidthFor(file: GitDiffFile): Dp { + val maxLine = + file.hunks.maxOfOrNull { hunk -> + hunk.lines.maxOfOrNull { maxOf(it.oldNumber ?: 0, it.newNumber ?: 0) } ?: 0 + } ?: 0 + val digits = maxLine.toString().length.coerceAtLeast(2) + return (digits * 8 + 8).dp +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt new file mode 100644 index 0000000000..6146391da6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.types + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.git.GitHttpClient +import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException + +private sealed interface ChangesState { + object Idle : ChangesState + + object Loading : ChangesState + + class Loaded( + val patch: ParsedPatch, + ) : ChangesState + + object Failed : ChangesState +} + +/** + * "View changes" section of a pull-request card. A NIP-34 PR references a clone + * URL + commit instead of embedding a patch, so the actual diff is computed on + * demand over the git smart-HTTP client (base = merge base, head = current + * commit) and rendered with the shared [GitDiffView]. + */ +@Composable +fun GitPullRequestChanges( + cloneUrls: List, + headCommit: String?, + mergeBase: String?, + accountViewModel: AccountViewModel, +) { + val candidates = remember(cloneUrls) { candidateUrls(cloneUrls) } + if (candidates.isEmpty() || headCommit.isNullOrBlank()) return + + var state by remember(headCommit, mergeBase) { mutableStateOf(ChangesState.Idle) } + val scope = rememberCoroutineScope() + + fun load() { + state = ChangesState.Loading + scope.launch { + state = + try { + val patch = loadDiff(accountViewModel, candidates, headCommit, mergeBase) + ChangesState.Loaded(patch) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + ChangesState.Failed + } + } + } + + when (val s = state) { + ChangesState.Idle -> + FilledTonalButton(onClick = { load() }, modifier = Modifier.padding(top = 8.dp)) { + Icon(MaterialSymbols.Code, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.git_pr_view_changes), modifier = Modifier.padding(start = 6.dp)) + } + + ChangesState.Loading -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(top = 12.dp), + ) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.git_pr_loading_changes), style = MaterialTheme.typography.bodySmall) + } + + is ChangesState.Loaded -> + if (s.patch.hasDiff) { + Column(Modifier.fillMaxWidth().padding(top = 12.dp)) { + GitDiffView(s.patch, Modifier.fillMaxWidth()) + } + } else { + Text( + text = stringRes(R.string.git_pr_no_changes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp), + ) + } + + ChangesState.Failed -> + FilledTonalButton(onClick = { load() }, modifier = Modifier.padding(top = 8.dp)) { + Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringRes(R.string.git_pr_changes_retry), modifier = Modifier.padding(start = 6.dp)) + } + } +} + +private suspend fun loadDiff( + accountViewModel: AccountViewModel, + candidates: List, + headCommit: String, + mergeBase: String?, +): ParsedPatch = + withContext(Dispatchers.IO) { + val client = GitHttpClient(accountViewModel.httpClientBuilder::okHttpClientForPreview) + var lastError: Exception? = null + for (url in candidates) { + try { + return@withContext client.computeDiff(url, headCommit, mergeBase?.takeIf { it.isNotBlank() }) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + lastError = e + } + } + throw lastError ?: IllegalStateException("no usable clone URL") + } + +private fun candidateUrls(cloneUrls: List): List { + val out = LinkedHashSet() + for (raw in cloneUrls) { + val url = raw.trim() + if (!url.startsWith("http://") && !url.startsWith("https://")) continue + out.add(url) + if (!url.removeSuffix("/").endsWith(".git")) out.add(url.removeSuffix("/") + ".git") + } + return out.toList() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt new file mode 100644 index 0000000000..ea8cc7075a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt @@ -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() ?: 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt index 2425dfd6fc..443ca59ad1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusPill.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -74,7 +73,6 @@ fun GitStatusPill( modifier: Modifier = Modifier, defaultIfMissing: StatusKind? = null, ) { - LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() } val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle() val map = index ?: return // hide pill until the initial scan completes — avoids a default-then-real flicker val kind = map[targetIdHex]?.statusKind() ?: defaultIfMissing ?: return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index b8a78cd5fb..9e05c1d6a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -310,6 +310,24 @@ class TopNavFilterState( ) } + private val _gitRepositoryRoutes = + combineTransform( + livePeopleListsFlow, + liveInterestFlows, + ) { peopleLists, interests -> + checkNotInMainThread() + emit( + listOf( + // Git repository announcements can be narrowed by author, hashtag and geohash, + // so this mirrors the kind3 catalog plus "Mine" — the user's own repositories. + listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow), + peopleLists, + interests, + listOf(muteListFollow), + ).flatten().toImmutableList(), + ) + } + private val _kind3GlobalPeople = livePeopleListsFlow.transform { peopleLists -> checkNotInMainThread() @@ -385,6 +403,11 @@ class TopNavFilterState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + val gitRepositoryRoutes = + _gitRepositoryRoutes + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + fun destroy() { Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index a7674daec0..c4efef0e6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt index af5c4bcf59..37caa116b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt @@ -42,6 +42,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState @@ -59,10 +60,12 @@ fun ListOfBookmarkGroupsFeedView( defaultBookmarks: BookmarkListState, oldBookmarks: OldBookmarkListState, pinnedNotes: PinListState, + repositories: GitRepositoryListState, groupListFeedSource: StateFlow>, openDefaultBookmarks: () -> Unit, openOldBookmarks: () -> Unit, openPinnedNotes: () -> Unit, + openRepositories: () -> Unit, onOpenItem: (String, BookmarkType) -> Unit, onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit, onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -91,6 +94,11 @@ fun ListOfBookmarkGroupsFeedView( HorizontalDivider(thickness = DividerThickness) } + item { + RepositoriesBookmarkList(repositories, openRepositories) + HorizontalDivider(thickness = DividerThickness) + } + itemsIndexed( bookmarkGroupFeedState, key = { _: Int, item: LabeledBookmarkList -> item.identifier }, @@ -197,6 +205,50 @@ fun PinnedNotesList( ) } +@Composable +fun RepositoriesBookmarkList( + repositories: GitRepositoryListState, + openRepositories: () -> Unit, +) { + val repositoryAddresses by repositories.publicRepositoryAddressSet.collectAsStateWithLifecycle() + + ListItem( + modifier = Modifier.clickable(onClick = openRepositories), + headlineContent = { + Text(stringRes(R.string.repository_bookmarks), maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + supportingContent = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringRes(R.string.repository_bookmarks_explainer), + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + } + }, + leadingContent = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + symbol = MaterialSymbols.Code, + contentDescription = stringRes(R.string.bookmark_list_icon_label), + modifier = Size40Modifier, + ) + Spacer(StdVertSpacer) + BookmarkMembershipStatusAndNumberDisplay( + modifier = Modifier.align(Alignment.CenterHorizontally), + postBookmarksSize = repositoryAddresses.size, + articleBookmarksSize = 0, + ) + } + }, + ) +} + @Composable fun OldBookmarkList( oldBookmarks: OldBookmarkListState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt index 2ac0c207cd..e1aa912ebd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState @@ -59,10 +60,12 @@ fun ListOfBookmarkGroupsScreen( defaultBookmarks = accountViewModel.account.bookmarkState, oldBookmarks = accountViewModel.account.oldBookmarkState, pinnedNotes = accountViewModel.account.pinState, + repositories = accountViewModel.account.gitRepositoryListState, listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow, openDefaultBookmarks = { nav.nav(Route.Bookmarks) }, openOldBookmarks = { nav.nav(Route.OldBookmarks) }, openPinnedNotes = { nav.nav(Route.PinnedNotes) }, + openRepositories = { nav.nav(Route.BookmarkedRepositories) }, addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) }, openBookmarkGroup = { identifier, bookmarkType -> nav.nav(Route.BookmarkGroupView(identifier, bookmarkType)) @@ -101,10 +104,12 @@ fun ListOfBookmarkGroupsFeed( defaultBookmarks: BookmarkListState, oldBookmarks: OldBookmarkListState, pinnedNotes: PinListState, + repositories: GitRepositoryListState, listSource: StateFlow>, openDefaultBookmarks: () -> Unit, openOldBookmarks: () -> Unit, openPinnedNotes: () -> Unit, + openRepositories: () -> Unit, addBookmarkGroup: () -> Unit, openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit, renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -148,10 +153,12 @@ fun ListOfBookmarkGroupsFeed( defaultBookmarks = defaultBookmarks, oldBookmarks = oldBookmarks, pinnedNotes = pinnedNotes, + repositories = repositories, groupListFeedSource = listSource, openDefaultBookmarks = openDefaultBookmarks, openOldBookmarks = openOldBookmarks, openPinnedNotes = openPinnedNotes, + openRepositories = openRepositories, onOpenItem = openBookmarkGroup, onRenameItem = renameBookmarkGroup, onItemDescriptionChange = changeBookmarkGroupDescription, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/BookmarkedRepositoriesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/BookmarkedRepositoriesScreen.kt new file mode 100644 index 0000000000..9f5749cd77 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/BookmarkedRepositoriesScreen.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.dal.BookmarkRepositoriesFeedViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.Address + +@Composable +fun BookmarkedRepositoriesScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val repositoriesFeedViewModel: BookmarkRepositoriesFeedViewModel = + viewModel( + key = "NostrBookmarkRepositoriesFeedViewModel", + factory = BookmarkRepositoriesFeedViewModel.Factory(accountViewModel.account), + ) + + val repositoryBookmarks by accountViewModel.account.gitRepositoryListState.publicRepositoryAddressSet + .collectAsStateWithLifecycle() + + LaunchedEffect(repositoryBookmarks) { + repositoriesFeedViewModel.invalidateData() + } + + // Preload any bookmarked repo announcements not yet in cache so they don't pop in one by one. + PreloadRepositoryEvents(repositoryBookmarks, accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + TopBarWithBackButton(stringRes(id = R.string.repository_bookmarks), nav) + }, + accountViewModel = accountViewModel, + ) { + RefresheableFeedView( + repositoriesFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +private fun PreloadRepositoryEvents( + repositoryBookmarks: Set
, + accountViewModel: AccountViewModel, +) { + val eventFinder = accountViewModel.dataSources().eventFinder + val account = accountViewModel.account + + val queries = + remember(repositoryBookmarks) { + repositoryBookmarks + .map { account.cache.getOrCreateAddressableNote(it) } + .filter { it.event == null } + .map { EventFinderQueryState(it, account) } + } + + DisposableEffect(queries) { + eventFinder.subscribe(queries) + onDispose { + eventFinder.unsubscribe(queries) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedFilter.kt new file mode 100644 index 0000000000..a04feb1f8e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedFilter.kt @@ -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() { + override fun feedKey(): String = + account.gitRepositoryListState.publicRepositoryAddressSet.value + .hashCode() + .toString() + + override fun feed(): List = + account.gitRepositoryListState.publicRepositoryAddressSet.value + .map { account.cache.getOrCreateAddressableNote(it) } + .sortedByDescending { it.createdAt() ?: 0L } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedViewModel.kt new file mode 100644 index 0000000000..cbef857aec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/repositories/dal/BookmarkRepositoriesFeedViewModel.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.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 create(modelClass: Class): T = BookmarkRepositoriesFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt index 612bb635cf..5b5f3c91e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitItemListRow.kt @@ -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 = + when (event) { + is GitIssueEvent -> event.topics() + is GitPullRequestEvent -> event.labels() + else -> emptyList() + }.filter { it.isNotBlank() }.distinct() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitNewIssue.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitNewIssue.kt new file mode 100644 index 0000000000..cdf10576bc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitNewIssue.kt @@ -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 = + raw + .split(',', ' ', '\n', '\t') + .map { it.trim().removePrefix("#") } + .filter { it.isNotEmpty() } + .distinct() + +private fun sendGitIssue( + accountViewModel: AccountViewModel, + repoNote: AddressableNote, + subject: String, + body: String, + labels: List, +) { + val repositoryHint = repoNote.toEventHint() ?: return + accountViewModel.launchSigner { + val template = GitIssueEvent.build(subject, body, repositoryHint, emptyList(), labels) + val signed = accountViewModel.account.signer.sign(template) + accountViewModel.account.sendAutomatic(signed) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepoSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepoSettings.kt new file mode 100644 index 0000000000..5c490ecbee --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepoSettings.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +/** + * Edit form for a NIP-34 repository announcement (kind 30617). Because the event + * is addressable, saving republishes it under the same `d` tag — preserving the + * earliest-unique-commit, relays, maintainers and personal-fork flag while letting + * the owner edit the name, description, web/clone URLs and topics. + */ +@Composable +fun GitRepoSettingsDialog( + event: GitRepositoryEvent, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, +) { + var name by rememberSaveable { mutableStateOf(event.name() ?: event.dTag()) } + var description by rememberSaveable { mutableStateOf(event.description() ?: "") } + var webUrls by rememberSaveable { mutableStateOf(event.webs().joinToString("\n")) } + var cloneUrls by rememberSaveable { mutableStateOf(event.clones().joinToString("\n")) } + var topics by rememberSaveable { mutableStateOf(event.hashtags().joinToString(", ")) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.git_repo_settings_title)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringRes(R.string.git_repo_settings_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text(stringRes(R.string.git_repo_settings_description)) }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = cloneUrls, + onValueChange = { cloneUrls = it }, + label = { Text(stringRes(R.string.git_repo_settings_clone_urls)) }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = webUrls, + onValueChange = { webUrls = it }, + label = { Text(stringRes(R.string.git_repo_settings_web_urls)) }, + minLines = 1, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = topics, + onValueChange = { topics = it }, + label = { Text(stringRes(R.string.git_repo_settings_topics)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + enabled = name.isNotBlank(), + onClick = { + saveRepository(accountViewModel, event, name.trim(), description.trim(), webUrls, cloneUrls, topics) + onDismiss() + }, + ) { + Text(stringRes(R.string.git_repo_settings_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.git_new_issue_cancel)) } + }, + ) +} + +private fun splitLines(raw: String): List = + raw + .split('\n', ',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + +private fun saveRepository( + accountViewModel: AccountViewModel, + event: GitRepositoryEvent, + name: String, + description: String, + webUrls: String, + cloneUrls: String, + topics: String, +) { + accountViewModel.launchSigner { + val template = + GitRepositoryEvent.build( + name = name, + description = description.ifBlank { null }, + webUrls = splitLines(webUrls), + cloneUrls = splitLines(cloneUrls), + relays = event.relays(), + maintainers = event.maintainers(), + hashtags = splitLines(topics).map { it.removePrefix("#") }, + earliestUniqueCommit = event.earliestUniqueCommit(), + personalFork = event.isPersonalFork(), + dTag = event.dTag(), + ) + val signed = accountViewModel.account.signer.sign(template) + accountViewModel.account.sendAutomatic(signed) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt new file mode 100644 index 0000000000..2800ab6d48 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryHomeUi.kt @@ -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) { + if (slices.isEmpty()) return + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(10.dp) + .clip(CircleShape), + ) { + slices.forEach { slice -> + Box( + modifier = + Modifier + .weight(slice.fraction.coerceAtLeast(0.0001f)) + .height(10.dp) + .background(slice.color), + ) + } + } + FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + slices.forEach { slice -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Box(Modifier.size(9.dp).clip(CircleShape).background(slice.color)) + Text( + text = slice.name + " " + (slice.fraction * 100).toInt() + "%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + } + } + } +} + +private class LangDef( + val name: String, + val color: Long, +) { + override fun equals(other: Any?) = other is LangDef && other.name == name + + override fun hashCode() = name.hashCode() +} + +private val EXT_TO_LANG: Map = + buildMap { + fun add( + def: LangDef, + vararg exts: String, + ) = exts.forEach { put(it, def) } + add(LangDef("Kotlin", 0xFFA97BFF), "kt", "kts") + add(LangDef("Java", 0xFFB07219), "java") + add(LangDef("JavaScript", 0xFFF1E05A), "js", "mjs", "cjs", "jsx") + add(LangDef("TypeScript", 0xFF3178C6), "ts", "tsx") + add(LangDef("Python", 0xFF3572A5), "py") + add(LangDef("Ruby", 0xFF701516), "rb") + add(LangDef("Rust", 0xFFDEA584), "rs") + add(LangDef("Go", 0xFF00ADD8), "go") + add(LangDef("C", 0xFF555555), "c", "h") + add(LangDef("C++", 0xFFF34B7D), "cpp", "cc", "cxx", "hpp", "hh", "hxx") + add(LangDef("C#", 0xFF178600), "cs") + add(LangDef("Swift", 0xFFF05138), "swift") + add(LangDef("PHP", 0xFF4F5D95), "php") + add(LangDef("Shell", 0xFF89E051), "sh", "bash", "zsh", "ksh") + add(LangDef("HTML", 0xFFE34C26), "html", "htm") + add(LangDef("CSS", 0xFF563D7C), "css") + add(LangDef("SCSS", 0xFFC6538C), "scss") + add(LangDef("Markdown", 0xFF083FA1), "md", "markdown") + add(LangDef("JSON", 0xFF40803F), "json") + add(LangDef("YAML", 0xFFCB171E), "yml", "yaml") + add(LangDef("XML", 0xFF0060AC), "xml") + add(LangDef("Gradle", 0xFF02303A), "gradle") + add(LangDef("Dart", 0xFF00B4AB), "dart") + add(LangDef("TOML", 0xFF9C4221), "toml") + } + +private const val OTHER_COLOR = 0xFFBBBBBB + +/** Buckets files by recognised language (by extension), returning the top slices + an "Other" remainder. */ +fun computeLanguageBreakdown(files: List): List { + if (files.isEmpty()) return emptyList() + val counts = HashMap() + var other = 0 + for (f in files) { + val ext = f.substringAfterLast('.', "").lowercase() + val def = EXT_TO_LANG[ext] + if (def == null) other++ else counts[def] = (counts[def] ?: 0) + 1 + } + val total = files.size.toFloat() + val ranked = counts.entries.sortedByDescending { it.value } + val top = ranked.take(6) + val remainder = ranked.drop(6).sumOf { it.value } + other + + val slices = + top.map { LanguageSlice(it.key.name, it.value / total, Color(it.key.color)) }.toMutableList() + if (remainder > 0) { + slices.add(LanguageSlice("Other", remainder / total, Color(OTHER_COLOR))) + } + return slices +} + +// --------------------------------------------------------------------------- +// 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, + 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" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt deleted file mode 100644 index 2420bf175f..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryOverview.kt +++ /dev/null @@ -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, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index 87d70f071f..6298397653 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -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)` override. + */ +internal class GitRepositoryBrowserViewModelFactory( + private val okHttpClient: (String) -> OkHttpClient, +) : ViewModelProvider.Factory { + override fun create(modelClass: Class): 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(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(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(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(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(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 & 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(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, +) { + 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 & 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 { + 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, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt new file mode 100644 index 0000000000..ae03a381ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitBrowseUi.kt @@ -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 = emptyList(), + tags: List = 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, + tags: List, + onSelectRef: (String?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + Row( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .clickable { expanded = true } + .padding(start = 8.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + Icon(MaterialSymbols.AltRoute, contentDescription = null, modifier = Modifier.size(15.dp), tint = MaterialTheme.colorScheme.primary) + Text( + text = current ?: stringRes(R.string.git_repo_default_branch), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + Icon(MaterialSymbols.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (branches.isNotEmpty()) { + SectionHeader(stringRes(R.string.git_repo_branches)) + branches.forEach { name -> + RefMenuItem(name, MaterialSymbols.AltRoute, selected = name == current) { + expanded = false + if (name != current) onSelectRef(name) + } + } + } + if (tags.isNotEmpty()) { + SectionHeader(stringRes(R.string.git_repo_tags)) + tags.forEach { name -> + RefMenuItem(name, MaterialSymbols.Tag, selected = name == current) { + expanded = false + if (name != current) onSelectRef(name) + } + } + } + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) +} + +@Composable +private fun RefMenuItem( + name: String, + symbol: MaterialSymbol, + selected: Boolean, + onClick: () -> Unit, +) { + DropdownMenuItem( + text = { + Text( + name, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) + }, + leadingIcon = { Icon(symbol, contentDescription = null, modifier = Modifier.size(16.dp)) }, + onClick = onClick, + ) +} + +@Composable +private fun InfoChip( + symbol: MaterialSymbol, + label: String, + monospace: Boolean = false, +) { + Row( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + fontFamily = if (monospace) FontFamily.Monospace else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt new file mode 100644 index 0000000000..b322552713 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCodeTab.kt @@ -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(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, + 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, + 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, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCommitLog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCommitLog.kt new file mode 100644 index 0000000000..f818bb51c1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitCommitLog.kt @@ -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(null) } + + val history by + produceState>?>(null, snapshot.headCommit) { + value = + try { + Result.success(viewModel.loadHistory(snapshot)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + } + + val commits = history?.getOrNull() + + if (openCommit != null) { + val commit = remember(openCommit, commits) { commits?.firstOrNull { it.oid == openCommit } } + BackHandler { openCommit = null } + Column(Modifier.fillMaxSize()) { + LogHeader(title = commit?.shortOid ?: "", onBack = { openCommit = null }) + HorizontalDivider(thickness = 0.5.dp) + if (commit == null) { + GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_file_load_error)) + } else { + CommitDiff(snapshot, viewModel, commit) + } + } + return + } + + BackHandler { onBack() } + Column(Modifier.fillMaxSize()) { + LogHeader(title = stringRes(R.string.git_repo_commits), onBack = onBack) + HorizontalDivider(thickness = 0.5.dp) + when { + history == null -> GitLoadingBox(stringRes(R.string.git_repo_code_loading)) + history!!.isFailure -> GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_code_error)) + commits.isNullOrEmpty() -> GitMessageBox(MaterialSymbols.History, stringRes(R.string.git_repo_no_commits)) + else -> + LazyColumn(Modifier.fillMaxSize()) { + items(commits, key = { it.oid }) { commit -> + CommitRow(commit) { openCommit = commit.oid } + HorizontalDivider( + modifier = Modifier.padding(start = 14.dp), + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), + ) + } + } + } + } +} + +@Composable +private fun CommitDiff( + snapshot: GitRepoSnapshot, + viewModel: GitRepositoryBrowserViewModel, + commit: GitCommit, +) { + val result by + produceState?>(null, commit.oid) { + value = + try { + Result.success(viewModel.commitDiff(snapshot, commit)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + } + + when (val r = result) { + null -> GitLoadingBox(stringRes(R.string.git_repo_code_loading)) + else -> + if (r.isFailure || r.getOrThrow().files.isEmpty()) { + GitMessageBox(MaterialSymbols.Code, stringRes(R.string.git_pr_no_changes)) + } else { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(12.dp), + ) { + if (commit.summary.isNotBlank()) { + Text(commit.summary, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text( + text = "${commit.shortOid} · ${commit.authorName}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + modifier = Modifier.padding(bottom = 8.dp), + ) + } + GitDiffView(r.getOrThrow(), Modifier.fillMaxWidth()) + } + } + } +} + +@Composable +private fun CommitRow( + commit: GitCommit, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + symbol = MaterialSymbols.Commit, + contentDescription = null, + modifier = Modifier.size(18.dp).padding(top = 2.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column(Modifier.weight(1f)) { + Text( + text = commit.summary.ifBlank { commit.shortOid }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = commit.shortOid, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f), + modifier = + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 5.dp, vertical = 1.dp), + ) + Text( + text = "${commit.authorName} · ${relativeTime(commit.authorTimeSec)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun LogHeader( + title: String, + onBack: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton(onClick = onBack) { ArrowBackIcon() } + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private fun relativeTime(epochSec: Long): String = + if (epochSec <= 0) { + "" + } else { + DateUtils.getRelativeTimeSpanString(epochSec * 1000L, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt new file mode 100644 index 0000000000..95535b7a94 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitFileViewer.kt @@ -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?>(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> { + val ranges = ArrayList>() + var lineStart = 0 + var i = 0 + while (i < text.length) { + if (text[i] == '\n') { + ranges.add(lineStart to i) + lineStart = i + 1 + } + i++ + } + ranges.add(lineStart to text.length) + if (ranges.size > 1 && ranges.last().let { it.first == it.second }) { + ranges.removeAt(ranges.size - 1) + } + return ranges +} + +private fun prettyLanguage(enumName: String): String = + when (enumName) { + "CPP" -> "C++" + "CSHARP" -> "C#" + "JAVASCRIPT" -> "JavaScript" + "TYPESCRIPT" -> "TypeScript" + "COFFEESCRIPT" -> "CoffeeScript" + "PHP" -> "PHP" + else -> enumName.lowercase().replaceFirstChar { it.uppercase() } + } + +@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" + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt new file mode 100644 index 0000000000..ac2dd315fb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/code/GitReadmeTab.kt @@ -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(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? { + val files = entries.filter { !it.isFolder } + val readmes = files.filter { it.name.substringBeforeLast('.').equals("readme", ignoreCase = true) || it.name.equals("readme", ignoreCase = true) } + if (readmes.isEmpty()) return null + val byPreference = listOf("readme.md", "readme.markdown", "readme.mdown", "readme", "readme.txt", "readme.rst") + for (preferred in byPreference) { + readmes.firstOrNull { it.name.equals(preferred, ignoreCase = true) }?.let { return it } + } + return readmes.first() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt index 7794864652..a3df977d5f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryIssuesFeedViewModel.kt @@ -39,7 +39,6 @@ class RepositoryIssuesFeedViewModel( // Status events (kinds 1630-1633) don't mutate the issue note, so the additive // feed update can't move an item between the Open/Closed buckets on its own. // Watch the status index and force a full re-partition whenever it changes. - GitStatusIndex.startIfNeeded() viewModelScope.launch(Dispatchers.IO) { GitStatusIndex.latestByTarget.collect { invalidateData() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt index 2231f60009..e4cd1ec15d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/dal/RepositoryPatchesFeedViewModel.kt @@ -39,7 +39,6 @@ class RepositoryPatchesFeedViewModel( // Status events (kinds 1630-1633) don't mutate the patch/PR note, so the additive // feed update can't move an item between the Open/Closed buckets on its own. // Watch the status index and force a full re-partition whenever it changes. - GitStatusIndex.startIfNeeded() viewModelScope.launch(Dispatchers.IO) { GitStatusIndex.latestByTarget.collect { invalidateData() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt index f5b05be87d..b47755d2a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/datasource/FilterRepositoryContent.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent +import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent @@ -37,6 +38,7 @@ val RepositoryContentKinds = GitIssueEvent.KIND, GitPatchEvent.KIND, GitPullRequestEvent.KIND, + GitPullRequestUpdateEvent.KIND, GitStatusOpenEvent.KIND, GitStatusAppliedEvent.KIND, GitStatusClosedEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesTopBar.kt index d28ff11f96..4c30842a95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/GitRepositoriesTopBar.kt @@ -58,7 +58,7 @@ private fun GitRepositoriesTopNavFilterBar( accountViewModel: AccountViewModel, onChange: (FeedDefinition) -> Unit, ) { - val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + val allLists by followListsModel.gitRepositoryRoutes.collectAsStateWithLifecycle() FeedFilterSpinner( placeholderCode = listName, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/dal/GitRepositoriesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/dal/GitRepositoriesFeedFilter.kt index abdd4b4335..0f383daee6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/dal/GitRepositoriesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/dal/GitRepositoriesFeedFilter.kt @@ -48,18 +48,36 @@ class GitRepositoriesFeedFilter( override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() override fun feed(): List { - val params = buildFilterParams(account) - val notes = - LocalCache.addressables.filterIntoSet(GitRepositoryEvent.KIND) { _, it -> - val noteEvent = it.event - noteEvent is GitRepositoryEvent && params.match(noteEvent, it.relays) + if (followList() == TopFilter.Mine) { + val me = account.userProfile().pubkeyHex + LocalCache.addressables.filterIntoSet(GitRepositoryEvent.KIND) { _, it -> isMine(it, me) } + } else { + val params = buildFilterParams(account) + LocalCache.addressables.filterIntoSet(GitRepositoryEvent.KIND) { _, it -> + val noteEvent = it.event + noteEvent is GitRepositoryEvent && params.match(noteEvent, it.relays) + } } return sort(notes) } - override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + override fun applyFilter(newItems: Set): Set { + if (followList() == TopFilter.Mine) { + val me = account.userProfile().pubkeyHex + return newItems.filterTo(HashSet()) { isMine(it, me) } + } + return innerApplyFilter(newItems) + } + + private fun isMine( + note: Note, + me: String, + ): Boolean { + val noteEvent = note.event + return noteEvent is GitRepositoryEvent && noteEvent.pubKey == me + } fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesSubAssembler.kt index 6fb5fc19c4..1c837ee555 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/GitRepositoriesSubAssembler.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies.filterGitRepositoriesMine import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription @@ -42,6 +43,13 @@ class GitRepositoriesSubAssembler( key: GitRepositoriesQueryState, since: SincePerRelayMap?, ): List { + // "Mine" bypasses the follow-list machinery: query the user's own repositories by author + // against their outbox relays (same pattern as music/badges), because the shared + // TopFilter.Mine flow falls back to all-follows. + if (key.listName() == TopFilter.Mine) { + val outbox = key.account.outboxRelays.flow.value + return filterGitRepositoriesMine(key.account.userProfile().pubkeyHex, outbox, since) + } val feedSettings = key.followsPerRelay() return makeGitRepositoriesFilter(feedSettings, since, key.feedStates.gitRepositoriesFeed.lastNoteCreatedAtIfFilled()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesMine.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesMine.kt new file mode 100644 index 0000000000..7e6396f168 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepositories/datasource/subassemblies/FilterGitRepositoriesMine.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies + +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent + +/** + * Builds the relay filters for the "Mine" repository selector: the user's own repository + * announcements, queried by author against their own outbox relays. Mirrors + * `filterNappletsMine` / `filterMusicEventsMine` — the only correct source for "my own" + * content, since the shared `TopFilter.Mine` flow falls back to all-follows. + */ +fun filterGitRepositoriesMine( + pubkey: HexKey, + relays: Set, + since: SincePerRelayMap?, +): List { + if (relays.isEmpty() || pubkey.isEmpty()) return emptyList() + val authors = listOf(pubkey) + return relays.map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(GitRepositoryEvent.KIND), + authors = authors, + limit = 200, + since = since?.get(relay)?.time, + ), + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b88a199ff3..a80072af2b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1016,6 +1016,8 @@ Private Bookmarks Public Bookmarks + Repositories + Your bookmarked git repositories Add to Private Bookmarks Add to Public Bookmarks Remove from Private Bookmarks @@ -2672,12 +2674,77 @@ Patches & PRs Open Closed & Resolved + All + Bookmark repository + Branches + Tags + Files + Updated + Recent activity + Maintained by + Hosted externally + This repository can\'t be cloned over http(s), so the code view isn\'t available here. + Open in browser Untitled About Links Maintainers Topics Personal fork + Readme + Code + Loading repository… + Could not load the repository from its clone URL. + This repository announcement has no http(s) clone URL to browse. + This repository has no README file. + Could not load this file. + Binary file (%1$s) — preview not available. + This folder is empty. + root + Retry + default + Branches + Tags + Search files… + No matching files. + Commits + No commit history available. + Copy file contents + Text + + %1$d item + %1$d items + + + %1$d file changed + %1$d files changed + + Binary file not shown. + New + New issue + Title + Description + Create + Cancel + Labels + bug, enhancement… + Close issue + Reopen + Close + Reopen + Mark merged + View changes + Loading changes… + No file changes found. + Retry loading changes + Revised + Edit repository + Name + Description + Clone URLs (one per line) + Web URLs (one per line) + Topics (comma separated) + Save Git Repositories nSite: %1$s nApplet: %1$s diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 44cb23f0d3..4c0d5532bd 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -122,6 +122,9 @@ kotlin { // Compose Multiplatform Resources implementation(libs.jetbrains.compose.components.resources) + + // KMP syntax highlighter (Apache-2.0) for the git code browser. + implementation(libs.highlights) } } diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index 79ccdd8713..9aeb9602e5 100644 Binary files a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index d5e253298d..168fe2f1b5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -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") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/GitRepositoryListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/GitRepositoryListState.kt new file mode 100644 index 0000000000..c69822397f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip51Lists/GitRepositoryListState.kt @@ -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 = repositoryList.flow().metadata.stateFlow + + fun getList(): GitRepositoryListEvent? = repositoryList.event as? GitRepositoryListEvent + + private fun publicAddresses(note: Note): Set
= (note.event as? GitRepositoryListEvent)?.publicRepositories()?.map { it.address }?.toSet() ?: emptySet() + + @OptIn(FlowPreview::class) + val publicRepositoryAddressSet: StateFlow> = + getListFlow() + .map { publicAddresses(it.note) } + .onStart { emit(publicAddresses(repositoryList)) } + .debounce(100) + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + fun isBookmarked(address: Address): Boolean = publicRepositoryAddressSet.value.contains(address) + + /** Adds [note]'s address to the public list, creating the list if needed. */ + suspend fun addRepository(note: AddressableNote): GitRepositoryListEvent { + val list = getList() + val bookmark = AddressBookmark(note.address, note.relayHintUrl()) + return if (list == null) { + GitRepositoryListEvent.create(publicRepositories = listOf(bookmark), signer = signer) + } else { + GitRepositoryListEvent.add(earlierVersion = list, repository = bookmark, isPrivate = false, signer = signer) + } + } + + /** Removes [note]'s address from the public list, leaving the private section intact. */ + suspend fun removeRepository(note: AddressableNote): GitRepositoryListEvent? { + val list = getList() ?: return null + val idTag = AddressBookmark(note.address, note.relayHintUrl()).toTagIdOnly() + val newTags = list.tags.remove(idTag) + if (newTags.size == list.tags.size) return null + return GitRepositoryListEvent.resign(content = list.content, tags = newTags, signer = signer) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/CodeHighlighter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/CodeHighlighter.kt new file mode 100644 index 0000000000..e5c566b64d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/ui/CodeHighlighter.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.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) + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt index 2094e04a70..eb2b94bd87 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt @@ -39,6 +39,15 @@ import androidx.compose.ui.unit.dp */ val LocalDisappearingScaffoldPadding = compositionLocalOf { PaddingValues(0.dp) } +/** + * The surrounding [DisappearingScaffold]'s bar state, exposed so inner content that swaps its + * scrollable in place — and thereby resets the scroll position to the top without emitting a + * scroll delta — can pull the bar back into view. Without this the bar can stay stuck at its + * hidden offset over fresh top-of-list content, leaving a blank band. Null when no scaffold + * provides it. + */ +val LocalDisappearingBarState = compositionLocalOf { null } + /** * Merges two [PaddingValues] component-wise, resolving start/end against the current * [LocalLayoutDirection]. diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepoSnapshotCache.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepoSnapshotCache.kt new file mode 100644 index 0000000000..ab085f73d7 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepoSnapshotCache.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.nip34Git + +import androidx.collection.LruCache +import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot + +/** + * Process-wide cache of fetched default-branch repository snapshots, keyed by repository + * address (`kind:pubkey:dTag`). + * + * A snapshot is a shallow clone fetched over the network, so re-cloning on every screen — or + * during the share-to-image capture, which only waits ~1s for content to settle — produces + * empty stats. Caching the default-branch snapshot lets the project home, the feed repo card + * and the image renderer reuse it synchronously and avoids the repeated fetch the user sees + * when switching screens. Bounded so a few large repos can't grow memory without limit. + */ +object GitRepoSnapshotCache { + private val cache = LruCache(8) + + fun get(key: String?): GitRepoSnapshot? = key?.let { cache.get(it) } + + fun put( + key: String?, + snapshot: GitRepoSnapshot, + ) { + if (key != null) cache.put(key, snapshot) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepositoryBrowserViewModel.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepositoryBrowserViewModel.kt new file mode 100644 index 0000000000..09312909d8 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip34Git/GitRepositoryBrowserViewModel.kt @@ -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.Loading) + val state = _state.asStateFlow() + + private var cloneUrls: List = emptyList() + private var started = false + private var cacheKey: String? = null + + /** The branch/tag currently displayed (null = the server's default branch). */ + var currentRef: String? = null + 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, + 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 = withContext(Dispatchers.IO) { client.loadHistory(snapshot.cloneUrl, snapshot.headCommit) } + + /** The diff a commit introduced (commit vs its first parent). */ + suspend fun commitDiff( + snapshot: GitRepoSnapshot, + commit: GitCommit, + ): ParsedPatch = + withContext(Dispatchers.IO) { + client.computeDiff(snapshot.cloneUrl, commit.oid, commit.parents.firstOrNull()) + } + + /** http(s) clone URLs to try, in order, including a `.git` variant when missing. */ + private fun candidateUrls(): List { + val out = LinkedHashSet() + 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" + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 05adc89478..bb2e78c405 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"} diff --git a/quartz/plans/2026-06-28-git-smart-http-browser.md b/quartz/plans/2026-06-28-git-smart-http-browser.md new file mode 100644 index 0000000000..fb48f89393 --- /dev/null +++ b/quartz/plans/2026-06-28-git-smart-http-browser.md @@ -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 (` \0<20-byte oid>` tree entries; `tree ` + from a commit). +- `Packfile` — parses a v2 packfile: object headers, zlib inflate per object, + `OBJ_OFS_DELTA` / `OBJ_REF_DELTA` resolution, and SHA-1 oid computation + (`sha1(" \0" + content)`). +- `GitDelta` — copy/insert delta instruction decoder. +- `GitSmartHttpTransport` — the three HTTP exchanges: + 1. `GET {clone}/info/refs?service=git-upload-pack` (`Git-Protocol: version=2`) + → capability advertisement (we read `fetch` features incl. `filter`, and + `object-format`). + 2. `POST {clone}/git-upload-pack` `command=ls-refs` → HEAD oid + default branch. + 3. `POST {clone}/git-upload-pack` `command=fetch` → sideband-framed packfile. +- `GitHttpRepository` / `GitHttpClient` — high level: + - `loadRepository(cloneUrl)` → `fetch want deepen 1 filter blob:none`, + yielding the commit + **all** trees in one shallow request. Builds + `treeOid -> entries` and a navigable snapshot (`entriesAt(path)`). + - `loadBlob(oid)` → lazy partial-clone fetch `want filter blob:none` + (cached). Falls back to a full `deepen 1` (no filter) snapshot for servers + that don't advertise `filter`, in which case all blobs arrive up-front. + +### Why this shape + +`filter blob:none` + `deepen 1` is exactly how a git partial clone browses a tip +without downloading file contents; lazy blob-by-oid fetch is the same mechanism +git uses to backfill missing blobs, so any server that advertises `filter` +supports both. We do **not** request `thin-pack`, so packs are self-contained and +deltas are `OFS_DELTA` (offset based) — no cross-pack base lookups. + +Validated end-to-end against `github.com/octocat/Hello-World.git`; the captured +wire bytes are checked in as offline test fixtures (CI-safe, no network). + +## UI (`amethyst`) + +- Tabs become: **README**, **Code**, Overview, Issues, Patches. +- `GitReadmeTab` — fetches `README(.md/.markdown/...)` from the root tree, renders + via `RenderContentAsMarkdown`; falls back to the repo description / overview. +- `GitCodeTab` — file browser (breadcrumb + folders-first listing) backed by a + `GitRepositoryBrowserViewModel`; tapping a file opens `GitFileViewer`. +- `GitFileViewer` — markdown for `.md`, otherwise monospace + syntax highlighting + via the `dev.snipme:highlights` KMP library (Apache-2.0 — permissive, OK). + +## Out of scope (future) + +- Writing/committing, branch switching beyond HEAD, history/blame, large-binary + preview, sha256 object-format repos. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/IntralineDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/IntralineDiff.kt new file mode 100644 index 0000000000..dbfae3a4a5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/IntralineDiff.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.patch + +/** A half-open `[start, end)` character span within a line. */ +class CharSpan( + val start: Int, + val end: Int, +) { + val isEmpty: Boolean get() = end <= start +} + +/** + * Computes intra-line (word/character level) change spans for a hunk, so the diff + * renderer can emphasize *what* changed inside a modified line rather than just + * coloring the whole line. Contiguous delete-runs are paired with the following + * add-runs (delete[i] ↔ add[i]); each pair is reduced to the differing middle by + * trimming the common prefix and suffix. + */ +object IntralineDiff { + /** The changed middle of [old] vs [new] (common prefix/suffix trimmed). */ + fun changedSpans( + old: String, + new: String, + ): Pair { + if (old == new) return CharSpan(0, 0) to CharSpan(0, 0) + val n = old.length + val m = new.length + var prefix = 0 + while (prefix < n && prefix < m && old[prefix] == new[prefix]) prefix++ + var suffix = 0 + while (suffix < n - prefix && suffix < m - prefix && old[n - 1 - suffix] == new[m - 1 - suffix]) suffix++ + return CharSpan(prefix, n - suffix) to CharSpan(prefix, m - suffix) + } + + /** + * Returns, per line index in [lines], the changed span to emphasize. Only + * paired modified lines get an entry; pure add/delete blocks and context + * lines are absent. + */ + fun emphasis(lines: List): Map { + val result = HashMap() + var i = 0 + while (i < lines.size) { + if (lines[i].type != GitDiffLineType.DELETE) { + i++ + continue + } + val delStart = i + while (i < lines.size && lines[i].type == GitDiffLineType.DELETE) i++ + val delEnd = i + val addStart = i + while (i < lines.size && lines[i].type == GitDiffLineType.ADD) i++ + val addEnd = i + + val pairs = minOf(delEnd - delStart, addEnd - addStart) + for (k in 0 until pairs) { + val delIdx = delStart + k + val addIdx = addStart + k + val (oldSpan, newSpan) = changedSpans(lines[delIdx].content, lines[addIdx].content) + if (!oldSpan.isEmpty) result[delIdx] = oldSpan + if (!newSpan.isEmpty) result[addIdx] = newSpan + } + } + return result + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/LineDiff.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/LineDiff.kt new file mode 100644 index 0000000000..936a6d83ee --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/LineDiff.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.patch + +/** + * Computes a line-level unified diff between two texts using Myers' O(ND) + * difference algorithm, producing the same [GitDiffHunk] model the embedded + * patch parser uses. This lets the app render a diff for pull requests that + * reference a clone URL + commit instead of embedding a `git format-patch`. + */ +object LineDiff { + private class Op( + val type: GitDiffLineType, + val oldIndex: Int, // 0-based index into old lines, -1 for inserts + val newIndex: Int, // 0-based index into new lines, -1 for deletes + ) + + /** + * Builds unified-diff hunks between [oldLines] and [newLines], with [context] + * unchanged lines around each change (git's default is 3). + */ + fun hunks( + oldLines: List, + newLines: List, + context: Int = 3, + ): List { + val ops = computeOps(oldLines, newLines) + return groupHunks(ops, oldLines, newLines, context) + } + + /** Myers O(ND) diff: returns an ordered op list (CONTEXT / DELETE / ADD). */ + private fun computeOps( + a: List, + b: List, + ): List { + val n = a.size + val m = b.size + if (n == 0 && m == 0) return emptyList() + val max = n + m + val offset = max + val trace = ArrayList() + val v = IntArray(2 * max + 1) + + var found = false + for (d in 0..max) { + trace.add(v.copyOf()) + var k = -d + while (k <= d) { + val idx = k + offset + var x = + if (k == -d || (k != d && v[idx - 1] < v[idx + 1])) { + v[idx + 1] + } else { + v[idx - 1] + 1 + } + var y = x - k + while (x < n && y < m && a[x] == b[y]) { + x++ + y++ + } + v[idx] = x + if (x >= n && y >= m) { + found = true + break + } + k += 2 + } + if (found) break + } + + // Backtrack to recover the edit script. + val reversed = ArrayList() + var x = n + var y = m + for (d in trace.indices.reversed()) { + val vv = trace[d] + val k = x - y + val idx = k + offset + val prevK = + if (k == -d || (k != d && vv[idx - 1] < vv[idx + 1])) { + k + 1 + } else { + k - 1 + } + val prevIdx = prevK + offset + val prevX = vv[prevIdx] + val prevY = prevX - prevK + + while (x > prevX && y > prevY) { + reversed.add(Op(GitDiffLineType.CONTEXT, x - 1, y - 1)) + x-- + y-- + } + if (d > 0) { + if (x == prevX) { + reversed.add(Op(GitDiffLineType.ADD, -1, y - 1)) + } else { + reversed.add(Op(GitDiffLineType.DELETE, x - 1, -1)) + } + } + x = prevX + y = prevY + } + reversed.reverse() + return reversed + } + + private fun groupHunks( + ops: List, + oldLines: List, + newLines: List, + context: Int, + ): List { + if (ops.none { it.type != GitDiffLineType.CONTEXT }) return emptyList() + + val changeIndexes = ops.indices.filter { ops[it].type != GitDiffLineType.CONTEXT } + val hunks = ArrayList() + + var i = 0 + while (i < changeIndexes.size) { + val start = changeIndexes[i] + var end = start + // Extend the hunk while the next change is within 2*context of the previous. + var j = i + while (j + 1 < changeIndexes.size && changeIndexes[j + 1] - changeIndexes[j] <= 2 * context + 1) { + j++ + end = changeIndexes[j] + } + val from = (start - context).coerceAtLeast(0) + val to = (end + context).coerceAtMost(ops.size - 1) + + val lines = ArrayList() + var oldStart = -1 + var newStart = -1 + var oldCount = 0 + var newCount = 0 + for (idx in from..to) { + val op = ops[idx] + val oldNo = if (op.oldIndex >= 0) op.oldIndex + 1 else null + val newNo = if (op.newIndex >= 0) op.newIndex + 1 else null + val content = if (op.type == GitDiffLineType.ADD) newLines[op.newIndex] else oldLines[op.oldIndex] + lines.add(GitDiffLine(op.type, content, oldNo, newNo)) + if (op.type != GitDiffLineType.ADD) { + if (oldStart < 0) oldStart = op.oldIndex + 1 + oldCount++ + } + if (op.type != GitDiffLineType.DELETE) { + if (newStart < 0) newStart = op.newIndex + 1 + newCount++ + } + } + if (oldStart < 0) oldStart = 0 + if (newStart < 0) newStart = 0 + val header = "@@ -$oldStart,$oldCount +$newStart,$newCount @@" + hunks.add(GitDiffHunk(header, oldStart, newStart, lines)) + + i = j + 1 + } + return hunks + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/UnifiedDiffParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/UnifiedDiffParser.kt new file mode 100644 index 0000000000..9cc53a2fc3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip34Git/patch/UnifiedDiffParser.kt @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.patch + +/** How a file changed within a diff. */ +enum class GitFileChange { + ADD, + DELETE, + MODIFY, + RENAME, +} + +/** The role of a single line inside a hunk. */ +enum class GitDiffLineType { + CONTEXT, + ADD, + DELETE, +} + +class GitDiffLine( + val type: GitDiffLineType, + val content: String, + val oldNumber: Int?, + val newNumber: Int?, +) + +class GitDiffHunk( + val header: String, + val oldStart: Int, + val newStart: Int, + val lines: List, +) + +class GitDiffFile( + val oldPath: String?, + val newPath: String?, + val change: GitFileChange, + val isBinary: Boolean, + val hunks: List, +) { + val displayPath: String get() = newPath ?: oldPath ?: "?" + val additions: Int get() = hunks.sumOf { hunk -> hunk.lines.count { it.type == GitDiffLineType.ADD } } + val deletions: Int get() = hunks.sumOf { hunk -> hunk.lines.count { it.type == GitDiffLineType.DELETE } } +} + +/** A parsed patch: the human-readable commit message and the changed files. */ +class ParsedPatch( + val message: String, + val files: List, +) { + val hasDiff: Boolean get() = files.isNotEmpty() + val totalAdditions: Int get() = files.sumOf { it.additions } + val totalDeletions: Int get() = files.sumOf { it.deletions } +} + +/** + * Parses a `git format-patch` / unified-diff string (the `content` of a NIP-34 + * patch event, kind 1617) into a commit message plus a structured list of file + * diffs. Tolerant of partial input: anything that isn't a recognizable diff is + * returned as the [ParsedPatch.message]. + */ +object UnifiedDiffParser { + private val DIFF_GIT_PREFIX = "diff --git " + private val HUNK_PREFIX = "@@" + private val MBOX_FROM = Regex("^From [0-9a-fA-F]{7,40}\\b.*") + private val HEADER_KEYS = + setOf( + "from", + "date", + "subject", + "mime-version", + "content-type", + "content-transfer-encoding", + "message-id", + "in-reply-to", + "references", + ) + + fun parse(patch: String): ParsedPatch { + val lines = patch.split("\n") + val firstDiff = lines.indexOfFirst { it.startsWith(DIFF_GIT_PREFIX) } + + if (firstDiff < 0) { + return ParsedPatch(cleanMessage(lines), emptyList()) + } + + val message = cleanMessage(lines.subList(0, firstDiff)) + val files = parseFiles(lines, firstDiff) + return ParsedPatch(message, files) + } + + private fun parseFiles( + lines: List, + from: Int, + ): List { + // Split into per-file blocks at each "diff --git" line. + val blocks = ArrayList() + var blockStart = -1 + for (i in from until lines.size) { + if (lines[i].startsWith(DIFF_GIT_PREFIX)) { + if (blockStart >= 0) blocks.add(blockStart until i) + blockStart = i + } + } + if (blockStart >= 0) blocks.add(blockStart until lines.size) + + return blocks.map { parseFile(lines, it) } + } + + private fun parseFile( + lines: List, + range: IntRange, + ): GitDiffFile { + var oldPath: String? + var newPath: String? + run { + val header = lines[range.first].removePrefix(DIFF_GIT_PREFIX) + val sep = header.indexOf(" b/") + if (sep >= 0) { + oldPath = + header + .substring(0, sep) + .removePrefix("a/") + .trim() + .ifBlank { null } + newPath = + header + .substring(sep + 1) + .removePrefix("b/") + .trim() + .ifBlank { null } + } else { + oldPath = null + newPath = null + } + } + + var change = GitFileChange.MODIFY + var isBinary = false + + var i = range.first + 1 + while (i <= range.last && !lines[i].startsWith(HUNK_PREFIX)) { + val line = lines[i] + when { + line.startsWith("new file mode") -> change = GitFileChange.ADD + line.startsWith("deleted file mode") -> change = GitFileChange.DELETE + line.startsWith("rename from ") -> { + change = GitFileChange.RENAME + oldPath = line.removePrefix("rename from ").trim() + } + line.startsWith("rename to ") -> { + change = GitFileChange.RENAME + newPath = line.removePrefix("rename to ").trim() + } + line.startsWith("--- ") -> oldPath = resolveSidePath(line.removePrefix("--- "), oldPath) + line.startsWith("+++ ") -> newPath = resolveSidePath(line.removePrefix("+++ "), newPath) + line.startsWith("Binary files") || line.startsWith("GIT binary patch") -> isBinary = true + } + i++ + } + + val hunks = if (isBinary) emptyList() else parseHunks(lines, i, range.last) + if (oldPath == null && newPath != null && change == GitFileChange.MODIFY) change = GitFileChange.ADD + if (newPath == null && oldPath != null && change == GitFileChange.MODIFY) change = GitFileChange.DELETE + return GitDiffFile(oldPath, newPath, change, isBinary, hunks) + } + + private fun parseHunks( + lines: List, + from: Int, + last: Int, + ): List { + val hunks = ArrayList() + var i = from + while (i <= last) { + if (!lines[i].startsWith(HUNK_PREFIX)) { + i++ + continue + } + val header = lines[i] + val (oldStart, oldLen, newStart, newLen) = parseHunkHeader(header) + val hunkLines = ArrayList() + var oldNo = oldStart + var newNo = newStart + // Bound the hunk by its declared line counts so trailing content (e.g. the + // format-patch "-- " signature) is never mistaken for a deletion. + var remainingOld = oldLen + var remainingNew = newLen + i++ + while (i <= last && (remainingOld > 0 || remainingNew > 0)) { + val line = lines[i] + if (line.startsWith(HUNK_PREFIX) || line.startsWith(DIFF_GIT_PREFIX)) break + when { + line.startsWith("+") -> { + hunkLines.add(GitDiffLine(GitDiffLineType.ADD, line.substring(1), null, newNo)) + newNo++ + remainingNew-- + } + line.startsWith("-") -> { + hunkLines.add(GitDiffLine(GitDiffLineType.DELETE, line.substring(1), oldNo, null)) + oldNo++ + remainingOld-- + } + line.startsWith("\\") -> {} // "\ No newline at end of file" + else -> { + val text = if (line.startsWith(" ")) line.substring(1) else line + hunkLines.add(GitDiffLine(GitDiffLineType.CONTEXT, text, oldNo, newNo)) + oldNo++ + newNo++ + remainingOld-- + remainingNew-- + } + } + i++ + } + hunks.add(GitDiffHunk(header, oldStart, newStart, hunkLines)) + } + return hunks + } + + private data class HunkHeader( + val oldStart: Int, + val oldLen: Int, + val newStart: Int, + val newLen: Int, + ) + + /** Parses `@@ -oldStart,oldLen +newStart,newLen @@ section`. Omitted lengths default to 1. */ + private fun parseHunkHeader(header: String): HunkHeader { + // header looks like: @@ -12,7 +12,9 @@ optional context + val body = header.removePrefix("@@").substringBefore("@@").trim() + var oldStart = 0 + var oldLen = 1 + var newStart = 0 + var newLen = 1 + for (token in body.split(' ')) { + when { + token.startsWith("-") -> { + val v = token.removePrefix("-") + oldStart = v.substringBefore(',').toIntOrNull() ?: 0 + oldLen = if (',' in v) v.substringAfter(',').toIntOrNull() ?: 1 else 1 + } + token.startsWith("+") -> { + val v = token.removePrefix("+") + newStart = v.substringBefore(',').toIntOrNull() ?: 0 + newLen = if (',' in v) v.substringAfter(',').toIntOrNull() ?: 1 else 1 + } + } + } + return HunkHeader(oldStart, oldLen, newStart, newLen) + } + + /** Resolves a `--- ` / `+++ ` target: `/dev/null` clears the side; a real path sets it; blank keeps [current]. */ + private fun resolveSidePath( + raw: String, + current: String?, + ): String? { + val path = raw.trim() + if (path == "/dev/null") return null + val cleaned = path.removePrefix("a/").removePrefix("b/") + return cleaned.ifBlank { current } + } + + /** Extracts the commit-message body from a format-patch preamble, dropping mbox/email headers and the diffstat. */ + private fun cleanMessage(lines: List): String { + var i = 0 + if (i < lines.size && MBOX_FROM.matches(lines[i])) i++ + + val looksLikeHeaders = i < lines.size && lines[i].substringBefore(':').lowercase() in HEADER_KEYS + if (looksLikeHeaders) { + while (i < lines.size && lines[i].isNotBlank()) i++ + if (i < lines.size && lines[i].isBlank()) i++ + } + + val body = StringBuilder() + while (i < lines.size) { + if (lines[i].trimEnd() == "---") break + body.append(lines[i]).append('\n') + i++ + } + return body.toString().trim() + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitDelta.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitDelta.kt new file mode 100644 index 0000000000..6b88a633cf --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitDelta.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +/** + * Applies a git delta ([delta]) against a [base] object payload, reconstructing + * the target object. This is the format used by `OBJ_OFS_DELTA` / `OBJ_REF_DELTA` + * packfile entries. + * + * Layout: a base-size varint, a target-size varint, then a stream of + * instructions — copy (high bit set, offset/size assembled from the selected + * following bytes) or insert (a literal run of `cmd` bytes). + */ +object GitDelta { + fun apply( + base: ByteArray, + delta: ByteArray, + ): ByteArray { + var pos = 0 + + val baseSize = readVarInt(delta) { pos }.also { pos = it.second }.first + require(baseSize == base.size) { + "delta base size mismatch: header says $baseSize, base is ${base.size}" + } + val targetSize = readVarInt(delta) { pos }.also { pos = it.second }.first + + val out = ByteArray(targetSize) + var outPos = 0 + + while (pos < delta.size) { + val cmd = delta[pos++].toInt() and 0xFF + if (cmd and 0x80 != 0) { + // copy from base + var copyOffset = 0 + var copySize = 0 + if (cmd and 0x01 != 0) copyOffset = copyOffset or (delta[pos++].toInt() and 0xFF) + if (cmd and 0x02 != 0) copyOffset = copyOffset or ((delta[pos++].toInt() and 0xFF) shl 8) + if (cmd and 0x04 != 0) copyOffset = copyOffset or ((delta[pos++].toInt() and 0xFF) shl 16) + if (cmd and 0x08 != 0) copyOffset = copyOffset or ((delta[pos++].toInt() and 0xFF) shl 24) + if (cmd and 0x10 != 0) copySize = copySize or (delta[pos++].toInt() and 0xFF) + if (cmd and 0x20 != 0) copySize = copySize or ((delta[pos++].toInt() and 0xFF) shl 8) + if (cmd and 0x40 != 0) copySize = copySize or ((delta[pos++].toInt() and 0xFF) shl 16) + if (copySize == 0) copySize = 0x10000 + base.copyInto(out, outPos, copyOffset, copyOffset + copySize) + outPos += copySize + } else if (cmd != 0) { + // insert literal: the next `cmd` bytes + delta.copyInto(out, outPos, pos, pos + cmd) + outPos += cmd + pos += cmd + } else { + throw IllegalArgumentException("invalid delta opcode 0x00") + } + } + + require(outPos == targetSize) { "delta produced $outPos bytes, expected $targetSize" } + return out + } + + /** Little-endian base-128 varint as used in delta size headers. */ + private inline fun readVarInt( + data: ByteArray, + startPos: () -> Int, + ): Pair { + var pos = startPos() + var value = 0 + var shift = 0 + while (true) { + val b = data[pos++].toInt() and 0xFF + value = value or ((b and 0x7F) shl shift) + if (b and 0x80 == 0) break + shift += 7 + } + return value to pos + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt new file mode 100644 index 0000000000..8c296f5ea7 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt @@ -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>() + val blobs = HashMap() + for ((oid, obj) in objects) { + when (obj.type) { + GitObjectType.TREE -> trees[oid] = GitObjectParser.parseTree(obj.data) + GitObjectType.BLOB -> blobs[oid] = obj.data + else -> {} + } + } + + val 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 { + require(cloneUrl.startsWith("http://") || cloneUrl.startsWith("https://")) { + "unsupported git transport (only http/https): $cloneUrl" + } + val caps = transport.fetchCapabilities(cloneUrl) + if (!caps.supportsFetch) throw GitHttpException("server does not speak git protocol v2 (fetch)") + + val start = + startCommit?.takeIf { it.isNotBlank() } + ?: run { + if (!caps.supportsLsRefs) throw GitHttpException("server can't list refs") + val refs = transport.lsRefs(cloneUrl, caps) + selectHead(refs, null)?.oid ?: throw GitHttpException("could not resolve HEAD") + } + + val pack = + transport.fetchPack( + cloneUrl = cloneUrl, + caps = caps, + wants = listOf(start), + deepen = depth, + // tree:0 → commits only; if the server can't filter, blob:none still works (we ignore the trees). + filter = if (caps.supportsFilter) "tree:0" else "blob:none", + ) + val objects = Packfile.parse(pack) + val commits = HashMap() + for (obj in objects.values) { + if (obj.type == GitObjectType.COMMIT) { + val commit = GitObjectParser.parseCommit(obj.oid, obj.data) + commits[commit.oid] = commit + } + } + + // Walk parents most-recent-first (like `git log`), bounded by depth and the shallow set. + val result = ArrayList() + val visited = HashSet() + val frontier = PriorityQueue(compareByDescending { it.authorTimeSec }) + commits[start]?.let { + frontier.add(it) + visited.add(start) + } + while (frontier.isNotEmpty() && result.size < depth) { + val commit = frontier.poll() + result.add(commit) + for (parent in commit.parents) { + if (parent !in visited) { + commits[parent]?.let { + frontier.add(it) + visited.add(parent) + } + } + } + } + return result + } + + /** + * Computes the diff a pull request introduces: the changes between + * [baseCommit] (or, when null, the server's HEAD) and [headCommit]. Fetches + * 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>() + val blobs = HashMap() + for ((oid, obj) in objects) { + when (obj.type) { + GitObjectType.TREE -> trees[oid] = GitObjectParser.parseTree(obj.data) + GitObjectType.BLOB -> blobs[oid] = obj.data + else -> {} + } + } + + val headFiles = collectFiles(trees, headTreeOid) + val baseFiles = collectFiles(trees, baseTreeOid) + + // 2. Classify changes and gather the blob oids we still need. + data class Change( + val path: String, + val oldOid: String?, + val newOid: String?, + val change: GitFileChange, + ) + val changes = ArrayList() + for (path in (headFiles.keys + baseFiles.keys).toSortedSet()) { + val oldOid = baseFiles[path] + val newOid = headFiles[path] + when { + oldOid == null && newOid != null -> changes.add(Change(path, null, newOid, GitFileChange.ADD)) + newOid == null && oldOid != null -> changes.add(Change(path, oldOid, null, GitFileChange.DELETE)) + oldOid != null && newOid != null && oldOid != newOid -> + changes.add(Change(path, oldOid, newOid, GitFileChange.MODIFY)) + } + } + + val needed = changes.flatMap { listOfNotNull(it.oldOid, it.newOid) }.filter { it !in blobs }.distinct() + if (needed.isNotEmpty() && caps.supportsFilter) { + val blobPack = transport.fetchPack(cloneUrl, caps, needed, deepen = null, 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, + commitOid: String, + ): String { + val commit = objects[commitOid] ?: throw GitHttpException("commit $commitOid missing from pack") + if (commit.type != GitObjectType.COMMIT) throw GitHttpException("$commitOid is not a commit") + return GitObjectParser.parseCommitTree(commit.data) ?: throw GitHttpException("commit $commitOid has no tree") + } + + private fun collectFiles( + trees: Map>, + rootTreeOid: String, + ): Map { + val out = HashMap() + + fun walk( + treeOid: String, + prefix: String, + ) { + val entries = trees[treeOid] ?: return + for (entry in entries) { + val path = if (prefix.isEmpty()) entry.name else "$prefix/${entry.name}" + when { + entry.isFolder -> walk(entry.oid, path) + entry.isSubmodule -> {} // submodule pointers aren't file content + else -> out[path] = entry.oid + } + } + } + walk(rootTreeOid, "") + return out + } + + private fun toLines(bytes: ByteArray?): List { + if (bytes == null || bytes.isEmpty()) return emptyList() + val text = bytes.decodeToString() + val parts = text.split("\n") + return if (text.endsWith("\n")) parts.dropLast(1) else parts + } + + private fun isBinary(bytes: ByteArray): Boolean { + val limit = minOf(bytes.size, 8000) + for (i in 0 until limit) if (bytes[i].toInt() == 0) return true + return false + } + + /** Picks the ref to browse: an explicit [ref] if given, otherwise HEAD. */ + private fun selectHead( + refs: List, + 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 = emptyList(), + val tags: List = emptyList(), + private val rootTreeOid: String, + private val trees: Map>, + blobs: Map, + private val transport: GitSmartHttpTransport, + private val caps: GitCapabilities, + /** The tip commit object, parsed up-front so the UI can show it without another fetch. */ + val tipCommit: GitCommit? = null, +) { + private val blobs = HashMap(blobs) + private val blobMutex = Mutex() + + /** Entries at the repository root, folders first. */ + fun rootEntries(): List = sortForDisplay(trees[rootTreeOid].orEmpty()) + + /** + * Every blob (file) path reachable from the tip tree, depth-first. Folders and submodules + * are not included. Drives the home screen's language breakdown; the whole tree is already + * in memory (the snapshot is fetched with `blob:none`, so all trees came down). + */ + fun walkFileNames(): List { + val out = ArrayList() + val stack = ArrayDeque>() + stack.addLast(rootTreeOid to "") + val seen = HashSet() + while (stack.isNotEmpty()) { + val (treeOid, prefix) = stack.removeLast() + if (!seen.add(treeOid + "@" + prefix)) continue + val entries = trees[treeOid] ?: continue + for (e in entries) { + if (e.isFolder) { + stack.addLast(e.oid to "$prefix${e.name}/") + } else if (!e.isSubmodule) { + out.add("$prefix${e.name}") + } + } + } + return out + } + + /** + * Entries inside the directory at [path] (a list of path segments). Returns + * null if the path doesn't resolve to a directory. + */ + fun entriesAt(path: List): List? { + var treeOid = rootTreeOid + for (segment in path) { + val entry = trees[treeOid]?.firstOrNull { it.name == segment && it.isFolder } ?: return null + treeOid = entry.oid + } + return trees[treeOid]?.let { sortForDisplay(it) } + } + + /** Resolves the entry of a file (or folder) at the full [path], or null. */ + fun entryAt(path: List): GitTreeEntry? { + if (path.isEmpty()) return null + val parent = entriesAt(path.dropLast(1)) ?: return null + return parent.firstOrNull { it.name == path.last() } + } + + /** + * Returns up to [limit] full file paths whose file name contains [query] + * (case-insensitive), searching the whole tree. Folders are walked but not + * matched. The tree is fully present (blob-less), so this is offline. + */ + fun searchFiles( + query: String, + limit: Int = 100, + ): List> { + if (query.isBlank()) return emptyList() + val needle = query.lowercase() + val result = ArrayList>() + + fun walk( + treeOid: String, + prefix: List, + ) { + if (result.size >= limit) return + val entries = trees[treeOid] ?: return + for (entry in entries) { + if (result.size >= limit) return + val path = prefix + entry.name + if (entry.isFolder) { + walk(entry.oid, path) + } else if (entry.name.lowercase().contains(needle)) { + result.add(path) + } + } + } + walk(rootTreeOid, emptyList()) + return result + } + + fun hasBlob(oid: String): Boolean = blobs.containsKey(oid) + + /** Returns the bytes of a blob, fetching (and caching) it if not already present. */ + 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): List = + entries.sortedWith( + compareByDescending { it.isFolder }.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, + ) +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt new file mode 100644 index 0000000000..e65b2ca487 Binary files /dev/null and b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt differ diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt new file mode 100644 index 0000000000..d8f63b8aaf --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitSmartHttpTransport.kt @@ -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, +) { + /** Some servers advertise `fetch` with no feature list; track that we saw the command. */ + var rawHadFetch: Boolean = false + + val supportsFetch: Boolean get() = fetchFeatures.isNotEmpty() || rawHadFetch + val supportsFilter: Boolean get() = fetchFeatures.contains("filter") + val supportsShallow: Boolean get() = fetchFeatures.contains("shallow") +} + +/** + * Low-level git smart-HTTP **protocol v2** transport (`git-upload-pack` only; + * we never write). The three exchanges are `info/refs` (capabilities), + * `ls-refs`, and `fetch`. Wire encoding/decoding lives in [GitUploadPackV2]. + * + * [okHttpClient] is the shared per-URL client provider, so every request inherits + * the app's proxy / Tor routing and onion-rewrite interceptors. + */ +class GitSmartHttpTransport( + private val okHttpClient: (String) -> OkHttpClient, +) { + suspend fun fetchCapabilities(cloneUrl: String): GitCapabilities = + withContext(Dispatchers.IO) { + val url = "${base(cloneUrl)}/info/refs?service=git-upload-pack" + val request = + Request + .Builder() + .url(url) + .header("Git-Protocol", "version=2") + .header("Accept", "*/*") + .get() + .build() + GitUploadPackV2.parseCapabilities(PktLineCodec.parse(execute(url, request))) + } + + suspend fun lsRefs( + cloneUrl: String, + caps: GitCapabilities, + ): List = + withContext(Dispatchers.IO) { + val body = GitUploadPackV2.lsRefsRequest(caps.objectFormat) + GitUploadPackV2.parseRefs(PktLineCodec.parse(postUploadPack(cloneUrl, body))) + } + + /** + * Runs a `fetch` and returns the raw packfile bytes (sideband demuxed). + * + * @param wants object ids to request. + * @param deepen shallow depth (1 = tip only). Null for a full fetch. + * @param filter a partial-clone filter spec such as `blob:none` (omit file + * contents) or `tree:0` (omit trees and blobs). Applied only when the server + * advertises `filter`. + */ + suspend fun fetchPack( + cloneUrl: String, + caps: GitCapabilities, + wants: List, + deepen: Int?, + 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) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt new file mode 100644 index 0000000000..61e9ce1f92 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitUploadPackV2.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +import java.io.ByteArrayOutputStream + +/** + * Pure (transport-free) encoders and decoders for the git `git-upload-pack` + * protocol-v2 exchanges. Kept separate from [GitSmartHttpTransport] so the wire + * formats can be unit-tested against captured server bytes without any network. + */ +object GitUploadPackV2 { + fun lsRefsRequest(objectFormat: String): ByteArray = + PktLineCodec.build { + write(PktLineCodec.dataLine("command=ls-refs\n")) + write(PktLineCodec.dataLine("object-format=$objectFormat\n")) + write(PktLineCodec.DELIM) + write(PktLineCodec.dataLine("symrefs\n")) + write(PktLineCodec.dataLine("peel\n")) + write(PktLineCodec.dataLine("ref-prefix HEAD\n")) + write(PktLineCodec.dataLine("ref-prefix refs/heads/\n")) + write(PktLineCodec.dataLine("ref-prefix refs/tags/\n")) + write(PktLineCodec.FLUSH) + } + + fun fetchRequest( + objectFormat: String, + wants: List, + deepen: Int?, + 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): GitCapabilities { + var agent: String? = null + var objectFormat = "sha1" + var supportsLsRefs = false + var fetchFeatures = emptySet() + var hadFetch = false + + for (line in lines) { + if (line !is PktLine.Data) continue + val text = line.text() + if (text.startsWith("# service=")) continue + val eq = text.indexOf('=') + val key = if (eq >= 0) text.substring(0, eq) else text + val value = if (eq >= 0) text.substring(eq + 1) else "" + when (key) { + "agent" -> agent = value + "object-format" -> if (value.isNotBlank()) objectFormat = value.trim() + "ls-refs" -> supportsLsRefs = true + "fetch" -> { + hadFetch = true + fetchFeatures = + value + .split(' ') + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() + } + } + } + + return GitCapabilities(agent, objectFormat, supportsLsRefs, fetchFeatures).also { it.rawHadFetch = hadFetch } + } + + fun parseRefs(lines: List): List { + val refs = ArrayList() + for (line in lines) { + if (line !is PktLine.Data) continue + val text = line.text() + if (text.isEmpty()) continue + val parts = text.split(' ') + if (parts.size < 2) continue + var symref: String? = null + for (i in 2 until parts.size) { + val attr = parts[i] + if (attr.startsWith("symref-target:")) symref = attr.substringAfter("symref-target:") + } + refs.add(GitRef(parts[0], parts[1], symref)) + } + return refs + } + + /** Demuxes the sideband-64k `packfile` section of a fetch response into raw pack bytes. */ + fun extractPack(lines: List): ByteArray { + val pack = ByteArrayOutputStream() + val errors = StringBuilder() + var inPack = false + for (line in lines) { + when (line) { + is PktLine.Flush -> if (inPack) break + is PktLine.Delim -> {} // section separator + is PktLine.ResponseEnd -> if (inPack) break + is PktLine.Data -> { + if (!inPack) { + if (line.text() == "packfile") inPack = true + continue + } + val payload = line.payload + if (payload.isEmpty()) continue + when (payload[0].toInt() and 0xFF) { + 1 -> pack.write(payload, 1, payload.size - 1) // pack data + 2 -> {} // progress + 3 -> errors.append(payload.decodeToString(1, payload.size)) // fatal + } + } + } + } + if (errors.isNotEmpty()) throw GitHttpException("git server error: $errors") + val bytes = pack.toByteArray() + if (bytes.size < 4) throw GitHttpException("empty packfile in fetch response") + return bytes + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/Packfile.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/Packfile.kt new file mode 100644 index 0000000000..a8b953f6fc --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/Packfile.kt @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +import java.security.MessageDigest +import java.util.zip.Inflater + +/** + * Parser for a git version-2 packfile (`PACK` magic). Inflates each object, + * resolves `OBJ_OFS_DELTA` / `OBJ_REF_DELTA` chains, and computes each object's + * SHA-1 oid so callers can look objects up by id. + * + * We never request thin packs, so every delta base is present inside the same + * pack; `OFS_DELTA` bases always precede their delta, and `REF_DELTA` bases are + * resolved through a pre-built oid index. + */ +object Packfile { + private const val OBJ_COMMIT = 1 + private const val OBJ_TREE = 2 + private const val OBJ_BLOB = 3 + private const val OBJ_TAG = 4 + private const val OBJ_OFS_DELTA = 6 + private const val OBJ_REF_DELTA = 7 + + private class Raw( + val offset: Int, + val type: Int, + val data: ByteArray, + val baseOffset: Int, + val baseOid: String?, + ) + + /** Parses [pack] and returns the resolved objects indexed by their oid (hex). */ + fun parse(pack: ByteArray): Map { + require(pack.size >= 12) { "packfile too short" } + require(pack[0] == 'P'.code.toByte() && pack[1] == 'A'.code.toByte() && pack[2] == 'C'.code.toByte() && pack[3] == 'K'.code.toByte()) { + "missing PACK signature" + } + val version = readUInt32(pack, 4) + require(version == 2L || version == 3L) { "unsupported packfile version $version" } + val count = readUInt32(pack, 8).toInt() + + val raws = ArrayList(count) + val byOffset = HashMap(count * 2) + var p = 12 + repeat(count) { + val start = p + var b = pack[p++].toInt() and 0xFF + val type = (b ushr 4) and 0x07 + var size = (b and 0x0F).toLong() + var shift = 4 + while (b and 0x80 != 0) { + b = pack[p++].toInt() and 0xFF + size = size or ((b and 0x7F).toLong() shl shift) + shift += 7 + } + + var baseOffset = -1 + var baseOid: String? = null + when (type) { + OBJ_OFS_DELTA -> { + b = pack[p++].toInt() and 0xFF + var rel = (b and 0x7F).toLong() + while (b and 0x80 != 0) { + b = pack[p++].toInt() and 0xFF + rel = ((rel + 1) shl 7) or (b and 0x7F).toLong() + } + baseOffset = (start - rel).toInt() + } + OBJ_REF_DELTA -> { + baseOid = toHex(pack, p, 20) + p += 20 + } + } + + val (data, consumed) = inflate(pack, p, size.toInt()) + p += consumed + + val raw = Raw(start, type, data, baseOffset, baseOid) + raws.add(raw) + byOffset[start] = raw + } + + return resolve(raws, byOffset) + } + + private fun resolve( + raws: List, + byOffset: Map, + ): Map { + val memo = HashMap(raws.size * 2) + val oidToOffset = HashMap(raws.size * 2) + + // Pre-index oids of non-delta objects so REF_DELTA bases can be found + // regardless of pack ordering. + for (r in raws) { + if (r.type != OBJ_OFS_DELTA && r.type != OBJ_REF_DELTA) { + oidToOffset[oidOf(baseType(r.type), r.data)] = r.offset + } + } + + fun resolveAt(offset: Int): GitObject { + memo[offset]?.let { return it } + val r = byOffset[offset] ?: throw IllegalStateException("delta base offset $offset not in pack") + val obj = + when (r.type) { + OBJ_OFS_DELTA -> { + val base = resolveAt(r.baseOffset) + val data = GitDelta.apply(base.data, r.data) + GitObject(base.type, data, oidOf(base.type, data)) + } + OBJ_REF_DELTA -> { + val baseOffset = + oidToOffset[r.baseOid] + ?: throw IllegalStateException("REF_DELTA base ${r.baseOid} missing from pack") + val base = resolveAt(baseOffset) + val data = GitDelta.apply(base.data, r.data) + GitObject(base.type, data, oidOf(base.type, data)) + } + else -> GitObject(baseType(r.type), r.data, oidOf(baseType(r.type), r.data)) + } + memo[offset] = obj + oidToOffset[obj.oid] = offset + return obj + } + + val result = HashMap(raws.size * 2) + for (r in raws) { + val obj = resolveAt(r.offset) + result[obj.oid] = obj + } + return result + } + + private fun baseType(type: Int): GitObjectType = + when (type) { + OBJ_COMMIT -> GitObjectType.COMMIT + OBJ_TREE -> GitObjectType.TREE + OBJ_BLOB -> GitObjectType.BLOB + OBJ_TAG -> GitObjectType.TAG + else -> throw IllegalArgumentException("not a base object type: $type") + } + + private fun oidOf( + type: GitObjectType, + data: ByteArray, + ): String { + val digest = MessageDigest.getInstance("SHA-1") + digest.update("${type.header} ${data.size}".encodeToByteArray()) + digest.update(0) + digest.update(data) + return digest.digest().toHex() + } + + private fun inflate( + src: ByteArray, + offset: Int, + expectedSize: Int, + ): Pair { + val inflater = Inflater() + try { + inflater.setInput(src, offset, src.size - offset) + val out = ByteArray(expectedSize) + var got = 0 + // Inflate until the stream is fully consumed (not just until we have all + // output bytes): the trailing adler32 checksum must be read too, otherwise + // bytesRead() is short and the next packed object's offset is misaligned. + val scratch = ByteArray(64) + while (!inflater.finished()) { + if (got < expectedSize) { + val n = inflater.inflate(out, got, expectedSize - got) + if (n == 0) { + if (inflater.needsInput() || inflater.needsDictionary()) break + } else { + got += n + } + } else { + val n = inflater.inflate(scratch, 0, scratch.size) + if (n == 0 && (inflater.needsInput() || inflater.needsDictionary())) break + } + } + if (got != expectedSize) { + throw IllegalStateException("inflate short read: $got of $expectedSize") + } + return out to inflater.bytesRead.toInt() + } finally { + inflater.end() + } + } + + private fun readUInt32( + data: ByteArray, + offset: Int, + ): Long { + var v = 0L + for (i in 0 until 4) v = (v shl 8) or (data[offset + i].toLong() and 0xFF) + return v + } + + private fun toHex( + data: ByteArray, + offset: Int, + len: Int, + ): String { + val sb = StringBuilder(len * 2) + for (i in 0 until len) { + val v = data[offset + i].toInt() and 0xFF + sb.append(HEX[v ushr 4]) + sb.append(HEX[v and 0x0F]) + } + return sb.toString() + } + + private fun ByteArray.toHex(): String { + val sb = StringBuilder(size * 2) + for (b in this) { + val v = b.toInt() and 0xFF + sb.append(HEX[v ushr 4]) + sb.append(HEX[v and 0x0F]) + } + return sb.toString() + } + + private val HEX = "0123456789abcdef".toCharArray() +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/PktLine.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/PktLine.kt new file mode 100644 index 0000000000..e531563d1a --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/PktLine.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +import java.io.ByteArrayOutputStream + +/** + * git `pkt-line` framing used by the smart-HTTP transport. + * + * Each frame is a 4-byte ASCII hexadecimal length prefix followed by that many + * bytes (the length count includes the 4 prefix bytes). Three lengths are + * reserved as control frames: + * + * - `0000` flush-pkt + * - `0001` delim-pkt (protocol v2 section separator) + * - `0002` response-end-pkt + * + * See `gitprotocol-common(5)` and `gitprotocol-v2(5)`. + */ +sealed interface PktLine { + object Flush : PktLine + + object Delim : PktLine + + object ResponseEnd : PktLine + + /** A data frame. [payload] excludes the length prefix. */ + class Data( + val payload: ByteArray, + ) : PktLine { + /** Convenience for text frames, trailing `\n` stripped. */ + fun text(): String = payload.decodeToString().trimEnd('\n') + } +} + +object PktLineCodec { + /** Encodes a text payload as a single data frame (a trailing `\n` is the git convention). */ + fun dataLine(text: String): ByteArray = dataLine(text.encodeToByteArray()) + + /** Encodes a binary payload as a single data frame. */ + fun dataLine(payload: ByteArray): ByteArray { + val len = payload.size + 4 + require(len <= 0xFFFF) { "pkt-line payload too large: ${payload.size}" } + val out = ByteArray(len) + writeLengthPrefix(out, len) + payload.copyInto(out, 4) + return out + } + + val FLUSH: ByteArray = "0000".encodeToByteArray() + val DELIM: ByteArray = "0001".encodeToByteArray() + + private fun writeLengthPrefix( + out: ByteArray, + len: Int, + ) { + val hex = len.toString(16).padStart(4, '0') + for (i in 0 until 4) out[i] = hex[i].code.toByte() + } + + /** + * Parses a full pkt-line stream into frames. The smart-HTTP responses we + * consume are small enough to read fully into memory before decoding. + */ + fun parse(bytes: ByteArray): List { + val result = ArrayList() + var i = 0 + while (i + 4 <= bytes.size) { + val len = parseHex4(bytes, i) + when (len) { + 0 -> { + result.add(PktLine.Flush) + i += 4 + } + 1 -> { + result.add(PktLine.Delim) + i += 4 + } + 2 -> { + result.add(PktLine.ResponseEnd) + i += 4 + } + else -> { + require(len >= 4) { "invalid pkt-line length $len at offset $i" } + val end = i + len + require(end <= bytes.size) { "truncated pkt-line: need $end have ${bytes.size}" } + result.add(PktLine.Data(bytes.copyOfRange(i + 4, end))) + i = end + } + } + } + return result + } + + private fun parseHex4( + bytes: ByteArray, + offset: Int, + ): Int { + var value = 0 + for (i in 0 until 4) { + val c = bytes[offset + i].toInt().toChar() + val digit = + when (c) { + in '0'..'9' -> c - '0' + in 'a'..'f' -> c - 'a' + 10 + in 'A'..'F' -> c - 'A' + 10 + else -> throw IllegalArgumentException("invalid pkt-line length char '$c' at offset ${offset + i}") + } + value = (value shl 4) or digit + } + return value + } + + /** Builds a request body by concatenating pre-encoded frames. */ + fun build(block: ByteArrayOutputStream.() -> Unit): ByteArray = ByteArrayOutputStream().apply(block).toByteArray() +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitCommitParserTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitCommitParserTest.kt new file mode 100644 index 0000000000..f8e2c8074e --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitCommitParserTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Base64 + +class GitCommitParserTest { + // A real, SSH-signed commit object (raw `git cat-file commit HEAD`), so the + // multi-line `gpgsig` header must be skipped without breaking parsing. + private val signedCommitB64 = + "dHJlZSA5YjZiM2Y5YmEzYjQxMjUxZTQzZGY0MDJiMjM0Njc4NzBiNDA4YTU4CnBhcmVudCBlNTc2ZjVmY2FmZjBkMDAwNGI2NmIzMjE2YmI2MTlhN2UyNjIwMTA2CmF1dGhvciBKYW5lIERldiA8YUBiLmNvbT4gMTc4MjY4MTU3NSArMDAwMApjb21taXR0ZXIgSmFuZSBEZXYgPGFAYi5jb20+IDE3ODI2ODE1NzUgKzAwMDAKZ3Bnc2lnIC0tLS0tQkVHSU4gU1NIIFNJR05BVFVSRS0tLS0tCiBVMU5JVTBsSEFBQUFBUUFBQURNQUFBQUxjM05vTFdWa01qVTFNVGtBQUFBZ3JMenNmRklTRjRieThRK0ZLejI3WXBrSzFVU3NCQittCiBhbXUxUWtKbmJEc0FBQUFEWjJsMEFBQUFBQUFBQUFaemFHRTFNVElBQUFCVEFBQUFDM056YUMxbFpESTFOVEU1QUFBQVFNRVUrNE1HCiAxU0tyWmVIUE5zeldQRGk1TzRIN3IyeU1mMkpWeGx1SG5xV2V4WTBHWHN1ZXBrSTBETXBOMldmWHZvMDc3cm9ac2Ivejhnb3RJSS92CiBCd009CiAtLS0tLUVORCBTU0ggU0lHTkFUVVJFLS0tLS0KCnNlY29uZCBjb21taXQKCndpdGggYSBib2R5IGxpbmUK" + + @Test + fun parsesSignedCommit() { + val data = Base64.getDecoder().decode(signedCommitB64) + val commit = GitObjectParser.parseCommit("20f90551a0e493306229a6b50990d3025b721cb0", data) + + assertEquals("9b6b3f9ba3b41251e43df402b23467870b408a58", commit.treeOid) + assertEquals(listOf("e576f5fcaff0d0004b66b3216bb619a7e2620106"), commit.parents) + assertEquals("Jane Dev", commit.authorName) + assertEquals("a@b.com", commit.authorEmail) + assertEquals(1782681575L, commit.authorTimeSec) + assertEquals("second commit", commit.summary) + assertEquals("20f9055", commit.shortOid) + // The signature block must not leak into the message. + assertEquals("second commit\n\nwith a body line\n", commit.message) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitPackfileDeltaTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitPackfileDeltaTest.kt new file mode 100644 index 0000000000..1134b6e520 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitPackfileDeltaTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import java.util.Base64 + +class GitPackfileDeltaTest { + // A real pack of two big.txt versions produced with: + // git pack-objects --stdout --delta-base-offset --window=10 --depth=10 + // It contains one full blob and one OBJ_OFS_DELTA against it. + private val deltaPack = + "UEFDSwAAAAIAAAACt4sDeJxl2LGOHFUURdHcX9Gf0Peequ6ukMBIlpADQCK2wAGSPQ5A4vdBZGc5fE68Zc+cVe/9cPv4/rfbjx9+/uXX208fPr5/9/u3r1+/vd2+/Pn2+Xav09Rp65Q6HXU66/So07NOrzpd/bcT0zXTOdM900HTRdNJ003TUdNV21XLv1FXbVdtV21XbVdtV21XbVelq9JV4b+uq9JV6ap0VboqXZWuOrrq6Kqjqw5+orrq6Kqjq46uOrrq6Kqzq86uOrvq7KqTH/SuOrvq7Kqzq86uenTVo6seXfXoqkdXPfj966pHVz266tFVz656dtWzq55d9eyqZ1c9mYWuenbVs6teXfXqqldXvbrq1VWvrnp11Yu16qpXV11ddXXV1VVXV11ddXXV1VVXV12MqCvKjN7Z0TtDemdJ70zpnS29M6Z31vTOnN7p+27m6XPoXXqn3q137F175569HwZ/VofoY/OH0R9Wf5j9YfeH4R+Wf5j+YfsnQkkf8z/s/wDAIMBAwGDAgMCgwMDAHEpOHxIMFAwWDBgMGgwcDB4MIAwizOmnBn2gMKgwsDC4MMAwyDDQMNgw4DAPv4Xow4cBiEGIgYjBiAGJQYmBicGJefqxRh9UDFYMWAxaDFwMXgxgDGIMZMzLr0n6UGNgY3BjgGOQY6BjsGPAY9BjLj93/d7lgxc/Fj8WPxY/Fj8WPxY/Fj8WP3b8IKcPPxY/Fj8WPxY/Fj8WP9b7gheG724M9Hln8NLgrcFrg/cGLw74sfix+LHxSkMffix+LH4sfix+LH4sfix+LH7s4Z2LPvxY/Fj8WPxY/Fj8WPxY/Fj82NNLIX34sfix+LH4sfix+LH4sfix+LEPb6304cfix+LH4sfix+LH4sfix+LHPr1W04cfix+LH4sfix+LH4sfix+LH/vy3k8ffix+LH4sfix+LH4sfix+LH7s5cOELxM8TeBH8CP4EfwIfgQ/gh/Bj+BHxqcT+vAj+BH8CH4EP4IfwY/gR/Aj69sOffgR/Ah+BD+CH8GP+PLk05NvT989PtHn85PvTz5A+QLlExR+BD+CH8GPHL6O0YcfwY/gR/Aj+BH8CH4EP4IfOX2+ow8/gh/Bj+BH8CP4EfwIfgQ/gh/Bj+BH8CP4EfwIfgQ/gh/Bj+BH8CP4EfwIfgQ/gh/Bj+BH8CP4EfwIfgQ/gh/Bj+BH8CP4EfwIfgQ/gh/Bj/znx6e3P26fbm+f/7l9+fTX3///+bt/ARJv6GvuAYYkeJzbbrjCUDQnMy9VIT9NoSS1okQhrzQ3KbVIwWCjwGQJAKvrCr5mknNvxZN0+vEf6iRSfsYDsdzxuw==" + + private fun bigTxtCommon() = (0..399).joinToString("") { "common line $it\n" } + + @Test + fun resolvesOfsDeltaAndComputesOids() { + val pack = Base64.getDecoder().decode(deltaPack) + val objects = Packfile.parse(pack) + + val common = bigTxtCommon() + val expectedHead = "A NEW FIRST LINE\n" + common + "and a new last line\n" + val expectedPrev = "line of text number 0\n" + common + + val head = objects["e873ccdfb69d504f8ce2d51255da3c530d191f21"] + assertNotNull("full base blob present", head) + assertEquals(GitObjectType.BLOB, head!!.type) + assertEquals(expectedHead, head.data.decodeToString()) + + val prev = objects["eb06ed9c0291ae7fd14aec388d470a1bff363f1e"] + assertNotNull("delta blob resolved", prev) + assertEquals(GitObjectType.BLOB, prev!!.type) + // The oid is recomputed from the reconstructed bytes, so a correct oid key + // proves both the delta application and the SHA-1 hashing are right. + assertEquals(expectedPrev, prev.data.decodeToString()) + } + + @Test + fun appliesHandcraftedDelta() { + val base = "the quick brown fox".encodeToByteArray() + // delta: copy "the quick " (offset 0, size 10), insert "red ", copy "fox" (offset 16, size 3) + val delta = + byteArrayOf( + base.size.toByte(), // base size varint (19) + ("the quick ".length + "red ".length + "fox".length).toByte(), // target size (17) + // copy op: cmd 0x90 = copy, offset 0 (no offset bytes), size from one byte (0x0A = 10) + 0x90.toByte(), + 0x0A, + // insert op: literal of 4 bytes + 0x04, + 'r'.code.toByte(), + 'e'.code.toByte(), + 'd'.code.toByte(), + ' '.code.toByte(), + // copy op: cmd 0x91 = copy, offset from one byte (0x10 = 16), size from one byte (0x03) + 0x91.toByte(), + 0x10, + 0x03, + ) + val result = GitDelta.apply(base, delta) + assertEquals("the quick red fox", result.decodeToString()) + } + + @Test + fun parsesTreeEntries() { + // Build a tree payload: "100644 README\0<20 bytes>" + "40000 src\0<20 bytes>" + val out = java.io.ByteArrayOutputStream() + out.write("100644 README".encodeToByteArray()) + out.write(0) + out.write(hex("980a0d5f19a64b4b30a87d4206aade58726b60e3")) + out.write("40000 src".encodeToByteArray()) + out.write(0) + out.write(hex("b4eecafa9be2f2006ce1b709d6857b07069b4608")) + val entries = GitObjectParser.parseTree(out.toByteArray()) + + assertEquals(2, entries.size) + assertEquals("README", entries[0].name) + assertEquals("980a0d5f19a64b4b30a87d4206aade58726b60e3", entries[0].oid) + assertEquals(false, entries[0].isFolder) + assertEquals("src", entries[1].name) + assertEquals(true, entries[1].isFolder) + } + + @Test + fun parsesCommitTreeHeader() { + val commit = + ( + "tree b4eecafa9be2f2006ce1b709d6857b07069b4608\n" + + "parent 0000000000000000000000000000000000000000\n" + + "author Someone 1 +0000\n\nmessage\n" + ).encodeToByteArray() + assertEquals("b4eecafa9be2f2006ce1b709d6857b07069b4608", GitObjectParser.parseCommitTree(commit)) + } + + @Test + fun roundTripsPktLine() { + val frame = PktLineCodec.dataLine("want abc\n") + assertArrayEquals("000dwant abc\n".encodeToByteArray(), frame) + val parsed = PktLineCodec.parse(frame + PktLineCodec.FLUSH + PktLineCodec.DELIM) + assertEquals(3, parsed.size) + assertEquals("want abc", (parsed[0] as PktLine.Data).text()) + assertEquals(PktLine.Flush, parsed[1]) + assertEquals(PktLine.Delim, parsed[2]) + } + + private fun hex(s: String): ByteArray = ByteArray(s.length / 2) { ((s[it * 2].digitToInt(16) shl 4) or s[it * 2 + 1].digitToInt(16)).toByte() } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitProtocolV2Test.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitProtocolV2Test.kt new file mode 100644 index 0000000000..60925b6bb5 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitProtocolV2Test.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.git + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Base64 + +/** + * Verifies the protocol-v2 parsers against real bytes captured from + * `github.com/octocat/Hello-World.git`. These fixtures are static so the test + * runs offline. + */ +class GitProtocolV2Test { + private fun decode(b64: String): ByteArray = Base64.getDecoder().decode(b64) + + // GET /info/refs?service=git-upload-pack with Git-Protocol: version=2 + private val infoRefs = + "MDAxZSMgc2VydmljZT1naXQtdXBsb2FkLXBhY2sKMDAwMDAwMGV2ZXJzaW9uIDIKMDAyOGFnZW50PWdpdC9naXRodWItZTBiNThhZGU0OTgwLUxpbnV4CjAwMTNscy1yZWZzPXVuYm9ybgowMDI3ZmV0Y2g9c2hhbGxvdyB3YWl0LWZvci1kb25lIGZpbHRlcgowMDEyc2VydmVyLW9wdGlvbgowMDE3b2JqZWN0LWZvcm1hdD1zaGExCjAwMDA=" + + // POST git-upload-pack command=ls-refs response + private val lsRefs = + "MDA1MjdmZDFhNjBiMDFmOTFiMzE0ZjU5OTU1YTRlNGQ0ZTgwZDhlZGYxMWQgSEVBRCBzeW1yZWYtdGFyZ2V0OnJlZnMvaGVhZHMvbWFzdGVyCjAwM2Y3ZmQxYTYwYjAxZjkxYjMxNGY1OTk1NWE0ZTRkNGU4MGQ4ZWRmMTFkIHJlZnMvaGVhZHMvbWFzdGVyCjAwNDhiMWIzZjk3MjM4MzExNDFhMzFhMWE3MjUyYTIxM2UyMTZlYTc2ZTU2IHJlZnMvaGVhZHMvb2N0b2NhdC1wYXRjaC0xCjAwM2RiM2NiZDViYmQ3ZTgxNDM2ZDJlZWUwNDUzN2VhMmI0YzBjYWQ0Y2RmIHJlZnMvaGVhZHMvdGVzdAowMDAw" + + // POST git-upload-pack command=fetch (want HEAD, deepen 1, filter blob:none) + private val fetchResp = + "MDAxMXNoYWxsb3ctaW5mbwowMDM0c2hhbGxvdyA3ZmQxYTYwYjAxZjkxYjMxNGY1OTk1NWE0ZTRkNGU4MGQ4ZWRmMTFkMDAwMTAwMGRwYWNrZmlsZQowMTMwAVBBQ0sAAAACAAAAAp0UeJydjUtqwzAURedexYOOkz79LQilG2g7aDegz1VssC1XUcj2ayjdQGeXwz2c3gCKGkihBB8hi2S2CSI69tmOxkV2bH3UlsdhDw1bJ2NUkuxcYeSkssnJZOGklcWGoP0Ie6hlxN/fWem1UGIEhIXxOcQYUhRRs/ZHWsrCWbEawr1PtdHXBPpIvabQ6VJ/x+tWHxMazqmuLySUEuyMFEwnHpmHg65z7/iXPLyhXUH7fVmo4fuOW6cnS6XVlT73kHCd6q0/76Gn6SSG4R0PWuYNdCSwZaqFyrzg/ANUZmZ/ogJ4nDM0MDAzMVEIcnV08XVlmMHFGy+5zNvbYEWtE9uqexFF2QmPAZo4Cv5JlwtXPkO1ygyeE+5XX4ICt6XpMDAwNgF9MDAwMA==" + + // POST git-upload-pack command=fetch (want the README blob by oid) + private val blobResp = + "MDAwZHBhY2tmaWxlCjAwM2EBUEFDSwAAAAIAAAABPXic80jNyclXCM8vyklR5AIAIJEESLzcQ5zr061K/FSjSFUFPLVK/b0wMDA2AQgwMDAw" + + @Test + fun parsesCapabilities() { + val caps = GitUploadPackV2.parseCapabilities(PktLineCodec.parse(decode(infoRefs))) + assertEquals("sha1", caps.objectFormat) + assertTrue(caps.supportsLsRefs) + assertTrue(caps.supportsFetch) + assertTrue("server advertised filter", caps.supportsFilter) + assertTrue("server advertised shallow", caps.supportsShallow) + assertEquals("git/github-e0b58ade4980-Linux", caps.agent) + } + + @Test + fun parsesLsRefs() { + val refs = GitUploadPackV2.parseRefs(PktLineCodec.parse(decode(lsRefs))) + val head = refs.first { it.name == "HEAD" } + assertEquals("7fd1a60b01f91b314f59955a4e4d4e80d8edf11d", head.oid) + assertEquals("refs/heads/master", head.symrefTarget) + assertNotNull(refs.firstOrNull { it.name == "refs/heads/master" }) + assertNotNull(refs.firstOrNull { it.name == "refs/heads/test" }) + } + + @Test + fun demuxesAndParsesShallowPack() { + val pack = GitUploadPackV2.extractPack(PktLineCodec.parse(decode(fetchResp))) + val objects = Packfile.parse(pack) + + // commit + root tree, blobs filtered out + val commit = objects["7fd1a60b01f91b314f59955a4e4d4e80d8edf11d"] + assertNotNull(commit) + assertEquals(GitObjectType.COMMIT, commit!!.type) + + val rootTreeOid = GitObjectParser.parseCommitTree(commit.data) + assertEquals("b4eecafa9be2f2006ce1b709d6857b07069b4608", rootTreeOid) + + val tree = objects[rootTreeOid]!! + assertEquals(GitObjectType.TREE, tree.type) + val entries = GitObjectParser.parseTree(tree.data) + val readme = entries.first { it.name == "README" } + assertEquals("980a0d5f19a64b4b30a87d4206aade58726b60e3", readme.oid) + assertFalse(readme.isFolder) + + // blob:none means the README content is not in this pack + assertFalse(objects.containsKey(readme.oid)) + } + + @Test + fun parsesSingleBlobFetch() { + val pack = GitUploadPackV2.extractPack(PktLineCodec.parse(decode(blobResp))) + val objects = Packfile.parse(pack) + val blob = objects["980a0d5f19a64b4b30a87d4206aade58726b60e3"] + assertNotNull(blob) + assertEquals(GitObjectType.BLOB, blob!!.type) + assertEquals("Hello World!\n", blob.data.decodeToString()) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/IntralineDiffTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/IntralineDiffTest.kt new file mode 100644 index 0000000000..b89fe62e21 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/IntralineDiffTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.patch + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class IntralineDiffTest { + private fun text( + old: String, + new: String, + ) = old.substring(IntralineDiff.changedSpans(old, new).first.let { it.start until it.end }) to + new.substring(IntralineDiff.changedSpans(old, new).second.let { it.start until it.end }) + + @Test + fun trimsCommonPrefixAndSuffix() { + val (oldChanged, newChanged) = text("the quick brown fox", "the quick red fox") + assertEquals("brown", oldChanged) + assertEquals("red", newChanged) + } + + @Test + fun pureInsertionHasEmptyOldSpan() { + val (old, new) = IntralineDiff.changedSpans("abcd", "abXYcd") + assertTrue(old.isEmpty) + assertEquals("XY", "abXYcd".substring(new.start until new.end)) + } + + @Test + fun identicalLinesProduceEmptySpans() { + val (old, new) = IntralineDiff.changedSpans("same", "same") + assertTrue(old.isEmpty) + assertTrue(new.isEmpty) + } + + @Test + fun emphasisPairsDeletesWithAdds() { + val lines = + listOf( + GitDiffLine(GitDiffLineType.CONTEXT, "ctx", 1, 1), + GitDiffLine(GitDiffLineType.DELETE, "value = 1", 2, null), + GitDiffLine(GitDiffLineType.ADD, "value = 2", null, 2), + ) + val emphasis = IntralineDiff.emphasis(lines) + // line 1 (delete) and line 2 (add) get the differing "1"/"2" highlighted + assertEquals("1", "value = 1".substring(emphasis[1]!!.start until emphasis[1]!!.end)) + assertEquals("2", "value = 2".substring(emphasis[2]!!.start until emphasis[2]!!.end)) + assertTrue(!emphasis.containsKey(0)) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/LineDiffTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/LineDiffTest.kt new file mode 100644 index 0000000000..3f8889bcca --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/LineDiffTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.patch + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LineDiffTest { + private fun reconstructNew(hunk: GitDiffHunk) = hunk.lines.filter { it.type != GitDiffLineType.DELETE }.map { it.content } + + private fun reconstructOld(hunk: GitDiffHunk) = hunk.lines.filter { it.type != GitDiffLineType.ADD }.map { it.content } + + @Test + fun mergesNearbyChangesIntoOneHunk() { + val old = listOf("alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel") + val new = listOf("alpha", "bravo", "CHARLIE", "delta", "echo", "foxtrot", "NEW LINE", "golf", "hotel") + + val hunks = LineDiff.hunks(old, new) + assertEquals(1, hunks.size) + val hunk = hunks.single() + // matches `git diff -U3` + assertEquals("@@ -1,8 +1,9 @@", hunk.header) + assertEquals(1, hunk.oldStart) + assertEquals(1, hunk.newStart) + + // The whole file is one hunk here, so it must reconstruct both sides exactly. + assertEquals(old, reconstructOld(hunk)) + assertEquals(new, reconstructNew(hunk)) + + val adds = hunk.lines.filter { it.type == GitDiffLineType.ADD }.map { it.content } + val dels = hunk.lines.filter { it.type == GitDiffLineType.DELETE }.map { it.content } + assertEquals(listOf("CHARLIE", "NEW LINE"), adds) + assertEquals(listOf("charlie"), dels) + } + + @Test + fun splitsFarApartChangesIntoTwoHunks() { + val old = (1..60).map { "line $it" } + val new = + old.toMutableList().apply { + this[1] = "line 2 CHANGED" + this[50] = "line 51 CHANGED" + } + + val hunks = LineDiff.hunks(old, new) + assertEquals(2, hunks.size) + // First hunk around line 2 (within the leading context), second around line 51. + assertTrue("first hunk near top, was ${hunks[0].oldStart}", hunks[0].oldStart <= 2) + assertTrue("second hunk near 51, was ${hunks[1].oldStart}", hunks[1].oldStart in 47..51) + } + + @Test + fun pureAdditionIntoEmptyFile() { + val hunks = LineDiff.hunks(emptyList(), listOf("a", "b", "c")) + val hunk = hunks.single() + assertEquals("@@ -0,0 +1,3 @@", hunk.header) + assertEquals(3, hunk.lines.count { it.type == GitDiffLineType.ADD }) + assertEquals(0, hunk.lines.count { it.type != GitDiffLineType.ADD }) + } + + @Test + fun fullDeletion() { + val hunks = LineDiff.hunks(listOf("a", "b"), emptyList()) + val hunk = hunks.single() + assertEquals("@@ -1,2 +0,0 @@", hunk.header) + assertEquals(2, hunk.lines.count { it.type == GitDiffLineType.DELETE }) + } + + @Test + fun identicalFilesProduceNoHunks() { + val same = listOf("x", "y", "z") + assertTrue(LineDiff.hunks(same, same).isEmpty()) + } + + @Test + fun reconstructsAcrossManyEdits() { + // Deterministic pseudo-random-ish edits, then verify each hunk reconstructs locally. + val old = (1..40).map { "item-$it" } + val new = + old.toMutableList().apply { + removeAt(35) + add(10, "inserted-A") + this[5] = "item-6-edited" + add("appended") + } + val hunks = LineDiff.hunks(old, new, context = 3) + // Each hunk's reconstructed old/new slices must match the corresponding source slices. + for (hunk in hunks) { + val oldSlice = old.subList(hunk.oldStart - 1, hunk.oldStart - 1 + reconstructOld(hunk).size) + assertEquals(oldSlice, reconstructOld(hunk)) + } + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/UnifiedDiffParserTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/UnifiedDiffParserTest.kt new file mode 100644 index 0000000000..dffc656b52 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip34Git/patch/UnifiedDiffParserTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip34Git.patch + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Base64 + +class UnifiedDiffParserTest { + // A real `git format-patch -1 --stdout`: modify a.txt, delete b.txt, add c.txt. + private val sampleB64 = + "RnJvbSAyZTllMGZlZjZhOWZkMTZiYzdjZGNhMjY2ZmI2MGVlNTdmN2Q0NzZjIE1vbiBTZXAgMTcgMDA6MDA6MDAgMjAwMQpGcm9tOiBUZXN0ZXIgPHRAdC5jb20+CkRhdGU6IFN1biwgMjggSnVuIDIwMjYgMTU6NDc6MTkgKzAwMDAKU3ViamVjdDogW1BBVENIXSBVcGRhdGUgYS50eHQsIHJlbW92ZSBiLnR4dCwgYWRkIGMudHh0CgpUaGlzIGlzIHRoZSBib2R5IG9mIHRoZSBjb21taXQgbWVzc2FnZS4KSXQgc3BhbnMgbXVsdGlwbGUgbGluZXMuCi0tLQogYS50eHQgfCAzICsrLQogYi50eHQgfCAxIC0KIGMudHh0IHwgMiArKwogMyBmaWxlcyBjaGFuZ2VkLCA0IGluc2VydGlvbnMoKyksIDIgZGVsZXRpb25zKC0pCiBkZWxldGUgbW9kZSAxMDA2NDQgYi50eHQKIGNyZWF0ZSBtb2RlIDEwMDY0NCBjLnR4dAoKZGlmZiAtLWdpdCBhL2EudHh0IGIvYS50eHQKaW5kZXggMGMyYWEzOC4uNThlMWYxMiAxMDA2NDQKLS0tIGEvYS50eHQKKysrIGIvYS50eHQKQEAgLTEsMyArMSw0IEBACiBsaW5lIG9uZQotbGluZSB0d28KK2xpbmUgdHdvIENIQU5HRUQKIGxpbmUgdGhyZWUKK2xpbmUgZm91cgpkaWZmIC0tZ2l0IGEvYi50eHQgYi9iLnR4dApkZWxldGVkIGZpbGUgbW9kZSAxMDA2NDQKaW5kZXggMmZhOTkyYy4uMDAwMDAwMAotLS0gYS9iLnR4dAorKysgL2Rldi9udWxsCkBAIC0xICswLDAgQEAKLWtlZXAKZGlmZiAtLWdpdCBhL2MudHh0IGIvYy50eHQKbmV3IGZpbGUgbW9kZSAxMDA2NDQKaW5kZXggMDAwMDAwMC4uOTQ5NTRhYgotLS0gL2Rldi9udWxsCisrKyBiL2MudHh0CkBAIC0wLDAgKzEsMiBAQAoraGVsbG8KK3dvcmxkCi0tIAoyLjQzLjAKCg==" + + private fun sample(): String = String(Base64.getDecoder().decode(sampleB64)) + + @Test + fun extractsCommitMessageBody() { + val parsed = UnifiedDiffParser.parse(sample()) + assertEquals("This is the body of the commit message.\nIt spans multiple lines.", parsed.message) + assertTrue(parsed.hasDiff) + } + + @Test + fun parsesAllThreeFiles() { + val files = UnifiedDiffParser.parse(sample()).files + assertEquals(3, files.size) + + val a = files[0] + assertEquals("a.txt", a.displayPath) + assertEquals(GitFileChange.MODIFY, a.change) + assertEquals(2, a.additions) // "line two CHANGED" + "line four" + assertEquals(1, a.deletions) // "line two" + + val b = files[1] + assertEquals("b.txt", b.displayPath) + assertEquals(GitFileChange.DELETE, b.change) + assertNull(b.newPath) + assertEquals(1, b.deletions) + + val c = files[2] + assertEquals("c.txt", c.displayPath) + assertEquals(GitFileChange.ADD, c.change) + assertNull(c.oldPath) + assertEquals(2, c.additions) + } + + @Test + fun assignsLineNumbers() { + val a = UnifiedDiffParser.parse(sample()).files[0] + val hunk = a.hunks.single() + assertEquals(1, hunk.oldStart) + assertEquals(1, hunk.newStart) + + // context "line one" keeps both numbers + val first = hunk.lines.first() + assertEquals(GitDiffLineType.CONTEXT, first.type) + assertEquals("line one", first.content) + assertEquals(1, first.oldNumber) + assertEquals(1, first.newNumber) + + val deleted = hunk.lines.first { it.type == GitDiffLineType.DELETE } + assertEquals("line two", deleted.content) + assertNull(deleted.newNumber) + + val added = hunk.lines.first { it.type == GitDiffLineType.ADD } + assertEquals("line two CHANGED", added.content) + assertNull(added.oldNumber) + } + + @Test + fun totalsAcrossFiles() { + val parsed = UnifiedDiffParser.parse(sample()) + assertEquals(4, parsed.totalAdditions) + assertEquals(2, parsed.totalDeletions) + } + + @Test + fun plainTextWithoutDiffIsMessageOnly() { + val parsed = UnifiedDiffParser.parse("Just a description, no diff here.") + assertFalse(parsed.hasDiff) + assertEquals("Just a description, no diff here.", parsed.message) + assertTrue(parsed.files.isEmpty()) + } + + @Test + fun detectsBinaryFiles() { + val patch = + "diff --git a/img.png b/img.png\n" + + "new file mode 100644\n" + + "index 0000000..abcdef1\n" + + "Binary files /dev/null and b/img.png differ\n" + val file = UnifiedDiffParser.parse(patch).files.single() + assertTrue(file.isBinary) + assertTrue(file.hunks.isEmpty()) + } +}