mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
Merge pull request #3415 from vitorpamplona/claude/git-repo-readme-code-tabs-e4uf6c
Add git smart-HTTP browser for NIP-34 repositories
This commit is contained in:
@@ -457,6 +457,9 @@ dependencies {
|
||||
implementation(libs.markdown.ui.material3)
|
||||
implementation(libs.markdown.commonmark)
|
||||
|
||||
// Syntax highlighting for the git repository code browser (Apache-2.0)
|
||||
implementation(libs.highlights)
|
||||
|
||||
// LaTeX math rendering ($...$ and $$...$$ inline equations)
|
||||
implementation(libs.jlatexmath.android)
|
||||
implementation(libs.jlatexmath.font.greek)
|
||||
|
||||
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState
|
||||
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.GitRepositoryListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.PinListState
|
||||
@@ -397,6 +398,7 @@ class Account(
|
||||
val appRecommendations = AppRecommendationsState(signer, cache, scope)
|
||||
val oldBookmarkState = OldBookmarkListState(signer, cache, scope)
|
||||
val bookmarkState = BookmarkListState(signer, cache, scope)
|
||||
val gitRepositoryListState = GitRepositoryListState(signer, cache, scope)
|
||||
val pinState = PinListState(signer, cache, scope)
|
||||
val emoji = EmojiPackState(signer, cache, scope)
|
||||
val ownedEmojiPacks = OwnedEmojiPacksState(signer, cache, scope)
|
||||
@@ -3010,6 +3012,16 @@ class Account(
|
||||
delete(note)
|
||||
}
|
||||
|
||||
suspend fun addGitRepositoryBookmark(note: AddressableNote) {
|
||||
if (!isWriteable()) return
|
||||
sendMyPublicAndPrivateOutbox(gitRepositoryListState.addRepository(note))
|
||||
}
|
||||
|
||||
suspend fun removeGitRepositoryBookmark(note: AddressableNote) {
|
||||
if (!isWriteable()) return
|
||||
gitRepositoryListState.removeRepository(note)?.let { sendMyPublicAndPrivateOutbox(it) }
|
||||
}
|
||||
|
||||
suspend fun addBookmark(
|
||||
note: Note,
|
||||
isPrivate: Boolean,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache.observeEvents
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* Cross-screen index of the most recent NIP-34 pull-request update event
|
||||
* (kind 1619) per parent pull-request id, kept up to date from
|
||||
* [LocalCache.observeEvents]. A PR update *revises* its parent PR with a
|
||||
* newer commit / merge base, so the UI folds the latest one into the PR rather
|
||||
* than listing updates separately. Like [GitStatusIndex], updates aren't tracked
|
||||
* in `Note.replies`, so a per-row cache scan would otherwise be required.
|
||||
*
|
||||
* The kind-indexed [observeEvents] re-emits the whole matching list on every new
|
||||
* 1619 (and seeds it from the cache index via `init()`), so [latestByPullRequest]
|
||||
* is just that list reduced to the latest-per-parent map. Shared [SharingStarted.Eagerly]
|
||||
* — never `WhileSubscribed` — because callers read `.value` synchronously and must
|
||||
* not see a stale map when no one is actively collecting. `null` means "not loaded yet".
|
||||
*/
|
||||
object GitPullRequestUpdateIndex {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
val latestByPullRequest: StateFlow<Map<HexKey, GitPullRequestUpdateEvent>?> =
|
||||
LocalCache
|
||||
.observeEvents<GitPullRequestUpdateEvent>(Filter(kinds = listOf(GitPullRequestUpdateEvent.KIND)))
|
||||
.map { latestByParent(it) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
private fun latestByParent(events: List<GitPullRequestUpdateEvent>): Map<HexKey, GitPullRequestUpdateEvent> {
|
||||
val latest = HashMap<HexKey, GitPullRequestUpdateEvent>()
|
||||
for (event in events) {
|
||||
val target = event.parentPullRequestId() ?: continue
|
||||
val current = latest[target]
|
||||
if (current == null || event.createdAt > current.createdAt) {
|
||||
latest[target] = event
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
}
|
||||
@@ -20,53 +20,63 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache.observeEvents
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* Cross-screen index of the most recent NIP-34 status event (kinds
|
||||
* 1630-1633) per target id, kept up to date from
|
||||
* [LocalCache.live.newEventBundles]. Status events are not tracked in
|
||||
* [LocalCache.observeEvents]. Status events are not tracked in
|
||||
* `Note.replies` (see `LocalCache.computeReplyTo`), so the only way to
|
||||
* find them otherwise would be a full cache scan per row.
|
||||
*
|
||||
* The kind-indexed [observeEvents] re-emits the whole matching list on every new
|
||||
* status event (and seeds it from the cache index via `init()`), so [latestByTarget]
|
||||
* is just that list reduced to the latest-per-target map. Shared [SharingStarted.Eagerly]
|
||||
* — never `WhileSubscribed` — because callers (e.g. [isClosedOrResolved] and the feed
|
||||
* filters) read `.value` synchronously and must not see a stale map when no one is
|
||||
* actively collecting. `null` means "not loaded yet".
|
||||
*/
|
||||
object GitStatusIndex {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val started = AtomicBoolean(false)
|
||||
|
||||
private val mutableLatestByTarget = MutableStateFlow<Map<HexKey, GitStatusEvent>?>(null)
|
||||
val latestByTarget: StateFlow<Map<HexKey, GitStatusEvent>?> = mutableLatestByTarget.asStateFlow()
|
||||
private val statusKinds =
|
||||
listOf(
|
||||
GitStatusEvent.KIND_OPEN,
|
||||
GitStatusEvent.KIND_APPLIED,
|
||||
GitStatusEvent.KIND_CLOSED,
|
||||
GitStatusEvent.KIND_DRAFT,
|
||||
)
|
||||
|
||||
fun startIfNeeded() {
|
||||
if (!started.compareAndSet(false, true)) return
|
||||
scope.launch {
|
||||
// Subscribe to bundle updates BEFORE the initial scan via onStart, so any
|
||||
// events that arrive between scan start and collector attach are picked up.
|
||||
LocalCache.live.newEventBundles
|
||||
.onStart {
|
||||
val initial = HashMap<HexKey, GitStatusEvent>()
|
||||
LocalCache.notes.forEach { _, note ->
|
||||
val event = note.event as? GitStatusEvent ?: return@forEach
|
||||
val target = event.rootEventId() ?: return@forEach
|
||||
val current = initial[target]
|
||||
if (current == null || event.createdAt > current.createdAt) {
|
||||
initial[target] = event
|
||||
}
|
||||
}
|
||||
mutableLatestByTarget.value = initial
|
||||
}.collect { bundle -> processBundle(bundle) }
|
||||
val latestByTarget: StateFlow<Map<HexKey, GitStatusEvent>?> =
|
||||
LocalCache
|
||||
.observeEvents<GitStatusEvent>(Filter(kinds = statusKinds))
|
||||
.map { reduceLatestByTarget(it) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
private fun reduceLatestByTarget(events: List<GitStatusEvent>): Map<HexKey, GitStatusEvent> {
|
||||
val latest = HashMap<HexKey, GitStatusEvent>()
|
||||
for (event in events) {
|
||||
val target = event.rootEventId() ?: continue
|
||||
val current = latest[target]
|
||||
if (current == null || event.createdAt > current.createdAt) {
|
||||
latest[target] = event
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,20 +94,4 @@ object GitStatusIndex {
|
||||
val event = map?.get(targetId) ?: return false
|
||||
return event is GitStatusClosedEvent || event is GitStatusAppliedEvent
|
||||
}
|
||||
|
||||
private fun processBundle(bundle: Set<Note>) {
|
||||
val snapshot = mutableLatestByTarget.value ?: emptyMap()
|
||||
var modified: HashMap<HexKey, GitStatusEvent>? = null
|
||||
for (note in bundle) {
|
||||
val event = note.event as? GitStatusEvent ?: continue
|
||||
val target = event.rootEventId() ?: continue
|
||||
val map = modified ?: snapshot
|
||||
val current = map[target]
|
||||
if (current == null || event.createdAt > current.createdAt) {
|
||||
if (modified == null) modified = HashMap(snapshot)
|
||||
modified[target] = event
|
||||
}
|
||||
}
|
||||
modified?.let { mutableLatestByTarget.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+5
-1
@@ -54,6 +54,7 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingBarNestedScroll
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingBarState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingBarState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberDisappearingBarState
|
||||
import com.vitorpamplona.amethyst.ui.components.getActivityWindow
|
||||
@@ -199,7 +200,10 @@ private fun ScaffoldLayout(
|
||||
|
||||
val contentPlaceable =
|
||||
subcompose(DisappearingSlot.Content) {
|
||||
CompositionLocalProvider(LocalDisappearingScaffoldPadding provides contentPadding) {
|
||||
CompositionLocalProvider(
|
||||
LocalDisappearingScaffoldPadding provides contentPadding,
|
||||
LocalDisappearingBarState provides state,
|
||||
) {
|
||||
mainContent(contentPadding)
|
||||
}
|
||||
}.firstOrNull()?.measure(
|
||||
|
||||
@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
|
||||
@@ -134,6 +135,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPack
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.FollowPacksScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitNewIssueScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryCodeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryIssuesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryPullsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.GitRepositoriesScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen
|
||||
@@ -440,6 +445,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.OldBookmarks> { OldBookmarkListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.PinnedNotes> { PinnedNotesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.BookmarkedRepositories> { BookmarkedRepositoriesScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.WebBookmarks> { WebBookmarksScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ScheduledPosts> { ScheduledPostsScreen(accountViewModel, nav) }
|
||||
@@ -490,6 +496,10 @@ fun BuildNavigation(
|
||||
composableFromEndArgs<Route.RelayMembers> { RelayMembersScreen(it.url, accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.GitRepository> { GitRepositoryScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.GitRepositoryCode> { GitRepositoryCodeScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.GitRepositoryIssues> { GitRepositoryIssuesScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.GitRepositoryPulls> { GitRepositoryPullsScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.GitRepositoryNewIssue> { GitNewIssueScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.FollowPack> { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
|
||||
|
||||
composableFromEndArgs<Route.Room> { ChatroomScreen(it.toKey(), it.message, it.attachment, it.replyId, it.draftId, it.expiresDays, accountViewModel, nav) }
|
||||
|
||||
@@ -302,6 +302,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object PinnedNotes : Route()
|
||||
|
||||
@Serializable object BookmarkedRepositories : Route()
|
||||
|
||||
@Serializable object BookmarkGroups : Route()
|
||||
|
||||
@Serializable object InterestSets : Route()
|
||||
@@ -523,6 +525,54 @@ sealed class Route {
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable data class GitRepositoryCode(
|
||||
val kind: Int,
|
||||
val pubKeyHex: HexKey,
|
||||
val dTag: String,
|
||||
) : Route() {
|
||||
constructor(address: Address) : this(
|
||||
kind = address.kind,
|
||||
pubKeyHex = address.pubKeyHex,
|
||||
dTag = address.dTag,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable data class GitRepositoryIssues(
|
||||
val kind: Int,
|
||||
val pubKeyHex: HexKey,
|
||||
val dTag: String,
|
||||
) : Route() {
|
||||
constructor(address: Address) : this(
|
||||
kind = address.kind,
|
||||
pubKeyHex = address.pubKeyHex,
|
||||
dTag = address.dTag,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable data class GitRepositoryPulls(
|
||||
val kind: Int,
|
||||
val pubKeyHex: HexKey,
|
||||
val dTag: String,
|
||||
) : Route() {
|
||||
constructor(address: Address) : this(
|
||||
kind = address.kind,
|
||||
pubKeyHex = address.pubKeyHex,
|
||||
dTag = address.dTag,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable data class GitRepositoryNewIssue(
|
||||
val kind: Int,
|
||||
val pubKeyHex: HexKey,
|
||||
val dTag: String,
|
||||
) : Route() {
|
||||
constructor(address: Address) : this(
|
||||
kind = address.kind,
|
||||
pubKeyHex = address.pubKeyHex,
|
||||
dTag = address.dTag,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable data class FollowPack(
|
||||
val kind: Int,
|
||||
val pubKeyHex: HexKey,
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -50,13 +51,19 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.nip34Git.GitBrowseState
|
||||
import com.vitorpamplona.amethyst.commons.nip34Git.GitRepoSnapshotCache
|
||||
import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.GitPullRequestUpdateIndex
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
@@ -68,6 +75,13 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.GitRepositoryBrowserViewModelFactory
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoExternalNotice
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoLanguageBar
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoLastCommit
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.RepoStatTiles
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.computeLanguageBreakdown
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.repoHasFetchableClone
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font12SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
|
||||
@@ -83,13 +97,14 @@ import com.vitorpamplona.amethyst.ui.theme.subtleBorder
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.UnifiedDiffParser
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
|
||||
private val CardShape = QuoteBorder
|
||||
private val ChipShape = RoundedCornerShape(8.dp)
|
||||
private val CardPadding = PaddingValues(Size10dp)
|
||||
private val CardPadding = PaddingValues(start = Size10dp, top = Size10dp, end = Size10dp, bottom = Size5dp)
|
||||
private val HeaderSpacing = Arrangement.spacedBy(Size8dp)
|
||||
private val LinkRowSpacing = Arrangement.spacedBy(Size8dp)
|
||||
|
||||
@@ -433,7 +448,26 @@ private fun RenderGitPatchEvent(
|
||||
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
|
||||
GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
// In a collapsed feed preview keep the lightweight markdown body; in the
|
||||
// full (thread) view parse the patch into a proper file-by-file diff.
|
||||
val parsed = if (makeItShort) null else remember(noteEvent) { UnifiedDiffParser.parse(noteEvent.content) }
|
||||
if (parsed != null && parsed.hasDiff) {
|
||||
if (parsed.message.isNotBlank()) {
|
||||
Text(
|
||||
text = parsed.message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
}
|
||||
GitDiffView(parsed, Modifier.fillMaxWidth())
|
||||
} else {
|
||||
GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
if (!makeItShort) {
|
||||
GitStatusActions(note, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,6 +538,10 @@ private fun RenderGitIssueEvent(
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
|
||||
GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
|
||||
if (!makeItShort) {
|
||||
GitStatusActions(note, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,6 +580,11 @@ private fun RenderGitPullRequestEvent(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// A later pull-request update (kind 1619) revises this PR with a newer commit /
|
||||
// merge base. Fold the most recent one in so the card reflects the current state.
|
||||
val updateIndex by GitPullRequestUpdateIndex.latestByPullRequest.collectAsStateWithLifecycle()
|
||||
val update = updateIndex?.get(note.idHex)
|
||||
|
||||
GitCardContainer {
|
||||
val repository = remember(noteEvent) { noteEvent.repositoryAddress() }
|
||||
if (repository != null) {
|
||||
@@ -565,6 +608,15 @@ private fun RenderGitPullRequestEvent(
|
||||
)
|
||||
|
||||
GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN)
|
||||
|
||||
if (update != null) {
|
||||
TypeChip(
|
||||
text = stringRes(id = R.string.git_pr_revised),
|
||||
background = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
symbol = MaterialSymbols.Sync,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val subject = remember(noteEvent) { noteEvent.subject()?.takeIf { it.isNotBlank() } }
|
||||
@@ -573,9 +625,18 @@ private fun RenderGitPullRequestEvent(
|
||||
}
|
||||
|
||||
val branch = remember(noteEvent) { noteEvent.branchName()?.takeIf { it.isNotBlank() } }
|
||||
val currentCommit = remember(noteEvent) { noteEvent.currentCommit()?.takeIf { it.isNotBlank() } }
|
||||
val mergeBase = remember(noteEvent) { noteEvent.mergeBase()?.takeIf { it.isNotBlank() } }
|
||||
val cloneUrls = remember(noteEvent) { noteEvent.cloneUrls().filter { it.isNotBlank() } }
|
||||
val currentCommit =
|
||||
remember(noteEvent, update) {
|
||||
(update?.currentCommit() ?: noteEvent.currentCommit())?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
val mergeBase =
|
||||
remember(noteEvent, update) {
|
||||
(update?.mergeBase() ?: noteEvent.mergeBase())?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
val cloneUrls =
|
||||
remember(noteEvent, update) {
|
||||
(update?.cloneUrls()?.filter { it.isNotBlank() }?.ifEmpty { null } ?: noteEvent.cloneUrls().filter { it.isNotBlank() })
|
||||
}
|
||||
|
||||
if (branch != null || currentCommit != null || mergeBase != null) {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
@@ -609,6 +670,11 @@ private fun RenderGitPullRequestEvent(
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
|
||||
GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
|
||||
if (!makeItShort) {
|
||||
GitPullRequestChanges(cloneUrls, currentCommit, mergeBase, accountViewModel)
|
||||
GitStatusActions(note, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -729,8 +795,6 @@ private fun RenderGitRepositoryEvent(
|
||||
) {
|
||||
val title = noteEvent.name() ?: noteEvent.dTag()
|
||||
val summary = noteEvent.description()
|
||||
val web = noteEvent.web()
|
||||
val clone = noteEvent.clone()
|
||||
val topics = remember(noteEvent) { noteEvent.hashtags().filter { it.isNotBlank() } }
|
||||
val isPersonalFork = remember(noteEvent) { noteEvent.isPersonalFork() }
|
||||
|
||||
@@ -784,25 +848,7 @@ private fun RenderGitRepositoryEvent(
|
||||
)
|
||||
}
|
||||
|
||||
if (web != null || clone != null) {
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Size5dp)) {
|
||||
web?.let {
|
||||
LinkRow(
|
||||
symbol = MaterialSymbols.Public,
|
||||
contentDescription = stringRes(id = R.string.git_web_address),
|
||||
url = it,
|
||||
)
|
||||
}
|
||||
clone?.let {
|
||||
LinkRow(
|
||||
symbol = MaterialSymbols.AutoMirrored.OpenInNew,
|
||||
contentDescription = stringRes(id = R.string.git_clone_address),
|
||||
url = it,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
RepoSnapshotDashboard(noteEvent, note, accountViewModel, nav)
|
||||
|
||||
if (topics.isNotEmpty()) {
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
@@ -821,3 +867,57 @@ private fun RenderGitRepositoryEvent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a shallow snapshot of the repository over smart-HTTP and renders the same
|
||||
* stat tiles / language bar / last-commit strip used on the project home, in place of
|
||||
* the old web/clone links. Fetches lazily (once) when the card is composed.
|
||||
*/
|
||||
@Composable
|
||||
private fun RepoSnapshotDashboard(
|
||||
noteEvent: GitRepositoryEvent,
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val cacheKey = remember(noteEvent) { noteEvent.address().toValue() }
|
||||
val browser: GitRepositoryBrowserViewModel =
|
||||
viewModel(
|
||||
key = note.idHex + "GitRepoCardBrowser",
|
||||
factory = GitRepositoryBrowserViewModelFactory(accountViewModel.httpClientBuilder::okHttpClientForPreview),
|
||||
)
|
||||
LaunchedEffect(noteEvent) { browser.loadOnce(noteEvent.clones(), cacheKey) }
|
||||
val browserState by browser.state.collectAsStateWithLifecycle()
|
||||
// Prefer the live state, but fall back to a cached snapshot so an already-fetched repo
|
||||
// renders synchronously — including in the share-to-image capture, which won't wait for a
|
||||
// fresh clone.
|
||||
val snapshot = (browserState as? GitBrowseState.Loaded)?.snapshot ?: remember(cacheKey) { GitRepoSnapshotCache.get(cacheKey) }
|
||||
if (snapshot == null) {
|
||||
// Repos we can't clone over http(s) (e.g. Iris's htree://) get an open-in-browser notice
|
||||
// instead of an indefinitely-empty dashboard.
|
||||
if (!repoHasFetchableClone(noteEvent)) {
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
RepoExternalNotice(noteEvent)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val fileNames = remember(snapshot) { snapshot.walkFileNames() }
|
||||
val slices = remember(fileNames) { computeLanguageBreakdown(fileNames) }
|
||||
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
RepoStatTiles(
|
||||
branches = snapshot.branches.size,
|
||||
tags = snapshot.tags.size,
|
||||
files = fileNames.size,
|
||||
updatedEpochSec = snapshot.tipCommit?.authorTimeSec,
|
||||
)
|
||||
if (slices.isNotEmpty()) {
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
RepoLanguageBar(slices)
|
||||
}
|
||||
snapshot.tipCommit?.let { commit ->
|
||||
Spacer(modifier = HalfDoubleVertSpacer)
|
||||
RepoLastCommit(commit) { nav.nav(Route.GitRepository(noteEvent.kind, noteEvent.pubKey, noteEvent.dTag())) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.nip34Git.ui.CodeHighlighter
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.CharSpan
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitDiffFile
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitDiffLine
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitDiffLineType
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitFileChange
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.IntralineDiff
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch
|
||||
import dev.snipme.highlights.model.SyntaxLanguage
|
||||
|
||||
private val AddColor = Color(0xFF1F883D)
|
||||
private val DeleteColor = Color(0xFFCF222E)
|
||||
private val DiffFontSize = 12.sp
|
||||
private val DiffLineHeight = 17.sp
|
||||
|
||||
// Above this many diff lines we skip per-line syntax highlighting to stay snappy.
|
||||
private const val HIGHLIGHT_LINE_BUDGET = 600
|
||||
|
||||
/**
|
||||
* Renders a parsed [ParsedPatch] as a GitHub-style file-by-file diff: a stat
|
||||
* summary, then one collapsible card per file with +/- line coloring, old/new
|
||||
* line numbers, and (for reasonably sized diffs) syntax highlighting reused from
|
||||
* the repository code browser.
|
||||
*/
|
||||
@Composable
|
||||
fun GitDiffView(
|
||||
parsed: ParsedPatch,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (!parsed.hasDiff) return
|
||||
val highlightEnabled =
|
||||
remember(parsed) { parsed.files.sumOf { f -> f.hunks.sumOf { it.lines.size } } <= HIGHLIGHT_LINE_BUDGET }
|
||||
|
||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
DiffStatSummary(parsed)
|
||||
parsed.files.forEach { file ->
|
||||
DiffFileCard(file, highlightEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiffStatSummary(parsed: ParsedPatch) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
val fileCount = parsed.files.size
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.git_diff_files_changed, fileCount, fileCount),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text("+${parsed.totalAdditions}", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = AddColor)
|
||||
Text("-${parsed.totalDeletions}", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = DeleteColor)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiffFileCard(
|
||||
file: GitDiffFile,
|
||||
highlightEnabled: Boolean,
|
||||
) {
|
||||
var expanded by rememberSaveable(file.displayPath) { mutableStateOf(true) }
|
||||
val language = remember(file.displayPath) { CodeHighlighter.languageForFile(file.displayPath) }
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
// File header
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val (symbol, tint) = changeBadge(file.change)
|
||||
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(16.dp), tint = tint)
|
||||
Text(
|
||||
text = file.displayPath,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (file.additions > 0) Text("+${file.additions}", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, color = AddColor)
|
||||
if (file.deletions > 0) Text("-${file.deletions}", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, color = DeleteColor)
|
||||
Icon(
|
||||
symbol = if (expanded) MaterialSymbols.KeyboardArrowUp else MaterialSymbols.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
if (file.isBinary) {
|
||||
Text(
|
||||
text = stringRes(R.string.git_diff_binary),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
} else {
|
||||
val gutterWidth = remember(file) { gutterWidthFor(file) }
|
||||
Column(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())) {
|
||||
file.hunks.forEach { hunk ->
|
||||
HunkHeaderRow(hunk.header)
|
||||
val emphasis = remember(hunk) { IntralineDiff.emphasis(hunk.lines) }
|
||||
hunk.lines.forEachIndexed { index, line ->
|
||||
DiffLineRow(line, gutterWidth, language.takeIf { highlightEnabled }, darkMode, emphasis[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HunkHeaderRow(header: String) {
|
||||
Text(
|
||||
text = header,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = DiffFontSize,
|
||||
lineHeight = DiffLineHeight,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.85f),
|
||||
maxLines = 1,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.06f))
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiffLineRow(
|
||||
line: GitDiffLine,
|
||||
gutterWidth: Dp,
|
||||
language: SyntaxLanguage?,
|
||||
darkMode: Boolean,
|
||||
emphasis: CharSpan?,
|
||||
) {
|
||||
val background =
|
||||
when (line.type) {
|
||||
GitDiffLineType.ADD -> AddColor.copy(alpha = 0.12f)
|
||||
GitDiffLineType.DELETE -> DeleteColor.copy(alpha = 0.12f)
|
||||
GitDiffLineType.CONTEXT -> Color.Transparent
|
||||
}
|
||||
val emphasisColor =
|
||||
when (line.type) {
|
||||
GitDiffLineType.ADD -> AddColor.copy(alpha = 0.30f)
|
||||
GitDiffLineType.DELETE -> DeleteColor.copy(alpha = 0.30f)
|
||||
GitDiffLineType.CONTEXT -> Color.Transparent
|
||||
}
|
||||
val marker =
|
||||
when (line.type) {
|
||||
GitDiffLineType.ADD -> "+"
|
||||
GitDiffLineType.DELETE -> "-"
|
||||
GitDiffLineType.CONTEXT -> " "
|
||||
}
|
||||
val markerColor =
|
||||
when (line.type) {
|
||||
GitDiffLineType.ADD -> AddColor
|
||||
GitDiffLineType.DELETE -> DeleteColor
|
||||
GitDiffLineType.CONTEXT -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
}
|
||||
val numberColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
|
||||
|
||||
val content =
|
||||
remember(line.content, language, darkMode, emphasis) {
|
||||
val base =
|
||||
if (language != null && line.content.isNotEmpty()) {
|
||||
CodeHighlighter.highlight(line.content, language, darkMode)
|
||||
} else {
|
||||
AnnotatedString(line.content)
|
||||
}
|
||||
if (emphasis != null && !emphasis.isEmpty) {
|
||||
buildAnnotatedString {
|
||||
append(base)
|
||||
addStyle(
|
||||
SpanStyle(background = emphasisColor),
|
||||
emphasis.start.coerceIn(0, line.content.length),
|
||||
emphasis.end.coerceIn(0, line.content.length),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.background(background),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
LineNumber(line.oldNumber, gutterWidth, numberColor)
|
||||
LineNumber(line.newNumber, gutterWidth, numberColor)
|
||||
Text(
|
||||
text = marker,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = DiffFontSize,
|
||||
lineHeight = DiffLineHeight,
|
||||
color = markerColor,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
Text(
|
||||
text = content,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = DiffFontSize,
|
||||
lineHeight = DiffLineHeight,
|
||||
softWrap = false,
|
||||
maxLines = 1,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(start = 4.dp, end = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LineNumber(
|
||||
number: Int?,
|
||||
width: Dp,
|
||||
color: Color,
|
||||
) {
|
||||
Text(
|
||||
text = number?.toString() ?: "",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = DiffFontSize,
|
||||
lineHeight = DiffLineHeight,
|
||||
color = color,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.width(width).padding(horizontal = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun changeBadge(change: GitFileChange): Pair<MaterialSymbol, Color> =
|
||||
when (change) {
|
||||
GitFileChange.ADD -> MaterialSymbols.AddCircle to AddColor
|
||||
GitFileChange.DELETE -> MaterialSymbols.Cancel to DeleteColor
|
||||
GitFileChange.RENAME -> MaterialSymbols.AltRoute to MaterialTheme.colorScheme.primary
|
||||
GitFileChange.MODIFY -> MaterialSymbols.Edit to MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
private fun gutterWidthFor(file: GitDiffFile): Dp {
|
||||
val maxLine =
|
||||
file.hunks.maxOfOrNull { hunk ->
|
||||
hunk.lines.maxOfOrNull { maxOf(it.oldNumber ?: 0, it.newNumber ?: 0) } ?: 0
|
||||
} ?: 0
|
||||
val digits = maxLine.toString().length.coerceAtLeast(2)
|
||||
return (digits * 8 + 8).dp
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitHttpClient
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
private sealed interface ChangesState {
|
||||
object Idle : ChangesState
|
||||
|
||||
object Loading : ChangesState
|
||||
|
||||
class Loaded(
|
||||
val patch: ParsedPatch,
|
||||
) : ChangesState
|
||||
|
||||
object Failed : ChangesState
|
||||
}
|
||||
|
||||
/**
|
||||
* "View changes" section of a pull-request card. A NIP-34 PR references a clone
|
||||
* URL + commit instead of embedding a patch, so the actual diff is computed on
|
||||
* demand over the git smart-HTTP client (base = merge base, head = current
|
||||
* commit) and rendered with the shared [GitDiffView].
|
||||
*/
|
||||
@Composable
|
||||
fun GitPullRequestChanges(
|
||||
cloneUrls: List<String>,
|
||||
headCommit: String?,
|
||||
mergeBase: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val candidates = remember(cloneUrls) { candidateUrls(cloneUrls) }
|
||||
if (candidates.isEmpty() || headCommit.isNullOrBlank()) return
|
||||
|
||||
var state by remember(headCommit, mergeBase) { mutableStateOf<ChangesState>(ChangesState.Idle) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun load() {
|
||||
state = ChangesState.Loading
|
||||
scope.launch {
|
||||
state =
|
||||
try {
|
||||
val patch = loadDiff(accountViewModel, candidates, headCommit, mergeBase)
|
||||
ChangesState.Loaded(patch)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
ChangesState.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val s = state) {
|
||||
ChangesState.Idle ->
|
||||
FilledTonalButton(onClick = { load() }, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Icon(MaterialSymbols.Code, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Text(stringRes(R.string.git_pr_view_changes), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
|
||||
ChangesState.Loading ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
|
||||
Text(stringRes(R.string.git_pr_loading_changes), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
is ChangesState.Loaded ->
|
||||
if (s.patch.hasDiff) {
|
||||
Column(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
GitDiffView(s.patch, Modifier.fillMaxWidth())
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringRes(R.string.git_pr_no_changes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
ChangesState.Failed ->
|
||||
FilledTonalButton(onClick = { load() }, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Text(stringRes(R.string.git_pr_changes_retry), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadDiff(
|
||||
accountViewModel: AccountViewModel,
|
||||
candidates: List<String>,
|
||||
headCommit: String,
|
||||
mergeBase: String?,
|
||||
): ParsedPatch =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = GitHttpClient(accountViewModel.httpClientBuilder::okHttpClientForPreview)
|
||||
var lastError: Exception? = null
|
||||
for (url in candidates) {
|
||||
try {
|
||||
return@withContext client.computeDiff(url, headCommit, mergeBase?.takeIf { it.isNotBlank() })
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
lastError = e
|
||||
}
|
||||
}
|
||||
throw lastError ?: IllegalStateException("no usable clone URL")
|
||||
}
|
||||
|
||||
private fun candidateUrls(cloneUrls: List<String>): List<String> {
|
||||
val out = LinkedHashSet<String>()
|
||||
for (raw in cloneUrls) {
|
||||
val url = raw.trim()
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) continue
|
||||
out.add(url)
|
||||
if (!url.removeSuffix("/").endsWith(".git")) out.add(url.removeSuffix("/") + ".git")
|
||||
}
|
||||
return out.toList()
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.GitStatusIndex
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
|
||||
private enum class StatusTarget { OPEN, CLOSED, APPLIED }
|
||||
|
||||
/**
|
||||
* NIP-34 status controls for an issue, patch or pull request. Visible only to the
|
||||
* people allowed to moderate the thread — the item's author and the repository
|
||||
* owner / maintainers. Patches and PRs additionally offer "Mark merged"
|
||||
* ([GitStatusAppliedEvent]); everything can be closed/reopened. [GitStatusIndex]
|
||||
* reflects the published status everywhere.
|
||||
*/
|
||||
@Composable
|
||||
fun GitStatusActions(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val event = note.event ?: return
|
||||
val repoAddress = repositoryAddressOf(event) ?: return
|
||||
val isPatchOrPr = event is GitPatchEvent || event is GitPullRequestEvent
|
||||
|
||||
val canModerate = remember(event, note) { canModerate(repoAddress, note, accountViewModel) }
|
||||
if (!canModerate) return
|
||||
|
||||
val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle()
|
||||
if (index == null) return
|
||||
val current = index?.get(note.idHex)
|
||||
val closedOrApplied = current is GitStatusClosedEvent || current is GitStatusAppliedEvent
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (closedOrApplied) {
|
||||
FilledTonalButton(onClick = { sendStatus(accountViewModel, note, StatusTarget.OPEN) }) {
|
||||
Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Text(stringRes(R.string.git_status_reopen), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
} else {
|
||||
if (isPatchOrPr) {
|
||||
FilledTonalButton(onClick = { sendStatus(accountViewModel, note, StatusTarget.APPLIED) }) {
|
||||
Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Text(stringRes(R.string.git_status_mark_merged), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { sendStatus(accountViewModel, note, StatusTarget.CLOSED) },
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Text(stringRes(R.string.git_status_close), modifier = Modifier.padding(start = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun repositoryAddressOf(event: Event): Address? =
|
||||
when (event) {
|
||||
is GitIssueEvent -> event.repositoryAddress()
|
||||
is GitPatchEvent -> event.repositoryAddress()
|
||||
is GitPullRequestEvent -> event.repositoryAddress()
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun canModerate(
|
||||
repoAddress: Address,
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
): Boolean {
|
||||
val myHex = accountViewModel.account.signer.pubKey
|
||||
if (accountViewModel.isLoggedUser(note.author)) return true
|
||||
if (myHex == repoAddress.pubKeyHex) return true
|
||||
val repoEvent = LocalCache.getAddressableNoteIfExists(repoAddress)?.event as? GitRepositoryEvent
|
||||
return repoEvent?.maintainers()?.contains(myHex) == true
|
||||
}
|
||||
|
||||
private fun sendStatus(
|
||||
accountViewModel: AccountViewModel,
|
||||
note: Note,
|
||||
target: StatusTarget,
|
||||
) {
|
||||
val hint = note.toEventHint<Event>() ?: return
|
||||
accountViewModel.launchSigner {
|
||||
val template =
|
||||
when (target) {
|
||||
StatusTarget.OPEN -> GitStatusOpenEvent.build("", hint)
|
||||
StatusTarget.CLOSED -> GitStatusClosedEvent.build("", hint)
|
||||
StatusTarget.APPLIED -> GitStatusAppliedEvent.build("", hint)
|
||||
}
|
||||
val signed = accountViewModel.account.signer.sign(template)
|
||||
accountViewModel.account.sendAutomatic(signed)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -74,7 +73,6 @@ fun GitStatusPill(
|
||||
modifier: Modifier = Modifier,
|
||||
defaultIfMissing: StatusKind? = null,
|
||||
) {
|
||||
LaunchedEffect(Unit) { GitStatusIndex.startIfNeeded() }
|
||||
val index by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle()
|
||||
val map = index ?: return // hide pill until the initial scan completes — avoids a default-then-real flicker
|
||||
val kind = map[targetIdHex]?.statusKind() ?: defaultIfMissing ?: return
|
||||
|
||||
@@ -310,6 +310,24 @@ class TopNavFilterState(
|
||||
)
|
||||
}
|
||||
|
||||
private val _gitRepositoryRoutes =
|
||||
combineTransform(
|
||||
livePeopleListsFlow,
|
||||
liveInterestFlows,
|
||||
) { peopleLists, interests ->
|
||||
checkNotInMainThread()
|
||||
emit(
|
||||
listOf(
|
||||
// Git repository announcements can be narrowed by author, hashtag and geohash,
|
||||
// so this mirrors the kind3 catalog plus "Mine" — the user's own repositories.
|
||||
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow),
|
||||
peopleLists,
|
||||
interests,
|
||||
listOf(muteListFollow),
|
||||
).flatten().toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
private val _kind3GlobalPeople =
|
||||
livePeopleListsFlow.transform { peopleLists ->
|
||||
checkNotInMainThread()
|
||||
@@ -385,6 +403,11 @@ class TopNavFilterState(
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow))
|
||||
|
||||
val gitRepositoryRoutes =
|
||||
_gitRepositoryRoutes
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow))
|
||||
|
||||
fun destroy() {
|
||||
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
|
||||
}
|
||||
|
||||
+12
@@ -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,
|
||||
|
||||
+52
@@ -42,6 +42,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
|
||||
@@ -59,10 +60,12 @@ fun ListOfBookmarkGroupsFeedView(
|
||||
defaultBookmarks: BookmarkListState,
|
||||
oldBookmarks: OldBookmarkListState,
|
||||
pinnedNotes: PinListState,
|
||||
repositories: GitRepositoryListState,
|
||||
groupListFeedSource: StateFlow<List<LabeledBookmarkList>>,
|
||||
openDefaultBookmarks: () -> Unit,
|
||||
openOldBookmarks: () -> Unit,
|
||||
openPinnedNotes: () -> Unit,
|
||||
openRepositories: () -> Unit,
|
||||
onOpenItem: (String, BookmarkType) -> Unit,
|
||||
onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit,
|
||||
onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit,
|
||||
@@ -91,6 +94,11 @@ fun ListOfBookmarkGroupsFeedView(
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
|
||||
item {
|
||||
RepositoriesBookmarkList(repositories, openRepositories)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
bookmarkGroupFeedState,
|
||||
key = { _: Int, item: LabeledBookmarkList -> item.identifier },
|
||||
@@ -197,6 +205,50 @@ fun PinnedNotesList(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RepositoriesBookmarkList(
|
||||
repositories: GitRepositoryListState,
|
||||
openRepositories: () -> Unit,
|
||||
) {
|
||||
val repositoryAddresses by repositories.publicRepositoryAddressSet.collectAsStateWithLifecycle()
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = openRepositories),
|
||||
headlineContent = {
|
||||
Text(stringRes(R.string.repository_bookmarks), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
supportingContent = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
stringRes(R.string.repository_bookmarks_explainer),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Code,
|
||||
contentDescription = stringRes(R.string.bookmark_list_icon_label),
|
||||
modifier = Size40Modifier,
|
||||
)
|
||||
Spacer(StdVertSpacer)
|
||||
BookmarkMembershipStatusAndNumberDisplay(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
postBookmarksSize = repositoryAddresses.size,
|
||||
articleBookmarksSize = 0,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OldBookmarkList(
|
||||
oldBookmarks: OldBookmarkListState,
|
||||
|
||||
+7
@@ -35,6 +35,7 @@ import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
|
||||
@@ -59,10 +60,12 @@ fun ListOfBookmarkGroupsScreen(
|
||||
defaultBookmarks = accountViewModel.account.bookmarkState,
|
||||
oldBookmarks = accountViewModel.account.oldBookmarkState,
|
||||
pinnedNotes = accountViewModel.account.pinState,
|
||||
repositories = accountViewModel.account.gitRepositoryListState,
|
||||
listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow,
|
||||
openDefaultBookmarks = { nav.nav(Route.Bookmarks) },
|
||||
openOldBookmarks = { nav.nav(Route.OldBookmarks) },
|
||||
openPinnedNotes = { nav.nav(Route.PinnedNotes) },
|
||||
openRepositories = { nav.nav(Route.BookmarkedRepositories) },
|
||||
addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) },
|
||||
openBookmarkGroup = { identifier, bookmarkType ->
|
||||
nav.nav(Route.BookmarkGroupView(identifier, bookmarkType))
|
||||
@@ -101,10 +104,12 @@ fun ListOfBookmarkGroupsFeed(
|
||||
defaultBookmarks: BookmarkListState,
|
||||
oldBookmarks: OldBookmarkListState,
|
||||
pinnedNotes: PinListState,
|
||||
repositories: GitRepositoryListState,
|
||||
listSource: StateFlow<List<LabeledBookmarkList>>,
|
||||
openDefaultBookmarks: () -> Unit,
|
||||
openOldBookmarks: () -> Unit,
|
||||
openPinnedNotes: () -> Unit,
|
||||
openRepositories: () -> Unit,
|
||||
addBookmarkGroup: () -> Unit,
|
||||
openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit,
|
||||
renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit,
|
||||
@@ -148,10 +153,12 @@ fun ListOfBookmarkGroupsFeed(
|
||||
defaultBookmarks = defaultBookmarks,
|
||||
oldBookmarks = oldBookmarks,
|
||||
pinnedNotes = pinnedNotes,
|
||||
repositories = repositories,
|
||||
groupListFeedSource = listSource,
|
||||
openDefaultBookmarks = openDefaultBookmarks,
|
||||
openOldBookmarks = openOldBookmarks,
|
||||
openPinnedNotes = openPinnedNotes,
|
||||
openRepositories = openRepositories,
|
||||
onOpenItem = openBookmarkGroup,
|
||||
onRenameItem = renameBookmarkGroup,
|
||||
onItemDescriptionChange = changeBookmarkGroupDescription,
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.dal.BookmarkRepositoriesFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
|
||||
@Composable
|
||||
fun BookmarkedRepositoriesScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val repositoriesFeedViewModel: BookmarkRepositoriesFeedViewModel =
|
||||
viewModel(
|
||||
key = "NostrBookmarkRepositoriesFeedViewModel",
|
||||
factory = BookmarkRepositoriesFeedViewModel.Factory(accountViewModel.account),
|
||||
)
|
||||
|
||||
val repositoryBookmarks by accountViewModel.account.gitRepositoryListState.publicRepositoryAddressSet
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(repositoryBookmarks) {
|
||||
repositoriesFeedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
// Preload any bookmarked repo announcements not yet in cache so they don't pop in one by one.
|
||||
PreloadRepositoryEvents(repositoryBookmarks, accountViewModel)
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
TopBarWithBackButton(stringRes(id = R.string.repository_bookmarks), nav)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
RefresheableFeedView(
|
||||
repositoriesFeedViewModel,
|
||||
null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreloadRepositoryEvents(
|
||||
repositoryBookmarks: Set<Address>,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val eventFinder = accountViewModel.dataSources().eventFinder
|
||||
val account = accountViewModel.account
|
||||
|
||||
val queries =
|
||||
remember(repositoryBookmarks) {
|
||||
repositoryBookmarks
|
||||
.map { account.cache.getOrCreateAddressableNote(it) }
|
||||
.filter { it.event == null }
|
||||
.map { EventFinderQueryState(it, account) }
|
||||
}
|
||||
|
||||
DisposableEffect(queries) {
|
||||
eventFinder.subscribe(queries)
|
||||
onDispose {
|
||||
eventFinder.unsubscribe(queries)
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
|
||||
/**
|
||||
* The user's bookmarked (starred) git repositories — the public NIP-51 kind 10018
|
||||
* [com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState] addresses
|
||||
* resolved to their addressable notes, newest first.
|
||||
*/
|
||||
class BookmarkRepositoriesFeedFilter(
|
||||
val account: Account,
|
||||
) : FeedFilter<Note>() {
|
||||
override fun feedKey(): String =
|
||||
account.gitRepositoryListState.publicRepositoryAddressSet.value
|
||||
.hashCode()
|
||||
.toString()
|
||||
|
||||
override fun feed(): List<Note> =
|
||||
account.gitRepositoryListState.publicRepositoryAddressSet.value
|
||||
.map { account.cache.getOrCreateAddressableNote(it) }
|
||||
.sortedByDescending { it.createdAt() ?: 0L }
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.dal
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
|
||||
|
||||
@Stable
|
||||
class BookmarkRepositoriesFeedViewModel(
|
||||
val account: Account,
|
||||
) : AndroidFeedViewModel(BookmarkRepositoriesFeedFilter(account)) {
|
||||
class Factory(
|
||||
val account: Account,
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = BookmarkRepositoriesFeedViewModel(account) as T
|
||||
}
|
||||
}
|
||||
+79
-3
@@ -21,14 +21,18 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -37,13 +41,17 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding
|
||||
import com.vitorpamplona.amethyst.model.GitPullRequestUpdateIndex
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport
|
||||
@@ -87,15 +95,20 @@ fun GitItemFeedLoaded(
|
||||
listState: LazyListState,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
labelFilter: String? = null,
|
||||
) {
|
||||
val items by loaded.feed.collectAsStateWithLifecycle()
|
||||
val list =
|
||||
remember(items, labelFilter) {
|
||||
if (labelFilter == null) items.list else items.list.filter { labelFilter in gitLabelsOf(it.event) }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
contentPadding = rememberFeedContentPadding(FeedPadding),
|
||||
state = listState,
|
||||
) {
|
||||
itemsIndexed(
|
||||
items.list,
|
||||
list,
|
||||
key = { _, item -> item.idHex },
|
||||
contentType = { _, item -> item.event?.kind ?: -1 },
|
||||
) { _, item ->
|
||||
@@ -196,15 +209,78 @@ private fun GitItemRowContent(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN)
|
||||
val labels = remember(note.event) { gitLabelsOf(note.event) }
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
) {
|
||||
GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN)
|
||||
if (note.event is GitPullRequestEvent) {
|
||||
GitRevisedChip(note.idHex)
|
||||
}
|
||||
labels.take(6).forEach { LabelChip(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun gitSubjectOf(event: Event?): String? =
|
||||
@Composable
|
||||
private fun LabelChip(label: String) {
|
||||
Text(
|
||||
text = "#$label",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/** Small "Revised" badge shown when a pull request has a later kind-1619 update. */
|
||||
@Composable
|
||||
private fun GitRevisedChip(prIdHex: String) {
|
||||
val index by GitPullRequestUpdateIndex.latestByPullRequest.collectAsStateWithLifecycle()
|
||||
if (index?.get(prIdHex) == null) return
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Sync,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(R.string.git_pr_revised),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun gitSubjectOf(event: Event?): String? =
|
||||
when (event) {
|
||||
is GitIssueEvent -> event.subject()?.takeIf { it.isNotBlank() }
|
||||
is GitPullRequestEvent -> event.subject()?.takeIf { it.isNotBlank() }
|
||||
is GitPatchEvent -> event.subject()?.takeIf { it.isNotBlank() }
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun gitLabelsOf(event: Event?): List<String> =
|
||||
when (event) {
|
||||
is GitIssueEvent -> event.topics()
|
||||
is GitPullRequestEvent -> event.labels()
|
||||
else -> emptyList()
|
||||
}.filter { it.isNotBlank() }.distinct()
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
|
||||
@Composable
|
||||
fun GitNewIssueScreen(
|
||||
address: Address,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
LoadAddressableNote(address, accountViewModel) { note ->
|
||||
note?.let { GitNewIssueForm(it, accountViewModel, nav) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen "New issue" composer: a subject and a markdown body. On submit it builds a
|
||||
* NIP-34 [GitIssueEvent] addressed to [repoNote], signs it with the account signer and
|
||||
* broadcasts it, then returns to the issues list.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun GitNewIssueForm(
|
||||
repoNote: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
var subject by rememberSaveable { mutableStateOf("") }
|
||||
var body by rememberSaveable { mutableStateOf("") }
|
||||
var labels by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
ShorterTopAppBar(
|
||||
title = { Text(stringRes(R.string.git_new_issue_title)) },
|
||||
navigationIcon = {
|
||||
Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = nav::popBack) { ArrowBackIcon() }
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(
|
||||
enabled = subject.isNotBlank(),
|
||||
onClick = {
|
||||
sendGitIssue(accountViewModel, repoNote, subject.trim(), body.trim(), parseLabels(labels))
|
||||
nav.popBack()
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.git_new_issue_create))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(LocalDisappearingScaffoldPadding.current)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = subject,
|
||||
onValueChange = { subject = it },
|
||||
label = { Text(stringRes(R.string.git_new_issue_subject)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = body,
|
||||
onValueChange = { body = it },
|
||||
label = { Text(stringRes(R.string.git_new_issue_body)) },
|
||||
minLines = 6,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = labels,
|
||||
onValueChange = { labels = it },
|
||||
label = { Text(stringRes(R.string.git_new_issue_labels)) },
|
||||
placeholder = { Text(stringRes(R.string.git_new_issue_labels_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Splits a free-text label field on commas/whitespace, stripping any leading `#`. */
|
||||
private fun parseLabels(raw: String): List<String> =
|
||||
raw
|
||||
.split(',', ' ', '\n', '\t')
|
||||
.map { it.trim().removePrefix("#") }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
|
||||
private fun sendGitIssue(
|
||||
accountViewModel: AccountViewModel,
|
||||
repoNote: AddressableNote,
|
||||
subject: String,
|
||||
body: String,
|
||||
labels: List<String>,
|
||||
) {
|
||||
val repositoryHint = repoNote.toEventHint<GitRepositoryEvent>() ?: return
|
||||
accountViewModel.launchSigner {
|
||||
val template = GitIssueEvent.build(subject, body, repositoryHint, emptyList(), labels)
|
||||
val signed = accountViewModel.account.signer.sign(template)
|
||||
accountViewModel.account.sendAutomatic(signed)
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
|
||||
/**
|
||||
* Edit form for a NIP-34 repository announcement (kind 30617). Because the event
|
||||
* is addressable, saving republishes it under the same `d` tag — preserving the
|
||||
* earliest-unique-commit, relays, maintainers and personal-fork flag while letting
|
||||
* the owner edit the name, description, web/clone URLs and topics.
|
||||
*/
|
||||
@Composable
|
||||
fun GitRepoSettingsDialog(
|
||||
event: GitRepositoryEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var name by rememberSaveable { mutableStateOf(event.name() ?: event.dTag()) }
|
||||
var description by rememberSaveable { mutableStateOf(event.description() ?: "") }
|
||||
var webUrls by rememberSaveable { mutableStateOf(event.webs().joinToString("\n")) }
|
||||
var cloneUrls by rememberSaveable { mutableStateOf(event.clones().joinToString("\n")) }
|
||||
var topics by rememberSaveable { mutableStateOf(event.hashtags().joinToString(", ")) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringRes(R.string.git_repo_settings_title)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringRes(R.string.git_repo_settings_name)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text(stringRes(R.string.git_repo_settings_description)) },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = cloneUrls,
|
||||
onValueChange = { cloneUrls = it },
|
||||
label = { Text(stringRes(R.string.git_repo_settings_clone_urls)) },
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = webUrls,
|
||||
onValueChange = { webUrls = it },
|
||||
label = { Text(stringRes(R.string.git_repo_settings_web_urls)) },
|
||||
minLines = 1,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = topics,
|
||||
onValueChange = { topics = it },
|
||||
label = { Text(stringRes(R.string.git_repo_settings_topics)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
enabled = name.isNotBlank(),
|
||||
onClick = {
|
||||
saveRepository(accountViewModel, event, name.trim(), description.trim(), webUrls, cloneUrls, topics)
|
||||
onDismiss()
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.git_repo_settings_save))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringRes(R.string.git_new_issue_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun splitLines(raw: String): List<String> =
|
||||
raw
|
||||
.split('\n', ',')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
|
||||
private fun saveRepository(
|
||||
accountViewModel: AccountViewModel,
|
||||
event: GitRepositoryEvent,
|
||||
name: String,
|
||||
description: String,
|
||||
webUrls: String,
|
||||
cloneUrls: String,
|
||||
topics: String,
|
||||
) {
|
||||
accountViewModel.launchSigner {
|
||||
val template =
|
||||
GitRepositoryEvent.build(
|
||||
name = name,
|
||||
description = description.ifBlank { null },
|
||||
webUrls = splitLines(webUrls),
|
||||
cloneUrls = splitLines(cloneUrls),
|
||||
relays = event.relays(),
|
||||
maintainers = event.maintainers(),
|
||||
hashtags = splitLines(topics).map { it.removePrefix("#") },
|
||||
earliestUniqueCommit = event.earliestUniqueCommit(),
|
||||
personalFork = event.isPersonalFork(),
|
||||
dTag = event.dTag(),
|
||||
)
|
||||
val signed = accountViewModel.account.signer.sign(template)
|
||||
accountViewModel.account.sendAutomatic(signed)
|
||||
}
|
||||
}
|
||||
+527
@@ -0,0 +1,527 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitCommit
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
|
||||
private val CardShape = RoundedCornerShape(15.dp)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hero / identity — name, description and topics in the dashboard language.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The top-bar title: owner avatar + project name, with the repo description as a subtitle. */
|
||||
@Composable
|
||||
fun RepoTitleBar(
|
||||
event: GitRepositoryEvent?,
|
||||
fallback: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val owner = event?.pubKey?.let { LocalCache.checkGetOrCreateUser(it) }
|
||||
val description = event?.description()?.takeIf { it.isNotBlank() }
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (owner != null) {
|
||||
ClickableUserPicture(
|
||||
baseUser = owner,
|
||||
size = 28.dp,
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = { nav.nav(Route.Profile(it.pubkeyHex)) },
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f, fill = false)) {
|
||||
Text(
|
||||
text = event?.name() ?: fallback,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The repository's topic chips and personal-fork badge (name + description live in the top bar). */
|
||||
@Composable
|
||||
fun RepoHero(event: GitRepositoryEvent) {
|
||||
val topics = remember(event) { event.hashtags().filter { it.isNotBlank() } }
|
||||
val isFork = event.isPersonalFork()
|
||||
if (topics.isEmpty() && !isFork) return
|
||||
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (isFork) PillChip(stringRes(R.string.git_repo_personal_fork))
|
||||
topics.forEach { PillChip("#$it") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PillChip(label: String) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f))
|
||||
.padding(horizontal = 10.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Social pulse — zaps, reactions and comments on the repository announcement.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun RepoSocialRow(
|
||||
note: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// The standard note footer: reply, boost, like, zap (+ zapraiser and the reaction gallery),
|
||||
// so the repository announcement gets the exact same interactions as any other note.
|
||||
ReactionsRow(
|
||||
baseNote = note,
|
||||
showReactionDetail = true,
|
||||
addPadding = false,
|
||||
editState = null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stat tiles — git facts (branches, tags, files, last updated).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun RepoStatTiles(
|
||||
branches: Int,
|
||||
tags: Int,
|
||||
files: Int,
|
||||
updatedEpochSec: Long?,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
StatTile(MaterialSymbols.Commit, branches.toString(), stringRes(R.string.git_repo_stat_branches), Modifier.weight(1f))
|
||||
StatTile(MaterialSymbols.Tag, tags.toString(), stringRes(R.string.git_repo_stat_tags), Modifier.weight(1f))
|
||||
StatTile(MaterialSymbols.Description, compactCount(files), stringRes(R.string.git_repo_stat_files), Modifier.weight(1f))
|
||||
StatTile(MaterialSymbols.Schedule, updatedEpochSec?.let { relativeShort(it) } ?: "—", stringRes(R.string.git_repo_stat_updated), Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatTile(
|
||||
symbol: MaterialSymbol,
|
||||
value: String,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.clip(CardShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Language breakdown bar — proportional colours by file count.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class LanguageSlice(
|
||||
val name: String,
|
||||
val fraction: Float,
|
||||
val color: Color,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun RepoLanguageBar(slices: List<LanguageSlice>) {
|
||||
if (slices.isEmpty()) return
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(10.dp)
|
||||
.clip(CircleShape),
|
||||
) {
|
||||
slices.forEach { slice ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(slice.fraction.coerceAtLeast(0.0001f))
|
||||
.height(10.dp)
|
||||
.background(slice.color),
|
||||
)
|
||||
}
|
||||
}
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
slices.forEach { slice ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Box(Modifier.size(9.dp).clip(CircleShape).background(slice.color))
|
||||
Text(
|
||||
text = slice.name + " " + (slice.fraction * 100).toInt() + "%",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LangDef(
|
||||
val name: String,
|
||||
val color: Long,
|
||||
) {
|
||||
override fun equals(other: Any?) = other is LangDef && other.name == name
|
||||
|
||||
override fun hashCode() = name.hashCode()
|
||||
}
|
||||
|
||||
private val EXT_TO_LANG: Map<String, LangDef> =
|
||||
buildMap {
|
||||
fun add(
|
||||
def: LangDef,
|
||||
vararg exts: String,
|
||||
) = exts.forEach { put(it, def) }
|
||||
add(LangDef("Kotlin", 0xFFA97BFF), "kt", "kts")
|
||||
add(LangDef("Java", 0xFFB07219), "java")
|
||||
add(LangDef("JavaScript", 0xFFF1E05A), "js", "mjs", "cjs", "jsx")
|
||||
add(LangDef("TypeScript", 0xFF3178C6), "ts", "tsx")
|
||||
add(LangDef("Python", 0xFF3572A5), "py")
|
||||
add(LangDef("Ruby", 0xFF701516), "rb")
|
||||
add(LangDef("Rust", 0xFFDEA584), "rs")
|
||||
add(LangDef("Go", 0xFF00ADD8), "go")
|
||||
add(LangDef("C", 0xFF555555), "c", "h")
|
||||
add(LangDef("C++", 0xFFF34B7D), "cpp", "cc", "cxx", "hpp", "hh", "hxx")
|
||||
add(LangDef("C#", 0xFF178600), "cs")
|
||||
add(LangDef("Swift", 0xFFF05138), "swift")
|
||||
add(LangDef("PHP", 0xFF4F5D95), "php")
|
||||
add(LangDef("Shell", 0xFF89E051), "sh", "bash", "zsh", "ksh")
|
||||
add(LangDef("HTML", 0xFFE34C26), "html", "htm")
|
||||
add(LangDef("CSS", 0xFF563D7C), "css")
|
||||
add(LangDef("SCSS", 0xFFC6538C), "scss")
|
||||
add(LangDef("Markdown", 0xFF083FA1), "md", "markdown")
|
||||
add(LangDef("JSON", 0xFF40803F), "json")
|
||||
add(LangDef("YAML", 0xFFCB171E), "yml", "yaml")
|
||||
add(LangDef("XML", 0xFF0060AC), "xml")
|
||||
add(LangDef("Gradle", 0xFF02303A), "gradle")
|
||||
add(LangDef("Dart", 0xFF00B4AB), "dart")
|
||||
add(LangDef("TOML", 0xFF9C4221), "toml")
|
||||
}
|
||||
|
||||
private const val OTHER_COLOR = 0xFFBBBBBB
|
||||
|
||||
/** Buckets files by recognised language (by extension), returning the top slices + an "Other" remainder. */
|
||||
fun computeLanguageBreakdown(files: List<String>): List<LanguageSlice> {
|
||||
if (files.isEmpty()) return emptyList()
|
||||
val counts = HashMap<LangDef, Int>()
|
||||
var other = 0
|
||||
for (f in files) {
|
||||
val ext = f.substringAfterLast('.', "").lowercase()
|
||||
val def = EXT_TO_LANG[ext]
|
||||
if (def == null) other++ else counts[def] = (counts[def] ?: 0) + 1
|
||||
}
|
||||
val total = files.size.toFloat()
|
||||
val ranked = counts.entries.sortedByDescending { it.value }
|
||||
val top = ranked.take(6)
|
||||
val remainder = ranked.drop(6).sumOf { it.value } + other
|
||||
|
||||
val slices =
|
||||
top.map { LanguageSlice(it.key.name, it.value / total, Color(it.key.color)) }.toMutableList()
|
||||
if (remainder > 0) {
|
||||
slices.add(LanguageSlice("Other", remainder / total, Color(OTHER_COLOR)))
|
||||
}
|
||||
return slices
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External-host notice — for repos we can't clone over http(s) (htree://, nostr://, …).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** True when the repo can be cloned over http(s), i.e. the smart-HTTP browser can fetch it. */
|
||||
fun repoHasFetchableClone(event: GitRepositoryEvent): Boolean = event.clones().any { it.startsWith("http://") || it.startsWith("https://") }
|
||||
|
||||
/**
|
||||
* Shown instead of the snapshot dashboard for repos whose only clone URLs use a transport the
|
||||
* smart-HTTP browser can't fetch (e.g. Iris's `htree://`). Offers the web URL as an
|
||||
* open-in-browser fallback when one is present.
|
||||
*/
|
||||
@Composable
|
||||
fun RepoExternalNotice(event: GitRepositoryEvent) {
|
||||
val web = remember(event) { event.webs().firstOrNull { it.startsWith("http://") || it.startsWith("https://") } }
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(CardShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Icon(MaterialSymbols.Public, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Text(
|
||||
text = stringRes(R.string.git_repo_external_host_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringRes(R.string.git_repo_external_host_body),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
if (web != null) {
|
||||
ClickableUrl(url = web, urlText = stringRes(R.string.git_repo_open_in_browser))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Last-commit strip.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun RepoLastCommit(
|
||||
commit: GitCommit,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(CardShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.let { if (onClick != null) it.clickable(onClick = onClick) else it }
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(MaterialSymbols.Commit, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = commit.summary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = commit.authorName + " · " + relativeShort(commit.authorTimeSec),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = commit.shortOid,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent activity pulse — newest issues / patches / status events.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun RepoActivityPulse(
|
||||
items: List<Note>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(CardShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(vertical = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.git_repo_recent_activity),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
)
|
||||
items.forEach { ActivityRow(it, accountViewModel, nav) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActivityRow(
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val event = note.event ?: return
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { nav.nav { routeFor(note, accountViewModel.account) } }
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = activityIcon(event),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = gitSubjectOf(event) ?: stringRes(R.string.git_untitled),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TimeAgo(note)
|
||||
}
|
||||
}
|
||||
|
||||
private fun activityIcon(event: Event): MaterialSymbol =
|
||||
when (event) {
|
||||
is GitIssueEvent -> MaterialSymbols.ErrorOutline
|
||||
is GitPullRequestEvent -> MaterialSymbols.CallMerge
|
||||
is GitPatchEvent -> MaterialSymbols.CallMerge
|
||||
else -> MaterialSymbols.Commit
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Compacts large counts: 1234 -> "1.2k", 2_500_000 -> "2.5M". */
|
||||
private fun compactCount(n: Int): String =
|
||||
when {
|
||||
n < 1_000 -> n.toString()
|
||||
n < 1_000_000 -> trimDecimal(n / 1_000.0) + "k"
|
||||
else -> trimDecimal(n / 1_000_000.0) + "M"
|
||||
}
|
||||
|
||||
private fun trimDecimal(v: Double): String {
|
||||
val rounded = (v * 10).toInt() / 10.0
|
||||
return if (rounded % 1.0 == 0.0) rounded.toInt().toString() else rounded.toString()
|
||||
}
|
||||
|
||||
/** A coarse "time since" label for epoch seconds: "5m", "3h", "2d", "4w", "1y". */
|
||||
private fun relativeShort(epochSec: Long): String {
|
||||
val deltaSec = (System.currentTimeMillis() / 1000) - epochSec
|
||||
if (deltaSec < 60) return "now"
|
||||
val mins = deltaSec / 60
|
||||
if (mins < 60) return mins.toString() + "m"
|
||||
val hours = mins / 60
|
||||
if (hours < 24) return hours.toString() + "h"
|
||||
val days = hours / 24
|
||||
if (days < 7) return days.toString() + "d"
|
||||
val weeks = days / 7
|
||||
if (weeks < 52) return weeks.toString() + "w"
|
||||
return (days / 365).toString() + "y"
|
||||
}
|
||||
-254
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+605
-151
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -28,50 +30,81 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SecondaryTabRow
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.nip34Git.GitBrowseState
|
||||
import com.vitorpamplona.amethyst.commons.nip34Git.GitRepoSnapshotCache
|
||||
import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.GitStatusIndex
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
|
||||
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.GitCodeTab
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code.GitReadmeSection
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryIssuesFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal.RepositoryPatchesFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.datasource.RepositoryFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project Home + drill-in screens.
|
||||
//
|
||||
// The repository is presented as a scrollable "project home" (facts + README +
|
||||
// navigation cards) rather than a tab bar. Code, Issues and Pull Requests are
|
||||
// dedicated screens, each owning its own disappearing top bar — so per-section
|
||||
// headers (branch/search selectors, status filters) track the bar correctly and
|
||||
// heavy content (the code browser, the feeds) only loads when navigated to.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
fun GitRepositoryScreen(
|
||||
@@ -81,7 +114,7 @@ fun GitRepositoryScreen(
|
||||
) {
|
||||
LoadAddressableNote(address, accountViewModel) { note ->
|
||||
note?.let {
|
||||
PrepareGitRepositoryScreen(
|
||||
GitRepositoryHome(
|
||||
note = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
@@ -91,149 +124,586 @@ fun GitRepositoryScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrepareGitRepositoryScreen(
|
||||
fun GitRepositoryCodeScreen(
|
||||
address: Address,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
LoadAddressableNote(address, accountViewModel) { note ->
|
||||
note?.let { GitRepositoryCode(it, accountViewModel, nav) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GitRepositoryIssuesScreen(
|
||||
address: Address,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
LoadAddressableNote(address, accountViewModel) { note ->
|
||||
note?.let { GitRepositoryIssues(it, accountViewModel, nav) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GitRepositoryPullsScreen(
|
||||
address: Address,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
LoadAddressableNote(address, accountViewModel) { note ->
|
||||
note?.let { GitRepositoryPulls(it, accountViewModel, nav) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the [GitRepositoryBrowserViewModel]. The factory lives app-side because the KMP
|
||||
* lifecycle artifact used in commons doesn't expose the `create(Class<T>)` override.
|
||||
*/
|
||||
internal class GitRepositoryBrowserViewModelFactory(
|
||||
private val okHttpClient: (String) -> OkHttpClient,
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = GitRepositoryBrowserViewModel(okHttpClient) as T
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberRepoBrowser(
|
||||
note: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
): GitRepositoryBrowserViewModel =
|
||||
viewModel(
|
||||
key = note.idHex + "GitRepoBrowser",
|
||||
factory = GitRepositoryBrowserViewModelFactory(accountViewModel.httpClientBuilder::okHttpClientForPreview),
|
||||
)
|
||||
|
||||
/**
|
||||
* Subscribes to the repository's issues/patches/status events while [event] is loaded.
|
||||
*
|
||||
* RepositoryContentSubAssembler.updateFilter reads note.event and bails out if it isn't a
|
||||
* GitRepositoryEvent yet. The compose subscription manager doesn't re-run updateFilter when
|
||||
* note.event later mutates, so subscribing before the event arrives (cold-start / deep-link)
|
||||
* leaves an empty filter forever. Gating on event presence makes the subscription composable
|
||||
* enter composition only once the repo event is loaded.
|
||||
*/
|
||||
@Composable
|
||||
private fun RepoContentSubscription(
|
||||
note: AddressableNote,
|
||||
event: GitRepositoryEvent?,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
if (event != null) {
|
||||
RepositoryFilterAssemblerSubscription(note, accountViewModel.dataSources().gitRepository)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun GitRepositoryHome(
|
||||
note: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val openIssuesViewModel: RepositoryIssuesFeedViewModel =
|
||||
val browserViewModel = rememberRepoBrowser(note, accountViewModel)
|
||||
val event by observeNoteEvent<GitRepositoryEvent>(note, accountViewModel)
|
||||
val cacheKey = remember(note) { note.address.toValue() }
|
||||
|
||||
// Start the smart-HTTP browser as soon as the announcement arrives, so the README renders.
|
||||
LaunchedEffect(event) {
|
||||
event?.let { browserViewModel.loadOnce(it.clones(), cacheKey) }
|
||||
}
|
||||
val browserState by browserViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
RepoContentSubscription(note, event, accountViewModel)
|
||||
|
||||
// Feed view models power the issue/PR counts on the nav cards and the recent-activity pulse.
|
||||
val openIssues: RepositoryIssuesFeedViewModel =
|
||||
viewModel(
|
||||
key = note.idHex + "GitRepoIssuesOpen",
|
||||
factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false),
|
||||
)
|
||||
|
||||
val closedIssuesViewModel: RepositoryIssuesFeedViewModel =
|
||||
val closedIssues: RepositoryIssuesFeedViewModel =
|
||||
viewModel(
|
||||
key = note.idHex + "GitRepoIssuesClosed",
|
||||
factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true),
|
||||
)
|
||||
|
||||
val openPatchesViewModel: RepositoryPatchesFeedViewModel =
|
||||
val openPatches: RepositoryPatchesFeedViewModel =
|
||||
viewModel(
|
||||
key = note.idHex + "GitRepoPatchesOpen",
|
||||
factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false),
|
||||
)
|
||||
|
||||
val closedPatchesViewModel: RepositoryPatchesFeedViewModel =
|
||||
val closedPatches: RepositoryPatchesFeedViewModel =
|
||||
viewModel(
|
||||
key = note.idHex + "GitRepoPatchesClosed",
|
||||
factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true),
|
||||
)
|
||||
WatchLifecycleAndUpdateModel(openIssues)
|
||||
WatchLifecycleAndUpdateModel(closedIssues)
|
||||
WatchLifecycleAndUpdateModel(openPatches)
|
||||
WatchLifecycleAndUpdateModel(closedPatches)
|
||||
|
||||
GitRepositoryScreen(
|
||||
note = note,
|
||||
openIssuesViewModel = openIssuesViewModel,
|
||||
closedIssuesViewModel = closedIssuesViewModel,
|
||||
openPatchesViewModel = openPatchesViewModel,
|
||||
closedPatchesViewModel = closedPatchesViewModel,
|
||||
val openIssueItems = rememberGitFeedItems(openIssues)
|
||||
val closedIssueItems = rememberGitFeedItems(closedIssues)
|
||||
val openPatchItems = rememberGitFeedItems(openPatches)
|
||||
val closedPatchItems = rememberGitFeedItems(closedPatches)
|
||||
|
||||
val snapshot = (browserState as? GitBrowseState.Loaded)?.snapshot ?: remember(cacheKey) { GitRepoSnapshotCache.get(cacheKey) }
|
||||
val fileNames = remember(snapshot) { snapshot?.walkFileNames() ?: emptyList() }
|
||||
val languageSlices = remember(fileNames) { computeLanguageBreakdown(fileNames) }
|
||||
val activity =
|
||||
remember(openIssueItems, closedIssueItems, openPatchItems, closedPatchItems) {
|
||||
(openIssueItems + closedIssueItems + openPatchItems + closedPatchItems)
|
||||
.sortedByDescending { it.createdAt() ?: 0L }
|
||||
.take(6)
|
||||
}
|
||||
|
||||
// Nav-card badges count only the OPEN issues/PRs. The open/closed split needs the status
|
||||
// index (kinds 1630-1633); it's an eagerly-shared StateFlow, so the home reflects it without
|
||||
// visiting the Issues screen first. The count is derived directly from the live index.
|
||||
val statusMap by GitStatusIndex.latestByTarget.collectAsStateWithLifecycle()
|
||||
val openIssueCount =
|
||||
remember(openIssueItems, closedIssueItems, statusMap) {
|
||||
(openIssueItems + closedIssueItems).distinctBy { it.idHex }.count { !GitStatusIndex.isClosedOrResolved(it.idHex, statusMap) }
|
||||
}
|
||||
val openPullCount =
|
||||
remember(openPatchItems, closedPatchItems, statusMap) {
|
||||
(openPatchItems + closedPatchItems).distinctBy { it.idHex }.count { !GitStatusIndex.isClosedOrResolved(it.idHex, statusMap) }
|
||||
}
|
||||
|
||||
var showSettings by rememberSaveable(note.idHex) { mutableStateOf(false) }
|
||||
val currentEventForSettings = event
|
||||
if (showSettings && currentEventForSettings != null) {
|
||||
GitRepoSettingsDialog(currentEventForSettings, accountViewModel) { showSettings = false }
|
||||
}
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
ShorterTopAppBar(
|
||||
title = { RepoTitleBar(event = event, fallback = note.dTag(), accountViewModel = accountViewModel, nav = nav) },
|
||||
navigationIcon = {
|
||||
Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = nav::popBack) { ArrowBackIcon() }
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
val bookmarkedSet by accountViewModel.account.gitRepositoryListState.publicRepositoryAddressSet
|
||||
.collectAsStateWithLifecycle()
|
||||
val isBookmarked = remember(bookmarkedSet, note) { bookmarkedSet.contains(note.address) }
|
||||
IconButton(onClick = { accountViewModel.toggleRepositoryBookmark(note, isBookmarked) }) {
|
||||
Icon(
|
||||
symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd,
|
||||
contentDescription = stringRes(R.string.git_repo_bookmark),
|
||||
tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (event != null && accountViewModel.isLoggedUser(event?.pubKey)) {
|
||||
IconButton(onClick = { showSettings = true }) {
|
||||
Icon(MaterialSymbols.Edit, contentDescription = stringRes(R.string.git_repo_settings_title))
|
||||
}
|
||||
}
|
||||
Row(Modifier.padding(end = 6.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
MoreOptionsButton(note, accountViewModel = accountViewModel, nav = nav)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
val scaffoldPadding = LocalDisappearingScaffoldPadding.current
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(scaffoldPadding)
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val currentEvent = event
|
||||
if (currentEvent != null) {
|
||||
RepoHero(currentEvent)
|
||||
}
|
||||
|
||||
if (snapshot != null) {
|
||||
RepoStatTiles(
|
||||
branches = snapshot.branches.size,
|
||||
tags = snapshot.tags.size,
|
||||
files = fileNames.size,
|
||||
updatedEpochSec = snapshot.tipCommit?.authorTimeSec,
|
||||
)
|
||||
if (languageSlices.isNotEmpty()) {
|
||||
RepoLanguageBar(languageSlices)
|
||||
}
|
||||
snapshot.tipCommit?.let { commit ->
|
||||
RepoLastCommit(commit) { nav.nav(Route.GitRepositoryCode(note.address)) }
|
||||
}
|
||||
} else if (currentEvent != null && !repoHasFetchableClone(currentEvent)) {
|
||||
RepoExternalNotice(currentEvent)
|
||||
}
|
||||
|
||||
RepoNavCards(note, openIssueCount, openPullCount, nav)
|
||||
|
||||
RepoActivityPulse(activity, accountViewModel, nav)
|
||||
|
||||
if (currentEvent != null) {
|
||||
RepoSocialRow(note, accountViewModel, nav)
|
||||
GitReadmeSection(browserState, browserViewModel, currentEvent, accountViewModel, nav)
|
||||
} else {
|
||||
EmptyMessage(stringRes(R.string.loading_feed))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GitRepositoryCode(
|
||||
note: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val browserViewModel = rememberRepoBrowser(note, accountViewModel)
|
||||
val event by observeNoteEvent<GitRepositoryEvent>(note, accountViewModel)
|
||||
val cacheKey = remember(note) { note.address.toValue() }
|
||||
LaunchedEffect(event) {
|
||||
event?.let { browserViewModel.loadOnce(it.clones(), cacheKey) }
|
||||
}
|
||||
val browserState by browserViewModel.state.collectAsStateWithLifecycle()
|
||||
RepoContentSubscription(note, event, accountViewModel)
|
||||
|
||||
GitRepoSubScreenScaffold(event, note.dTag(), accountViewModel, nav) {
|
||||
val ev = event
|
||||
if (ev != null && !repoHasFetchableClone(ev)) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(LocalDisappearingScaffoldPadding.current)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
RepoExternalNotice(ev)
|
||||
}
|
||||
} else {
|
||||
GitCodeTab(browserState, browserViewModel, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GitRepositoryIssues(
|
||||
note: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val openViewModel: RepositoryIssuesFeedViewModel =
|
||||
viewModel(
|
||||
key = note.idHex + "GitRepoIssuesOpen",
|
||||
factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false),
|
||||
)
|
||||
val closedViewModel: RepositoryIssuesFeedViewModel =
|
||||
viewModel(
|
||||
key = note.idHex + "GitRepoIssuesClosed",
|
||||
factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true),
|
||||
)
|
||||
WatchLifecycleAndUpdateModel(openViewModel)
|
||||
WatchLifecycleAndUpdateModel(closedViewModel)
|
||||
|
||||
val event by observeNoteEvent<GitRepositoryEvent>(note, accountViewModel)
|
||||
RepoContentSubscription(note, event, accountViewModel)
|
||||
|
||||
StatusFeedScreen(
|
||||
persistKey = note.idHex + "GitRepoIssuesStatus",
|
||||
event = event,
|
||||
fallbackTitle = note.dTag(),
|
||||
openViewModel = openViewModel,
|
||||
closedViewModel = closedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
floatingButton = if (event != null) ({ NewIssueFab { nav.nav(Route.GitRepositoryNewIssue(note.address)) } }) else null,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GitRepositoryPulls(
|
||||
note: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val openViewModel: RepositoryPatchesFeedViewModel =
|
||||
viewModel(
|
||||
key = note.idHex + "GitRepoPatchesOpen",
|
||||
factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false),
|
||||
)
|
||||
val closedViewModel: RepositoryPatchesFeedViewModel =
|
||||
viewModel(
|
||||
key = note.idHex + "GitRepoPatchesClosed",
|
||||
factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true),
|
||||
)
|
||||
WatchLifecycleAndUpdateModel(openViewModel)
|
||||
WatchLifecycleAndUpdateModel(closedViewModel)
|
||||
|
||||
val event by observeNoteEvent<GitRepositoryEvent>(note, accountViewModel)
|
||||
RepoContentSubscription(note, event, accountViewModel)
|
||||
|
||||
StatusFeedScreen(
|
||||
persistKey = note.idHex + "GitRepoPatchesStatus",
|
||||
event = event,
|
||||
fallbackTitle = note.dTag(),
|
||||
openViewModel = openViewModel,
|
||||
closedViewModel = closedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared scaffold for the Code / Issues / Pull Requests drill-in screens: a back arrow and the
|
||||
* repo title. [belowBar] (e.g. status-filter chips) is rendered inside the disappearing top bar
|
||||
* so it hides with it instead of leaving a static band, and [floatingButton] feeds the scaffold's
|
||||
* FAB slot.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun GitRepositoryScreen(
|
||||
note: AddressableNote,
|
||||
openIssuesViewModel: RepositoryIssuesFeedViewModel,
|
||||
closedIssuesViewModel: RepositoryIssuesFeedViewModel,
|
||||
openPatchesViewModel: RepositoryPatchesFeedViewModel,
|
||||
closedPatchesViewModel: RepositoryPatchesFeedViewModel,
|
||||
private fun GitRepoSubScreenScaffold(
|
||||
event: GitRepositoryEvent?,
|
||||
fallbackTitle: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
belowBar: (@Composable () -> Unit)? = null,
|
||||
floatingButton: (@Composable () -> Unit)? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
WatchLifecycleAndUpdateModel(openIssuesViewModel)
|
||||
WatchLifecycleAndUpdateModel(closedIssuesViewModel)
|
||||
WatchLifecycleAndUpdateModel(openPatchesViewModel)
|
||||
WatchLifecycleAndUpdateModel(closedPatchesViewModel)
|
||||
|
||||
val event by observeNoteEvent<GitRepositoryEvent>(note, accountViewModel)
|
||||
|
||||
// RepositoryContentSubAssembler.updateFilter reads note.event and bails out if it isn't
|
||||
// a GitRepositoryEvent yet. The compose subscription manager doesn't re-run updateFilter
|
||||
// when note.event later mutates, so subscribing before the event has arrived (cold-start
|
||||
// / deep-link case) leaves an empty filter forever. Gate the subscription composable on
|
||||
// event presence so it enters composition (and fires DisposableEffect → subscribe → fresh
|
||||
// updateFilter) only once the repo event is loaded.
|
||||
if (event != null) {
|
||||
RepositoryFilterAssemblerSubscription(note, accountViewModel.dataSources().gitRepository)
|
||||
}
|
||||
|
||||
val pagerState = rememberForeverPagerState(note.idHex + "GitRepoScreenPagerState") { 3 }
|
||||
|
||||
DisappearingScaffold(
|
||||
isInvertedLayout = false,
|
||||
topBar = {
|
||||
Column {
|
||||
ShorterTopAppBar(
|
||||
title = {
|
||||
TopBarTitle(event = event, fallback = note.dTag())
|
||||
},
|
||||
title = { TopBarTitle(event = event, fallback = fallbackTitle) },
|
||||
navigationIcon = {
|
||||
Row(TitleIconModifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = nav::popBack) { ArrowBackIcon() }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
SecondaryTabRow(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = TabRowHeight,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 0,
|
||||
text = { Text(stringRes(R.string.git_repo_tab_overview)) },
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } },
|
||||
)
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 1,
|
||||
text = { Text(stringRes(R.string.git_repo_tab_issues)) },
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } },
|
||||
)
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 2,
|
||||
text = { Text(stringRes(R.string.git_repo_tab_patches)) },
|
||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } },
|
||||
)
|
||||
}
|
||||
belowBar?.invoke()
|
||||
}
|
||||
},
|
||||
floatingButton = floatingButton,
|
||||
accountViewModel = accountViewModel,
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> {
|
||||
val currentEvent = event
|
||||
if (currentEvent != null) {
|
||||
GitRepositoryOverview(currentEvent, accountViewModel, nav)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** The Code / Issues / Pull Requests entry points on the project home. */
|
||||
@Composable
|
||||
private fun RepoNavCards(
|
||||
note: AddressableNote,
|
||||
openIssues: Int,
|
||||
openPulls: Int,
|
||||
nav: INav,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
RepoNavCard(MaterialSymbols.Code, stringRes(R.string.git_repo_tab_code), null) {
|
||||
nav.nav(Route.GitRepositoryCode(note.address))
|
||||
}
|
||||
RepoNavCard(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_tab_issues), openIssues) {
|
||||
nav.nav(Route.GitRepositoryIssues(note.address))
|
||||
}
|
||||
RepoNavCard(MaterialSymbols.CallMerge, stringRes(R.string.git_repo_tab_patches), openPulls) {
|
||||
nav.nav(Route.GitRepositoryPulls(note.address))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RepoNavCard(
|
||||
symbol: MaterialSymbol,
|
||||
title: String,
|
||||
count: Int?,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(15.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (count != null && count > 0) {
|
||||
Text(
|
||||
text =
|
||||
if (count > 999) {
|
||||
"999+"
|
||||
} else {
|
||||
EmptyMessage(stringRes(R.string.loading_feed))
|
||||
}
|
||||
}
|
||||
count.toString()
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f))
|
||||
.padding(horizontal = 9.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ChevronRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.3f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
1 -> {
|
||||
StatusSplitFeed(
|
||||
persistKey = note.idHex + "GitRepoIssuesStatus",
|
||||
openViewModel = openIssuesViewModel,
|
||||
closedViewModel = closedIssuesViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
/**
|
||||
* A drill-in screen showing an Open / Closed & Resolved status feed. The status + label
|
||||
* filter chips live inside the disappearing top bar (so they hide with it and the feed scrolls
|
||||
* cleanly under them), while the feed body uses the scaffold's normal content padding.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun StatusFeedScreen(
|
||||
persistKey: String,
|
||||
event: GitRepositoryEvent?,
|
||||
fallbackTitle: String,
|
||||
openViewModel: FeedViewModel,
|
||||
closedViewModel: FeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
floatingButton: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
var showClosed by rememberSaveable(persistKey) { mutableStateOf(false) }
|
||||
var selectedLabel by rememberSaveable(persistKey) { mutableStateOf<String?>(null) }
|
||||
|
||||
2 -> {
|
||||
StatusSplitFeed(
|
||||
persistKey = note.idHex + "GitRepoPatchesStatus",
|
||||
openViewModel = openPatchesViewModel,
|
||||
closedViewModel = closedPatchesViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
val openItems = rememberGitFeedItems(openViewModel)
|
||||
val closedItems = rememberGitFeedItems(closedViewModel)
|
||||
|
||||
val activeItems = if (showClosed) closedItems else openItems
|
||||
val labels =
|
||||
remember(activeItems) {
|
||||
activeItems.flatMap { gitLabelsOf(it.event) }.distinct().sorted()
|
||||
}
|
||||
|
||||
// A label selected under one status may not exist under the other; drop it when it's gone.
|
||||
LaunchedEffect(labels) {
|
||||
if (selectedLabel != null && selectedLabel !in labels) selectedLabel = null
|
||||
}
|
||||
|
||||
GitRepoSubScreenScaffold(
|
||||
event = event,
|
||||
fallbackTitle = fallbackTitle,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
belowBar = {
|
||||
StatusFilterChips(
|
||||
showClosed = showClosed,
|
||||
onShowClosed = { showClosed = it },
|
||||
openCount = openItems.size,
|
||||
closedCount = closedItems.size,
|
||||
selectedLabel = selectedLabel,
|
||||
onSelectLabel = { selectedLabel = it },
|
||||
labels = labels,
|
||||
)
|
||||
},
|
||||
floatingButton = floatingButton,
|
||||
) {
|
||||
RefresheableFeedView(
|
||||
viewModel = if (showClosed) closedViewModel else openViewModel,
|
||||
routeForLastRead = null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onLoaded = { loaded, listState ->
|
||||
GitItemFeedLoaded(loaded, listState, accountViewModel, nav, labelFilter = selectedLabel)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The Open/Closed status chips plus the optional label-filter row, shown inside the top bar. */
|
||||
@Composable
|
||||
private fun StatusFilterChips(
|
||||
showClosed: Boolean,
|
||||
onShowClosed: (Boolean) -> Unit,
|
||||
openCount: Int,
|
||||
closedCount: Int,
|
||||
selectedLabel: String?,
|
||||
onSelectLabel: (String?) -> Unit,
|
||||
labels: List<String>,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.background)) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = !showClosed,
|
||||
onClick = { onShowClosed(false) },
|
||||
label = { Text(countedLabel(stringRes(R.string.git_repo_filter_open), openCount)) },
|
||||
leadingIcon =
|
||||
if (!showClosed) {
|
||||
{ Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
FilterChip(
|
||||
selected = showClosed,
|
||||
onClick = { onShowClosed(true) },
|
||||
label = { Text(countedLabel(stringRes(R.string.git_repo_filter_closed), closedCount)) },
|
||||
leadingIcon =
|
||||
if (showClosed) {
|
||||
{ Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (labels.isNotEmpty()) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 10.dp, vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selectedLabel == null,
|
||||
onClick = { onSelectLabel(null) },
|
||||
label = { Text(stringRes(R.string.git_repo_label_all)) },
|
||||
)
|
||||
labels.forEach { label ->
|
||||
FilterChip(
|
||||
selected = selectedLabel == label,
|
||||
onClick = { onSelectLabel(if (selectedLabel == label) null else label) },
|
||||
label = { Text("#$label") },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -241,55 +711,39 @@ private fun GitRepositoryScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a feed in an Open / Closed & Resolved segmented selector, swapping between two
|
||||
* status-scoped feed view models. Each view model already filters by NIP-34 status, so the
|
||||
* selector only chooses which one is rendered. The selection survives configuration changes
|
||||
* and tab swipes via [persistKey].
|
||||
*/
|
||||
@Composable
|
||||
private fun StatusSplitFeed(
|
||||
persistKey: String,
|
||||
openViewModel: FeedViewModel,
|
||||
closedViewModel: FeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
var showClosed by rememberSaveable(persistKey) { mutableStateOf(false) }
|
||||
private fun NewIssueFab(onClick: () -> Unit) {
|
||||
// The app theme overrides shapes.large (the extended-FAB default) to 0.dp, which makes the
|
||||
// FAB square — so pin an explicit rounded shape here.
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
icon = { Icon(MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(20.dp)) },
|
||||
text = { Text(stringRes(R.string.git_new_issue_button)) },
|
||||
)
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
SingleChoiceSegmentedButtonRow(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
) {
|
||||
SegmentedButton(
|
||||
selected = !showClosed,
|
||||
onClick = { showClosed = false },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
|
||||
) {
|
||||
Text(stringRes(R.string.git_repo_filter_open))
|
||||
}
|
||||
SegmentedButton(
|
||||
selected = showClosed,
|
||||
onClick = { showClosed = true },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
|
||||
) {
|
||||
Text(stringRes(R.string.git_repo_filter_closed))
|
||||
/** Appends a count to a chip label, e.g. "Open · 3". Hidden while the feed is still empty/loading. */
|
||||
private fun countedLabel(
|
||||
base: String,
|
||||
count: Int,
|
||||
): String = if (count > 0) "$base · $count" else base
|
||||
|
||||
/**
|
||||
* Mirrors the active feed list out of a [FeedViewModel] so the status row can show item counts
|
||||
* and derive the available label set. Emits an empty list while the feed is loading or empty.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Composable
|
||||
private fun rememberGitFeedItems(viewModel: FeedViewModel): List<Note> {
|
||||
val flow =
|
||||
remember(viewModel) {
|
||||
viewModel.feedState.feedContent.flatMapLatest { state ->
|
||||
if (state is FeedState.Loaded) state.feed.map { it.list } else flowOf(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
RefresheableFeedView(
|
||||
viewModel = if (showClosed) closedViewModel else openViewModel,
|
||||
routeForLastRead = null,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onLoaded = { loaded, listState ->
|
||||
GitItemFeedLoaded(loaded, listState, accountViewModel, nav)
|
||||
},
|
||||
)
|
||||
}
|
||||
val items by flow.collectAsStateWithLifecycle(emptyList())
|
||||
return items
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -311,7 +765,7 @@ private fun EmptyMessage(text: String) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
/** Centered spinner + caption while the repository or a file is loading. */
|
||||
@Composable
|
||||
fun GitLoadingBox(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.padding(24.dp),
|
||||
) {
|
||||
CircularProgressIndicator(strokeWidth = 2.5.dp, modifier = Modifier.size(34.dp))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Centered icon + message, with an optional primary retry action. */
|
||||
@Composable
|
||||
fun GitMessageBox(
|
||||
symbol: MaterialSymbol,
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
modifier = Modifier.padding(32.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(64.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.08f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
if (onRetry != null) {
|
||||
FilledTonalButton(onClick = onRetry) {
|
||||
Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.git_repo_retry),
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact bar identifying the snapshot: a branch/tag picker, the short commit the
|
||||
* file tree was loaded from, and the entry count of the current directory.
|
||||
*/
|
||||
@Composable
|
||||
fun RepoInfoBar(
|
||||
branch: String?,
|
||||
headCommit: String,
|
||||
entryCount: Int,
|
||||
branches: List<String> = emptyList(),
|
||||
tags: List<String> = emptyList(),
|
||||
onSelectRef: ((String?) -> Unit)? = null,
|
||||
onHistory: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 2.dp, bottom = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (onSelectRef != null && (branches.size + tags.size) > 1) {
|
||||
BranchSelector(branch, branches, tags, onSelectRef)
|
||||
} else if (branch != null) {
|
||||
InfoChip(symbol = MaterialSymbols.AltRoute, label = branch)
|
||||
}
|
||||
InfoChip(symbol = MaterialSymbols.Commit, label = headCommit.take(7), monospace = true)
|
||||
Text(
|
||||
text = pluralStringResource(R.plurals.git_repo_item_count, entryCount, entryCount),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
if (onHistory != null) {
|
||||
IconButton(onClick = onHistory) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.History,
|
||||
contentDescription = stringRes(R.string.git_repo_commits),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BranchSelector(
|
||||
current: String?,
|
||||
branches: List<String>,
|
||||
tags: List<String>,
|
||||
onSelectRef: (String?) -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
.clickable { expanded = true }
|
||||
.padding(start = 8.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Icon(MaterialSymbols.AltRoute, contentDescription = null, modifier = Modifier.size(15.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
Text(
|
||||
text = current ?: stringRes(R.string.git_repo_default_branch),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
)
|
||||
Icon(MaterialSymbols.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
if (branches.isNotEmpty()) {
|
||||
SectionHeader(stringRes(R.string.git_repo_branches))
|
||||
branches.forEach { name ->
|
||||
RefMenuItem(name, MaterialSymbols.AltRoute, selected = name == current) {
|
||||
expanded = false
|
||||
if (name != current) onSelectRef(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tags.isNotEmpty()) {
|
||||
SectionHeader(stringRes(R.string.git_repo_tags))
|
||||
tags.forEach { name ->
|
||||
RefMenuItem(name, MaterialSymbols.Tag, selected = name == current) {
|
||||
expanded = false
|
||||
if (name != current) onSelectRef(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RefMenuItem(
|
||||
name: String,
|
||||
symbol: MaterialSymbol,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
name,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
)
|
||||
},
|
||||
leadingIcon = { Icon(symbol, contentDescription = null, modifier = Modifier.size(16.dp)) },
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoChip(
|
||||
symbol: MaterialSymbol,
|
||||
label: String,
|
||||
monospace: Boolean = false,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = if (monospace) FontFamily.Monospace else null,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.nip34Git.GitBrowseState
|
||||
import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingBarState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.LocalDisappearingScaffoldPadding
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitTreeEntry
|
||||
|
||||
@Composable
|
||||
fun GitCodeTab(
|
||||
state: GitBrowseState,
|
||||
viewModel: GitRepositoryBrowserViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val scaffoldPadding = LocalDisappearingScaffoldPadding.current
|
||||
when (state) {
|
||||
is GitBrowseState.Loading ->
|
||||
GitLoadingBox(stringRes(R.string.git_repo_code_loading), Modifier.padding(scaffoldPadding))
|
||||
|
||||
is GitBrowseState.Error -> {
|
||||
val noClone = state.message == GitRepositoryBrowserViewModel.NO_CLONE_URL
|
||||
GitMessageBox(
|
||||
symbol = if (noClone) MaterialSymbols.Code else MaterialSymbols.ErrorOutline,
|
||||
text = if (noClone) stringRes(R.string.git_repo_no_clone_url) else stringRes(R.string.git_repo_code_error),
|
||||
modifier = Modifier.padding(scaffoldPadding),
|
||||
onRetry = if (noClone) null else viewModel::reload,
|
||||
)
|
||||
}
|
||||
|
||||
is GitBrowseState.Loaded ->
|
||||
CodeBrowser(state.snapshot, viewModel, accountViewModel, nav, scaffoldPaddingTop = scaffoldPadding)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodeBrowser(
|
||||
snapshot: GitRepoSnapshot,
|
||||
viewModel: GitRepositoryBrowserViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
scaffoldPaddingTop: PaddingValues,
|
||||
) {
|
||||
var pathString by rememberSaveable(snapshot.headCommit) { mutableStateOf("") }
|
||||
var openFilePath by rememberSaveable(snapshot.headCommit) { mutableStateOf<String?>(null) }
|
||||
var showHistory by rememberSaveable(snapshot.headCommit) { mutableStateOf(false) }
|
||||
|
||||
// Each of these toggles swaps the scrollable content in place, which lands the new view at
|
||||
// the top with no scroll delta. Pull the disappearing top bar back into view so it doesn't
|
||||
// stay stranded at its hidden offset, leaving a blank band over the fresh content.
|
||||
val barState = LocalDisappearingBarState.current
|
||||
LaunchedEffect(pathString, openFilePath, showHistory) {
|
||||
barState?.resetToVisible()
|
||||
}
|
||||
|
||||
if (showHistory) {
|
||||
Column(Modifier.fillMaxSize().padding(scaffoldPaddingTop)) {
|
||||
GitCommitLog(snapshot, viewModel, onBack = { showHistory = false })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val path = remember(pathString) { if (pathString.isEmpty()) emptyList() else pathString.split("/") }
|
||||
val openPath = openFilePath?.let { if (it.isEmpty()) emptyList() else it.split("/") }
|
||||
|
||||
if (openPath != null) {
|
||||
val entry = remember(openFilePath) { snapshot.entryAt(openPath) }
|
||||
BackHandler { openFilePath = null }
|
||||
Column(Modifier.fillMaxSize().padding(scaffoldPaddingTop)) {
|
||||
FileHeader(name = openPath.lastOrNull() ?: "", onBack = { openFilePath = null })
|
||||
HorizontalDivider(thickness = 0.5.dp)
|
||||
if (entry == null) {
|
||||
GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_file_load_error))
|
||||
} else {
|
||||
GitFileViewer(
|
||||
snapshot = snapshot,
|
||||
viewModel = viewModel,
|
||||
entry = entry,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var query by rememberSaveable(snapshot.headCommit) { mutableStateOf("") }
|
||||
val searching = query.isNotBlank()
|
||||
|
||||
BackHandler(enabled = searching || path.isNotEmpty()) {
|
||||
if (searching) query = "" else pathString = path.dropLast(1).joinToString("/")
|
||||
}
|
||||
|
||||
val entries = remember(snapshot, pathString) { snapshot.entriesAt(path).orEmpty() }
|
||||
|
||||
// A single scrolling list so the branch/search header rides along with the disappearing
|
||||
// top bar instead of staying pinned below it. The header rows are the first list items;
|
||||
// the file/search rows follow, all under one contentPadding for the scaffold inset.
|
||||
val results = remember(snapshot, query) { if (searching) snapshot.searchFiles(query.trim()) else emptyList() }
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = scaffoldPaddingTop) {
|
||||
item(key = "repo-info-bar") {
|
||||
RepoInfoBar(
|
||||
branch = snapshot.branch,
|
||||
headCommit = snapshot.headCommit,
|
||||
entryCount = entries.size,
|
||||
branches = snapshot.branches,
|
||||
tags = snapshot.tags,
|
||||
onSelectRef = { viewModel.switchRef(it) },
|
||||
onHistory = { showHistory = true },
|
||||
)
|
||||
}
|
||||
item(key = "file-search") {
|
||||
FileSearchField(query = query, onQueryChange = { query = it })
|
||||
HorizontalDivider(thickness = 0.5.dp)
|
||||
}
|
||||
|
||||
if (searching) {
|
||||
if (results.isEmpty()) {
|
||||
item(key = "no-results") {
|
||||
GitMessageBox(MaterialSymbols.Search, stringRes(R.string.git_repo_no_search_results), Modifier.fillParentMaxWidth())
|
||||
}
|
||||
} else {
|
||||
items(results, key = { "result/" + it.joinToString("/") }) { result ->
|
||||
SearchResultRow(result) { openFilePath = result.joinToString("/") }
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 40.dp),
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
item(key = "breadcrumb") {
|
||||
Breadcrumb(
|
||||
path = path,
|
||||
onNavigate = { depth -> pathString = path.take(depth).joinToString("/") },
|
||||
)
|
||||
HorizontalDivider(thickness = 0.5.dp)
|
||||
}
|
||||
if (entries.isEmpty()) {
|
||||
item(key = "empty-folder") {
|
||||
GitMessageBox(MaterialSymbols.Folder, stringRes(R.string.git_repo_empty_folder), Modifier.fillParentMaxWidth())
|
||||
}
|
||||
} else {
|
||||
items(entries, key = { "entry/" + it.name }) { entry ->
|
||||
EntryRow(
|
||||
entry = entry,
|
||||
onClick = {
|
||||
val child = (path + entry.name).joinToString("/")
|
||||
if (entry.isFolder) pathString = child else openFilePath = child
|
||||
},
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 44.dp),
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileSearchField(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
) {
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
singleLine = true,
|
||||
placeholder = { Text(stringRes(R.string.git_repo_search_files)) },
|
||||
leadingIcon = { Icon(MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(18.dp)) },
|
||||
trailingIcon = {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(MaterialSymbols.Close, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 10.dp, vertical = 2.dp)
|
||||
.clip(RoundedCornerShape(10.dp)),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchResultRow(
|
||||
path: List<String>,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Description,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = path.last(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (path.size > 1) {
|
||||
Text(
|
||||
text = path.dropLast(1).joinToString("/"),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Breadcrumb(
|
||||
path: List<String>,
|
||||
onNavigate: (depth: Int) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 10.dp, vertical = 3.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Crumb(label = stringRes(R.string.git_repo_root), current = path.isEmpty()) { onNavigate(0) }
|
||||
path.forEachIndexed { index, segment ->
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ChevronRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.35f),
|
||||
)
|
||||
Crumb(label = segment, current = index == path.lastIndex) { onNavigate(index + 1) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Crumb(
|
||||
label: String,
|
||||
current: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val background =
|
||||
if (current) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
|
||||
}
|
||||
val textColor = if (current) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = if (current) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.let { if (!current) it.clickable(onClick = onClick) else it }
|
||||
.background(background)
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntryRow(
|
||||
entry: GitTreeEntry,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
val symbol =
|
||||
when {
|
||||
entry.isFolder -> MaterialSymbols.Folder
|
||||
entry.isSubmodule -> MaterialSymbols.Code
|
||||
else -> MaterialSymbols.Description
|
||||
}
|
||||
val tint = if (entry.isFolder) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = tint,
|
||||
)
|
||||
Text(
|
||||
text = entry.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = if (entry.isFolder) null else FontFamily.Monospace,
|
||||
fontWeight = if (entry.isFolder) FontWeight.Medium else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (entry.isFolder) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ChevronRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.3f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileHeader(
|
||||
name: String,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(end = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) { ArrowBackIcon() }
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.types.GitDiffView
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitCommit
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Commit history for the current branch: a `git log`-style list. Tapping a commit
|
||||
* loads the diff it introduced (vs its first parent) and renders it with the
|
||||
* shared [GitDiffView].
|
||||
*/
|
||||
@Composable
|
||||
fun GitCommitLog(
|
||||
snapshot: GitRepoSnapshot,
|
||||
viewModel: GitRepositoryBrowserViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var openCommit by rememberSaveable(snapshot.headCommit) { mutableStateOf<String?>(null) }
|
||||
|
||||
val history by
|
||||
produceState<Result<List<GitCommit>>?>(null, snapshot.headCommit) {
|
||||
value =
|
||||
try {
|
||||
Result.success(viewModel.loadHistory(snapshot))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
val commits = history?.getOrNull()
|
||||
|
||||
if (openCommit != null) {
|
||||
val commit = remember(openCommit, commits) { commits?.firstOrNull { it.oid == openCommit } }
|
||||
BackHandler { openCommit = null }
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
LogHeader(title = commit?.shortOid ?: "", onBack = { openCommit = null })
|
||||
HorizontalDivider(thickness = 0.5.dp)
|
||||
if (commit == null) {
|
||||
GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_file_load_error))
|
||||
} else {
|
||||
CommitDiff(snapshot, viewModel, commit)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
BackHandler { onBack() }
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
LogHeader(title = stringRes(R.string.git_repo_commits), onBack = onBack)
|
||||
HorizontalDivider(thickness = 0.5.dp)
|
||||
when {
|
||||
history == null -> GitLoadingBox(stringRes(R.string.git_repo_code_loading))
|
||||
history!!.isFailure -> GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_code_error))
|
||||
commits.isNullOrEmpty() -> GitMessageBox(MaterialSymbols.History, stringRes(R.string.git_repo_no_commits))
|
||||
else ->
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(commits, key = { it.oid }) { commit ->
|
||||
CommitRow(commit) { openCommit = commit.oid }
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 14.dp),
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommitDiff(
|
||||
snapshot: GitRepoSnapshot,
|
||||
viewModel: GitRepositoryBrowserViewModel,
|
||||
commit: GitCommit,
|
||||
) {
|
||||
val result by
|
||||
produceState<Result<ParsedPatch>?>(null, commit.oid) {
|
||||
value =
|
||||
try {
|
||||
Result.success(viewModel.commitDiff(snapshot, commit))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
when (val r = result) {
|
||||
null -> GitLoadingBox(stringRes(R.string.git_repo_code_loading))
|
||||
else ->
|
||||
if (r.isFailure || r.getOrThrow().files.isEmpty()) {
|
||||
GitMessageBox(MaterialSymbols.Code, stringRes(R.string.git_pr_no_changes))
|
||||
} else {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
) {
|
||||
if (commit.summary.isNotBlank()) {
|
||||
Text(commit.summary, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
text = "${commit.shortOid} · ${commit.authorName}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
GitDiffView(r.getOrThrow(), Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommitRow(
|
||||
commit: GitCommit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Commit,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp).padding(top = 2.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = commit.summary.ifBlank { commit.shortOid },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = commit.shortOid,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.55f),
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 5.dp, vertical = 1.dp),
|
||||
)
|
||||
Text(
|
||||
text = "${commit.authorName} · ${relativeTime(commit.authorTimeSec)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogHeader(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(end = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) { ArrowBackIcon() }
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun relativeTime(epochSec: Long): String =
|
||||
if (epochSec <= 0) {
|
||||
""
|
||||
} else {
|
||||
DateUtils.getRelativeTimeSpanString(epochSec * 1000L, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString()
|
||||
}
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel
|
||||
import com.vitorpamplona.amethyst.commons.nip34Git.ui.CodeHighlighter
|
||||
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitTreeEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
private val CodeFontSize = 13.sp
|
||||
private val CodeLineHeight = 20.sp
|
||||
|
||||
/**
|
||||
* Renders a single file from the repository. Markdown files render as rich text,
|
||||
* text files render with a line-number gutter and syntax highlighting, and binary
|
||||
* files show a notice.
|
||||
*/
|
||||
@Composable
|
||||
fun GitFileViewer(
|
||||
snapshot: GitRepoSnapshot,
|
||||
viewModel: GitRepositoryBrowserViewModel,
|
||||
entry: GitTreeEntry,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val result by
|
||||
produceState<Result<ByteArray>?>(null, entry.oid) {
|
||||
value =
|
||||
try {
|
||||
Result.success(viewModel.readBlob(snapshot, entry.oid))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
val bytes = result
|
||||
when {
|
||||
bytes == null -> GitLoadingBox(stringRes(R.string.git_repo_code_loading), modifier)
|
||||
bytes.isFailure -> GitMessageBox(MaterialSymbols.ErrorOutline, stringRes(R.string.git_repo_file_load_error), modifier)
|
||||
else -> {
|
||||
val data = bytes.getOrThrow()
|
||||
when {
|
||||
isMarkdownFile(entry.name) ->
|
||||
MarkdownFile(data.decodeToString(), accountViewModel, nav, modifier)
|
||||
isImageFile(entry.name) ->
|
||||
ImageFile(data, entry.name, modifier)
|
||||
isProbablyBinary(data) ->
|
||||
GitMessageBox(
|
||||
symbol = MaterialSymbols.Description,
|
||||
text = stringRes(R.string.git_repo_binary_file, humanSize(data.size)),
|
||||
modifier = modifier,
|
||||
)
|
||||
else ->
|
||||
HighlightedCode(data.decodeToString(), entry.name, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MarkdownFile(
|
||||
content: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
val backgroundColor = remember { mutableStateOf(background) }
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
RichTextViewer(
|
||||
content = content,
|
||||
canPreview = true,
|
||||
quotesLeft = 1,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HighlightedCode(
|
||||
code: String,
|
||||
fileName: String,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
val language = remember(fileName) { CodeHighlighter.languageForFile(fileName) }
|
||||
|
||||
val annotated by
|
||||
produceState(AnnotatedString(code), code, fileName, darkMode) {
|
||||
value =
|
||||
withContext(Dispatchers.Default) {
|
||||
if (language == null) AnnotatedString(code) else CodeHighlighter.highlight(code, language, darkMode)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier.fillMaxSize()) {
|
||||
CodeBar(languageLabel = language?.name?.let(::prettyLanguage) ?: stringRes(R.string.git_repo_plain_text), code = code)
|
||||
HorizontalDivider(thickness = 0.5.dp)
|
||||
CodeWithLineNumbers(annotated, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodeBar(
|
||||
languageLabel: String,
|
||||
code: String,
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
||||
.padding(start = 14.dp, end = 4.dp, top = 2.dp, bottom = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = languageLabel,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
clipboard.setText(code)
|
||||
Toast.makeText(context, stringRes(context, R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ContentCopy,
|
||||
contentDescription = stringRes(R.string.git_repo_copy_file),
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodeWithLineNumbers(
|
||||
annotated: AnnotatedString,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val lineRanges = remember(annotated) { computeLineRanges(annotated.text) }
|
||||
val gutterDigits = lineRanges.size.toString().length
|
||||
val gutterWidth = (gutterDigits * 9 + 20).dp
|
||||
|
||||
val verticalScroll = rememberScrollState()
|
||||
val horizontalScroll = rememberScrollState()
|
||||
val gutterColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
|
||||
|
||||
Row(modifier.verticalScroll(verticalScroll)) {
|
||||
// Sticky line-number gutter (only the code area scrolls horizontally).
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
|
||||
.width(gutterWidth)
|
||||
.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
for (i in lineRanges.indices) {
|
||||
Text(
|
||||
text = (i + 1).toString(),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = CodeFontSize,
|
||||
lineHeight = CodeLineHeight,
|
||||
color = gutterColor,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.horizontalScroll(horizontalScroll)
|
||||
.padding(start = 12.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
for ((start, end) in lineRanges) {
|
||||
Text(
|
||||
text = annotated.subSequence(start, end),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = CodeFontSize,
|
||||
lineHeight = CodeLineHeight,
|
||||
softWrap = false,
|
||||
maxLines = 1,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Splits text into per-line character ranges, dropping a single trailing newline's empty line. */
|
||||
private fun computeLineRanges(text: String): List<Pair<Int, Int>> {
|
||||
val ranges = ArrayList<Pair<Int, Int>>()
|
||||
var lineStart = 0
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
if (text[i] == '\n') {
|
||||
ranges.add(lineStart to i)
|
||||
lineStart = i + 1
|
||||
}
|
||||
i++
|
||||
}
|
||||
ranges.add(lineStart to text.length)
|
||||
if (ranges.size > 1 && ranges.last().let { it.first == it.second }) {
|
||||
ranges.removeAt(ranges.size - 1)
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
private fun prettyLanguage(enumName: String): String =
|
||||
when (enumName) {
|
||||
"CPP" -> "C++"
|
||||
"CSHARP" -> "C#"
|
||||
"JAVASCRIPT" -> "JavaScript"
|
||||
"TYPESCRIPT" -> "TypeScript"
|
||||
"COFFEESCRIPT" -> "CoffeeScript"
|
||||
"PHP" -> "PHP"
|
||||
else -> enumName.lowercase().replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageFile(
|
||||
bytes: ByteArray,
|
||||
name: String,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val request = remember(bytes) { ImageRequest.Builder(context).data(bytes).build() }
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = request,
|
||||
contentDescription = name,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isMarkdownFile(name: String): Boolean {
|
||||
val ext = name.substringAfterLast('.', "").lowercase()
|
||||
return ext == "md" || ext == "markdown" || ext == "mdown" || ext == "mkd"
|
||||
}
|
||||
|
||||
private fun isImageFile(name: String): Boolean {
|
||||
val ext = name.substringAfterLast('.', "").lowercase()
|
||||
return ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "gif" || ext == "webp" || ext == "bmp"
|
||||
}
|
||||
|
||||
/** Heuristic: a NUL byte in the first chunk means the content isn't text. */
|
||||
private fun isProbablyBinary(data: ByteArray): Boolean {
|
||||
val limit = minOf(data.size, 8000)
|
||||
for (i in 0 until limit) if (data[i].toInt() == 0) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private fun humanSize(bytes: Int): String =
|
||||
when {
|
||||
bytes < 1024 -> "$bytes B"
|
||||
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
|
||||
else -> "${bytes / (1024 * 1024)} MB"
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.code
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.nip34Git.GitBrowseState
|
||||
import com.vitorpamplona.amethyst.commons.nip34Git.GitRepositoryBrowserViewModel
|
||||
import com.vitorpamplona.amethyst.ui.components.RichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitTreeEntry
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* The repository's README as embeddable column content — no scroll container or
|
||||
* scaffold padding of its own, so the project home screen renders it inline below
|
||||
* the repository facts inside a single scroll. Falls back to the announcement's own
|
||||
* description when no README file can be fetched, so the section is never empty.
|
||||
*/
|
||||
@Composable
|
||||
fun GitReadmeSection(
|
||||
state: GitBrowseState,
|
||||
viewModel: GitRepositoryBrowserViewModel,
|
||||
event: GitRepositoryEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val snapshot = (state as? GitBrowseState.Loaded)?.snapshot
|
||||
val readme = remember(snapshot) { snapshot?.let { findReadme(it.rootEntries()) } }
|
||||
|
||||
if (snapshot != null && readme != null) {
|
||||
ReadmeContent(snapshot, viewModel, readme, accountViewModel, nav)
|
||||
} else {
|
||||
ReadmeFallback(
|
||||
event = event,
|
||||
loading = state is GitBrowseState.Loading,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReadmeContent(
|
||||
snapshot: GitRepoSnapshot,
|
||||
viewModel: GitRepositoryBrowserViewModel,
|
||||
readme: GitTreeEntry,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val content by
|
||||
produceState<String?>(null, readme.oid) {
|
||||
value =
|
||||
try {
|
||||
viewModel.readBlob(snapshot, readme.oid).decodeToString()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
when (val text = content) {
|
||||
null -> StatusLine(stringRes(R.string.git_repo_code_loading))
|
||||
"" -> StatusLine(stringRes(R.string.git_repo_file_load_error))
|
||||
else -> {
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
val backgroundColor = remember { mutableStateOf(background) }
|
||||
RichTextViewer(
|
||||
content = text,
|
||||
canPreview = true,
|
||||
quotesLeft = 1,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReadmeFallback(
|
||||
event: GitRepositoryEvent,
|
||||
loading: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val description = event.description()?.takeIf { it.isNotBlank() }
|
||||
if (description == null) {
|
||||
StatusLine(
|
||||
if (loading) stringRes(R.string.git_repo_code_loading) else stringRes(R.string.git_repo_readme_missing),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val background = MaterialTheme.colorScheme.background
|
||||
val backgroundColor = remember { mutableStateOf(background) }
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
RichTextViewer(
|
||||
content = description,
|
||||
canPreview = true,
|
||||
quotesLeft = 1,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
tags = EmptyTagList,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
if (loading) {
|
||||
StatusLine(stringRes(R.string.git_repo_code_loading), topPadding = 16.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusLine(
|
||||
text: String,
|
||||
topPadding: androidx.compose.ui.unit.Dp = 0.dp,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
modifier = Modifier.padding(top = topPadding),
|
||||
)
|
||||
}
|
||||
|
||||
/** Picks the most README-like file in the root, preferring markdown. */
|
||||
private fun findReadme(entries: List<GitTreeEntry>): GitTreeEntry? {
|
||||
val files = entries.filter { !it.isFolder }
|
||||
val readmes = files.filter { it.name.substringBeforeLast('.').equals("readme", ignoreCase = true) || it.name.equals("readme", ignoreCase = true) }
|
||||
if (readmes.isEmpty()) return null
|
||||
val byPreference = listOf("readme.md", "readme.markdown", "readme.mdown", "readme", "readme.txt", "readme.rst")
|
||||
for (preferred in byPreference) {
|
||||
readmes.firstOrNull { it.name.equals(preferred, ignoreCase = true) }?.let { return it }
|
||||
}
|
||||
return readmes.first()
|
||||
}
|
||||
-1
@@ -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() }
|
||||
}
|
||||
|
||||
-1
@@ -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() }
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+24
-6
@@ -48,18 +48,36 @@ class GitRepositoriesFeedFilter(
|
||||
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val params = buildFilterParams(account)
|
||||
|
||||
val notes =
|
||||
LocalCache.addressables.filterIntoSet(GitRepositoryEvent.KIND) { _, it ->
|
||||
val noteEvent = it.event
|
||||
noteEvent is GitRepositoryEvent && params.match(noteEvent, it.relays)
|
||||
if (followList() == TopFilter.Mine) {
|
||||
val me = account.userProfile().pubkeyHex
|
||||
LocalCache.addressables.filterIntoSet(GitRepositoryEvent.KIND) { _, it -> isMine(it, me) }
|
||||
} else {
|
||||
val params = buildFilterParams(account)
|
||||
LocalCache.addressables.filterIntoSet(GitRepositoryEvent.KIND) { _, it ->
|
||||
val noteEvent = it.event
|
||||
noteEvent is GitRepositoryEvent && params.match(noteEvent, it.relays)
|
||||
}
|
||||
}
|
||||
|
||||
return sort(notes)
|
||||
}
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> {
|
||||
if (followList() == TopFilter.Mine) {
|
||||
val me = account.userProfile().pubkeyHex
|
||||
return newItems.filterTo(HashSet()) { isMine(it, me) }
|
||||
}
|
||||
return innerApplyFilter(newItems)
|
||||
}
|
||||
|
||||
private fun isMine(
|
||||
note: Note,
|
||||
me: String,
|
||||
): Boolean {
|
||||
val noteEvent = note.event
|
||||
return noteEvent is GitRepositoryEvent && noteEvent.pubKey == me
|
||||
}
|
||||
|
||||
fun buildFilterParams(account: Account): FilterByListParams =
|
||||
FilterByListParams.create(
|
||||
|
||||
+8
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies.filterGitRepositoriesMine
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
@@ -42,6 +43,13 @@ class GitRepositoriesSubAssembler(
|
||||
key: GitRepositoriesQueryState,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
// "Mine" bypasses the follow-list machinery: query the user's own repositories by author
|
||||
// against their outbox relays (same pattern as music/badges), because the shared
|
||||
// TopFilter.Mine flow falls back to all-follows.
|
||||
if (key.listName() == TopFilter.Mine) {
|
||||
val outbox = key.account.outboxRelays.flow.value
|
||||
return filterGitRepositoriesMine(key.account.userProfile().pubkeyHex, outbox, since)
|
||||
}
|
||||
val feedSettings = key.followsPerRelay()
|
||||
|
||||
return makeGitRepositoriesFilter(feedSettings, since, key.feedStates.gitRepositoriesFeed.lastNoteCreatedAtIfFilled())
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
|
||||
|
||||
/**
|
||||
* Builds the relay filters for the "Mine" repository selector: the user's own repository
|
||||
* announcements, queried by author against their own outbox relays. Mirrors
|
||||
* `filterNappletsMine` / `filterMusicEventsMine` — the only correct source for "my own"
|
||||
* content, since the shared `TopFilter.Mine` flow falls back to all-follows.
|
||||
*/
|
||||
fun filterGitRepositoriesMine(
|
||||
pubkey: HexKey,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
if (relays.isEmpty() || pubkey.isEmpty()) return emptyList()
|
||||
val authors = listOf(pubkey)
|
||||
return relays.map { relay ->
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(GitRepositoryEvent.KIND),
|
||||
authors = authors,
|
||||
limit = 200,
|
||||
since = since?.get(relay)?.time,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1016,6 +1016,8 @@
|
||||
</plurals>
|
||||
<string name="private_bookmarks">Private Bookmarks</string>
|
||||
<string name="public_bookmarks">Public Bookmarks</string>
|
||||
<string name="repository_bookmarks">Repositories</string>
|
||||
<string name="repository_bookmarks_explainer">Your bookmarked git repositories</string>
|
||||
<string name="add_to_private_bookmarks">Add to Private Bookmarks</string>
|
||||
<string name="add_to_public_bookmarks">Add to Public Bookmarks</string>
|
||||
<string name="remove_from_private_bookmarks">Remove from Private Bookmarks</string>
|
||||
@@ -2672,12 +2674,77 @@
|
||||
<string name="git_repo_tab_patches">Patches & PRs</string>
|
||||
<string name="git_repo_filter_open">Open</string>
|
||||
<string name="git_repo_filter_closed">Closed & Resolved</string>
|
||||
<string name="git_repo_label_all">All</string>
|
||||
<string name="git_repo_bookmark">Bookmark repository</string>
|
||||
<string name="git_repo_stat_branches">Branches</string>
|
||||
<string name="git_repo_stat_tags">Tags</string>
|
||||
<string name="git_repo_stat_files">Files</string>
|
||||
<string name="git_repo_stat_updated">Updated</string>
|
||||
<string name="git_repo_recent_activity">Recent activity</string>
|
||||
<string name="git_repo_maintained_by">Maintained by</string>
|
||||
<string name="git_repo_external_host_title">Hosted externally</string>
|
||||
<string name="git_repo_external_host_body">This repository can\'t be cloned over http(s), so the code view isn\'t available here.</string>
|
||||
<string name="git_repo_open_in_browser">Open in browser</string>
|
||||
<string name="git_untitled">Untitled</string>
|
||||
<string name="git_repo_section_about">About</string>
|
||||
<string name="git_repo_section_links">Links</string>
|
||||
<string name="git_repo_section_maintainers">Maintainers</string>
|
||||
<string name="git_repo_section_topics">Topics</string>
|
||||
<string name="git_repo_personal_fork">Personal fork</string>
|
||||
<string name="git_repo_tab_readme">Readme</string>
|
||||
<string name="git_repo_tab_code">Code</string>
|
||||
<string name="git_repo_code_loading">Loading repository…</string>
|
||||
<string name="git_repo_code_error">Could not load the repository from its clone URL.</string>
|
||||
<string name="git_repo_no_clone_url">This repository announcement has no http(s) clone URL to browse.</string>
|
||||
<string name="git_repo_readme_missing">This repository has no README file.</string>
|
||||
<string name="git_repo_file_load_error">Could not load this file.</string>
|
||||
<string name="git_repo_binary_file">Binary file (%1$s) — preview not available.</string>
|
||||
<string name="git_repo_empty_folder">This folder is empty.</string>
|
||||
<string name="git_repo_root">root</string>
|
||||
<string name="git_repo_retry">Retry</string>
|
||||
<string name="git_repo_default_branch">default</string>
|
||||
<string name="git_repo_branches">Branches</string>
|
||||
<string name="git_repo_tags">Tags</string>
|
||||
<string name="git_repo_search_files">Search files…</string>
|
||||
<string name="git_repo_no_search_results">No matching files.</string>
|
||||
<string name="git_repo_commits">Commits</string>
|
||||
<string name="git_repo_no_commits">No commit history available.</string>
|
||||
<string name="git_repo_copy_file">Copy file contents</string>
|
||||
<string name="git_repo_plain_text">Text</string>
|
||||
<plurals name="git_repo_item_count">
|
||||
<item quantity="one">%1$d item</item>
|
||||
<item quantity="other">%1$d items</item>
|
||||
</plurals>
|
||||
<plurals name="git_diff_files_changed">
|
||||
<item quantity="one">%1$d file changed</item>
|
||||
<item quantity="other">%1$d files changed</item>
|
||||
</plurals>
|
||||
<string name="git_diff_binary">Binary file not shown.</string>
|
||||
<string name="git_new_issue_button">New</string>
|
||||
<string name="git_new_issue_title">New issue</string>
|
||||
<string name="git_new_issue_subject">Title</string>
|
||||
<string name="git_new_issue_body">Description</string>
|
||||
<string name="git_new_issue_create">Create</string>
|
||||
<string name="git_new_issue_cancel">Cancel</string>
|
||||
<string name="git_new_issue_labels">Labels</string>
|
||||
<string name="git_new_issue_labels_hint">bug, enhancement…</string>
|
||||
<string name="git_issue_close">Close issue</string>
|
||||
<string name="git_issue_reopen">Reopen</string>
|
||||
<string name="git_status_close">Close</string>
|
||||
<string name="git_status_reopen">Reopen</string>
|
||||
<string name="git_status_mark_merged">Mark merged</string>
|
||||
<string name="git_pr_view_changes">View changes</string>
|
||||
<string name="git_pr_loading_changes">Loading changes…</string>
|
||||
<string name="git_pr_no_changes">No file changes found.</string>
|
||||
<string name="git_pr_changes_retry">Retry loading changes</string>
|
||||
<string name="git_pr_revised">Revised</string>
|
||||
<string name="git_repo_settings_title">Edit repository</string>
|
||||
<string name="git_repo_settings_name">Name</string>
|
||||
<string name="git_repo_settings_description">Description</string>
|
||||
<string name="git_repo_settings_clone_urls">Clone URLs (one per line)</string>
|
||||
<string name="git_repo_settings_web_urls">Web URLs (one per line)</string>
|
||||
<string name="git_repo_settings_topics">Topics (comma separated)</string>
|
||||
<string name="git_repo_settings_save">Save</string>
|
||||
<string name="git_repositories">Git Repositories</string>
|
||||
<string name="nsite_title">nSite: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
+1
@@ -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")
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.model.nip51Lists
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.gitRepositoryList.GitRepositoryListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.remove
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
/**
|
||||
* Account state for the user's bookmarked (starred) git repositories — NIP-51
|
||||
* kind 10018. Mirrors [BookmarkListState] but only the public list is used for
|
||||
* the star toggle; removal rebuilds the public tags and preserves the encrypted
|
||||
* private section untouched, so it never needs to decrypt.
|
||||
*/
|
||||
@Stable
|
||||
class GitRepositoryListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: ICacheProvider,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
// Long-term reference so the GC keeps the note alive.
|
||||
val repositoryList = cache.getOrCreateAddressableNote(getListAddress())
|
||||
|
||||
fun getListAddress() = GitRepositoryListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getListFlow(): StateFlow<NoteState> = repositoryList.flow().metadata.stateFlow
|
||||
|
||||
fun getList(): GitRepositoryListEvent? = repositoryList.event as? GitRepositoryListEvent
|
||||
|
||||
private fun publicAddresses(note: Note): Set<Address> = (note.event as? GitRepositoryListEvent)?.publicRepositories()?.map { it.address }?.toSet() ?: emptySet()
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
val publicRepositoryAddressSet: StateFlow<Set<Address>> =
|
||||
getListFlow()
|
||||
.map { publicAddresses(it.note) }
|
||||
.onStart { emit(publicAddresses(repositoryList)) }
|
||||
.debounce(100)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
fun isBookmarked(address: Address): Boolean = publicRepositoryAddressSet.value.contains(address)
|
||||
|
||||
/** Adds [note]'s address to the public list, creating the list if needed. */
|
||||
suspend fun addRepository(note: AddressableNote): GitRepositoryListEvent {
|
||||
val list = getList()
|
||||
val bookmark = AddressBookmark(note.address, note.relayHintUrl())
|
||||
return if (list == null) {
|
||||
GitRepositoryListEvent.create(publicRepositories = listOf(bookmark), signer = signer)
|
||||
} else {
|
||||
GitRepositoryListEvent.add(earlierVersion = list, repository = bookmark, isPrivate = false, signer = signer)
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes [note]'s address from the public list, leaving the private section intact. */
|
||||
suspend fun removeRepository(note: AddressableNote): GitRepositoryListEvent? {
|
||||
val list = getList() ?: return null
|
||||
val idTag = AddressBookmark(note.address, note.relayHintUrl()).toTagIdOnly()
|
||||
val newTags = list.tags.remove(idTag)
|
||||
if (newTags.size == list.tags.size) return null
|
||||
return GitRepositoryListEvent.resign(content = list.content, tags = newTags, signer = signer)
|
||||
}
|
||||
}
|
||||
+95
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -39,6 +39,15 @@ import androidx.compose.ui.unit.dp
|
||||
*/
|
||||
val LocalDisappearingScaffoldPadding = compositionLocalOf { PaddingValues(0.dp) }
|
||||
|
||||
/**
|
||||
* The surrounding [DisappearingScaffold]'s bar state, exposed so inner content that swaps its
|
||||
* scrollable in place — and thereby resets the scroll position to the top without emitting a
|
||||
* scroll delta — can pull the bar back into view. Without this the bar can stay stuck at its
|
||||
* hidden offset over fresh top-of-list content, leaving a blank band. Null when no scaffold
|
||||
* provides it.
|
||||
*/
|
||||
val LocalDisappearingBarState = compositionLocalOf<DisappearingBarState?> { null }
|
||||
|
||||
/**
|
||||
* Merges two [PaddingValues] component-wise, resolving start/end against the current
|
||||
* [LocalLayoutDirection].
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.nip34Git
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot
|
||||
|
||||
/**
|
||||
* Process-wide cache of fetched default-branch repository snapshots, keyed by repository
|
||||
* address (`kind:pubkey:dTag`).
|
||||
*
|
||||
* A snapshot is a shallow clone fetched over the network, so re-cloning on every screen — or
|
||||
* during the share-to-image capture, which only waits ~1s for content to settle — produces
|
||||
* empty stats. Caching the default-branch snapshot lets the project home, the feed repo card
|
||||
* and the image renderer reuse it synchronously and avoids the repeated fetch the user sees
|
||||
* when switching screens. Bounded so a few large repos can't grow memory without limit.
|
||||
*/
|
||||
object GitRepoSnapshotCache {
|
||||
private val cache = LruCache<String, GitRepoSnapshot>(8)
|
||||
|
||||
fun get(key: String?): GitRepoSnapshot? = key?.let { cache.get(it) }
|
||||
|
||||
fun put(
|
||||
key: String?,
|
||||
snapshot: GitRepoSnapshot,
|
||||
) {
|
||||
if (key != null) cache.put(key, snapshot)
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.nip34Git
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitCommit
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitHttpClient
|
||||
import com.vitorpamplona.quartz.nip34Git.git.GitRepoSnapshot
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/** UI state for the git repository code/README browser. */
|
||||
sealed interface GitBrowseState {
|
||||
object Loading : GitBrowseState
|
||||
|
||||
class Loaded(
|
||||
val snapshot: GitRepoSnapshot,
|
||||
) : GitBrowseState
|
||||
|
||||
class Error(
|
||||
val message: String,
|
||||
) : GitBrowseState
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a shallow snapshot of a NIP-34 repository over git smart-HTTP and serves
|
||||
* both the README tab and the Code browser. The snapshot lets the UI walk
|
||||
* directories offline; [readBlob] lazily fetches file contents on demand.
|
||||
*/
|
||||
class GitRepositoryBrowserViewModel(
|
||||
private val okHttpClient: (String) -> OkHttpClient,
|
||||
) : ViewModel() {
|
||||
private val client = GitHttpClient(okHttpClient)
|
||||
|
||||
private val _state = MutableStateFlow<GitBrowseState>(GitBrowseState.Loading)
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private var cloneUrls: List<String> = emptyList()
|
||||
private var started = false
|
||||
private var cacheKey: String? = null
|
||||
|
||||
/** The branch/tag currently displayed (null = the server's default branch). */
|
||||
var currentRef: String? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Loads the repository once, using the announcement's clone URLs. Safe to call
|
||||
* on every recomposition; only the first call (after the event has arrived) runs.
|
||||
*
|
||||
* When [cacheKey] (the repository address) has an already-fetched default-branch snapshot
|
||||
* in [GitRepoSnapshotCache], it is served synchronously and no new clone is performed — so
|
||||
* revisiting a repo across screens, and the share-to-image renderer, reuse it instantly.
|
||||
*/
|
||||
fun loadOnce(
|
||||
cloneUrls: List<String>,
|
||||
cacheKey: String? = null,
|
||||
) {
|
||||
if (started) return
|
||||
started = true
|
||||
this.cloneUrls = cloneUrls
|
||||
this.cacheKey = cacheKey
|
||||
|
||||
val cached = GitRepoSnapshotCache.get(cacheKey)
|
||||
if (cached != null) {
|
||||
_state.value = GitBrowseState.Loaded(cached)
|
||||
return
|
||||
}
|
||||
reload()
|
||||
}
|
||||
|
||||
/** Reloads the tree at [ref] (a branch or tag name; null = default branch). */
|
||||
fun switchRef(ref: String?) {
|
||||
if (ref == currentRef) return
|
||||
currentRef = ref
|
||||
reload()
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
_state.value = GitBrowseState.Loading
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val candidates = candidateUrls()
|
||||
if (candidates.isEmpty()) {
|
||||
_state.value = GitBrowseState.Error(NO_CLONE_URL)
|
||||
return@launch
|
||||
}
|
||||
val errors = StringBuilder()
|
||||
for (url in candidates) {
|
||||
try {
|
||||
val snapshot = client.open(url, currentRef)
|
||||
// Only the default branch is cached; named refs are transient selections.
|
||||
if (currentRef == null) GitRepoSnapshotCache.put(cacheKey, snapshot)
|
||||
_state.value = GitBrowseState.Loaded(snapshot)
|
||||
return@launch
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
errors
|
||||
.append(url)
|
||||
.append(" → ")
|
||||
.append(e.message ?: e.toString())
|
||||
.append('\n')
|
||||
}
|
||||
}
|
||||
_state.value = GitBrowseState.Error(errors.toString().trim())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun readBlob(
|
||||
snapshot: GitRepoSnapshot,
|
||||
oid: String,
|
||||
): ByteArray = snapshot.readBlob(oid)
|
||||
|
||||
/** Recent commits ending at the snapshot's tip, most recent first. */
|
||||
suspend fun loadHistory(snapshot: GitRepoSnapshot): List<GitCommit> = withContext(Dispatchers.IO) { client.loadHistory(snapshot.cloneUrl, snapshot.headCommit) }
|
||||
|
||||
/** The diff a commit introduced (commit vs its first parent). */
|
||||
suspend fun commitDiff(
|
||||
snapshot: GitRepoSnapshot,
|
||||
commit: GitCommit,
|
||||
): ParsedPatch =
|
||||
withContext(Dispatchers.IO) {
|
||||
client.computeDiff(snapshot.cloneUrl, commit.oid, commit.parents.firstOrNull())
|
||||
}
|
||||
|
||||
/** http(s) clone URLs to try, in order, including a `.git` variant when missing. */
|
||||
private fun candidateUrls(): List<String> {
|
||||
val out = LinkedHashSet<String>()
|
||||
for (raw in cloneUrls) {
|
||||
val url = raw.trim()
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) continue
|
||||
out.add(url)
|
||||
if (!url.removeSuffix("/").endsWith(".git")) out.add(url.removeSuffix("/") + ".git")
|
||||
}
|
||||
return out.toList()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NO_CLONE_URL = "no-clone-url"
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ lifecycleRuntimeKtx = "2.11.0"
|
||||
lightcompressor-enhanced = "2.2.1"
|
||||
jlatexmath = "1.4"
|
||||
markdown = "f92ef49c9d"
|
||||
highlights = "1.1.0"
|
||||
material3 = "1.9.0"
|
||||
media3 = "1.10.1"
|
||||
mockk = "1.14.11"
|
||||
@@ -191,6 +192,7 @@ jlatexmath-font-cyrillic = { module = "com.github.rikkahub.jlatexmath-android:jl
|
||||
markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-commonmark", version.ref = "markdown" }
|
||||
markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" }
|
||||
markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" }
|
||||
highlights = { group = "dev.snipme", name = "highlights", version.ref = "highlights" }
|
||||
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
|
||||
mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Git smart-HTTP browser for NIP-34 repositories
|
||||
|
||||
Date: 2026-06-28
|
||||
Module: `quartz` (protocol client) + `amethyst` (UI)
|
||||
|
||||
## Goal
|
||||
|
||||
On the git repository screen, render the repo's `README` in the first tab and add
|
||||
a **Code** tab that browses the repository's file tree and renders source files
|
||||
(syntax-highlighted), reading directly from the repo's git `clone` URL.
|
||||
|
||||
NIP-34 (`GitRepositoryEvent`, kind 30617) only guarantees a git `clone` URL — a
|
||||
plain git-over-HTTP(S) endpoint. To work with **every** server in the Nostr git
|
||||
ecosystem (GRASP / `ngit` bare servers as well as GitHub/GitLab/Gitea), we read
|
||||
files via the **git smart-HTTP protocol v2** rather than host-specific web APIs.
|
||||
|
||||
## Survey (what already exists — reused, not duplicated)
|
||||
|
||||
- `GitRepositoryEvent.clones()` / `webs()` — quartz, kept as-is. Source of the
|
||||
endpoint URLs.
|
||||
- `OkHttpClientFactory` + the `(String) -> OkHttpClient` provider already injected
|
||||
into quartz fetchers (`OkHttpNip05Fetcher`, `OkHttpLnurlEndpointResolver`). The
|
||||
git client takes the same lambda, so it inherits Tor/proxy routing and the
|
||||
onion-rewrite interceptors for free.
|
||||
- `RenderContentAsMarkdown` (amethyst) — renders the `README.md` rich-text.
|
||||
- `GitRepositoryScreen` HorizontalPager + `SecondaryTabRow` — the tab host we
|
||||
extend.
|
||||
|
||||
Genuinely new: the git smart-HTTP client (no git/pack code existed anywhere) and
|
||||
a syntax-highlighted code viewer.
|
||||
|
||||
## Protocol client (`quartz`, `jvmAndroid` source set)
|
||||
|
||||
Placed in `jvmAndroid` (shared by Android + Desktop JVM) so it can use
|
||||
`java.util.zip.Inflater`, `java.security.MessageDigest`, and OkHttp directly with
|
||||
no expect/actual. Not needed on iOS/native.
|
||||
|
||||
Package `com.vitorpamplona.quartz.nip34Git.git`:
|
||||
|
||||
- `PktLine` — git pkt-line frame reader/writer (flush `0000`, delim `0001`,
|
||||
response-end `0002`, data otherwise).
|
||||
- `GitObjectType`, `GitTreeEntry`, `GitTree`/commit parsers — pure byte parsing of
|
||||
loose object payloads (`<mode> <name>\0<20-byte oid>` tree entries; `tree <oid>`
|
||||
from a commit).
|
||||
- `Packfile` — parses a v2 packfile: object headers, zlib inflate per object,
|
||||
`OBJ_OFS_DELTA` / `OBJ_REF_DELTA` resolution, and SHA-1 oid computation
|
||||
(`sha1("<type> <len>\0" + content)`).
|
||||
- `GitDelta` — copy/insert delta instruction decoder.
|
||||
- `GitSmartHttpTransport` — the three HTTP exchanges:
|
||||
1. `GET {clone}/info/refs?service=git-upload-pack` (`Git-Protocol: version=2`)
|
||||
→ capability advertisement (we read `fetch` features incl. `filter`, and
|
||||
`object-format`).
|
||||
2. `POST {clone}/git-upload-pack` `command=ls-refs` → HEAD oid + default branch.
|
||||
3. `POST {clone}/git-upload-pack` `command=fetch` → sideband-framed packfile.
|
||||
- `GitHttpRepository` / `GitHttpClient` — high level:
|
||||
- `loadRepository(cloneUrl)` → `fetch want <HEAD> deepen 1 filter blob:none`,
|
||||
yielding the commit + **all** trees in one shallow request. Builds
|
||||
`treeOid -> entries` and a navigable snapshot (`entriesAt(path)`).
|
||||
- `loadBlob(oid)` → lazy partial-clone fetch `want <oid> filter blob:none`
|
||||
(cached). Falls back to a full `deepen 1` (no filter) snapshot for servers
|
||||
that don't advertise `filter`, in which case all blobs arrive up-front.
|
||||
|
||||
### Why this shape
|
||||
|
||||
`filter blob:none` + `deepen 1` is exactly how a git partial clone browses a tip
|
||||
without downloading file contents; lazy blob-by-oid fetch is the same mechanism
|
||||
git uses to backfill missing blobs, so any server that advertises `filter`
|
||||
supports both. We do **not** request `thin-pack`, so packs are self-contained and
|
||||
deltas are `OFS_DELTA` (offset based) — no cross-pack base lookups.
|
||||
|
||||
Validated end-to-end against `github.com/octocat/Hello-World.git`; the captured
|
||||
wire bytes are checked in as offline test fixtures (CI-safe, no network).
|
||||
|
||||
## UI (`amethyst`)
|
||||
|
||||
- Tabs become: **README**, **Code**, Overview, Issues, Patches.
|
||||
- `GitReadmeTab` — fetches `README(.md/.markdown/...)` from the root tree, renders
|
||||
via `RenderContentAsMarkdown`; falls back to the repo description / overview.
|
||||
- `GitCodeTab` — file browser (breadcrumb + folders-first listing) backed by a
|
||||
`GitRepositoryBrowserViewModel`; tapping a file opens `GitFileViewer`.
|
||||
- `GitFileViewer` — markdown for `.md`, otherwise monospace + syntax highlighting
|
||||
via the `dev.snipme:highlights` KMP library (Apache-2.0 — permissive, OK).
|
||||
|
||||
## Out of scope (future)
|
||||
|
||||
- Writing/committing, branch switching beyond HEAD, history/blame, large-binary
|
||||
preview, sha256 object-format repos.
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip34Git.patch
|
||||
|
||||
/** A half-open `[start, end)` character span within a line. */
|
||||
class CharSpan(
|
||||
val start: Int,
|
||||
val end: Int,
|
||||
) {
|
||||
val isEmpty: Boolean get() = end <= start
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes intra-line (word/character level) change spans for a hunk, so the diff
|
||||
* renderer can emphasize *what* changed inside a modified line rather than just
|
||||
* coloring the whole line. Contiguous delete-runs are paired with the following
|
||||
* add-runs (delete[i] ↔ add[i]); each pair is reduced to the differing middle by
|
||||
* trimming the common prefix and suffix.
|
||||
*/
|
||||
object IntralineDiff {
|
||||
/** The changed middle of [old] vs [new] (common prefix/suffix trimmed). */
|
||||
fun changedSpans(
|
||||
old: String,
|
||||
new: String,
|
||||
): Pair<CharSpan, CharSpan> {
|
||||
if (old == new) return CharSpan(0, 0) to CharSpan(0, 0)
|
||||
val n = old.length
|
||||
val m = new.length
|
||||
var prefix = 0
|
||||
while (prefix < n && prefix < m && old[prefix] == new[prefix]) prefix++
|
||||
var suffix = 0
|
||||
while (suffix < n - prefix && suffix < m - prefix && old[n - 1 - suffix] == new[m - 1 - suffix]) suffix++
|
||||
return CharSpan(prefix, n - suffix) to CharSpan(prefix, m - suffix)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns, per line index in [lines], the changed span to emphasize. Only
|
||||
* paired modified lines get an entry; pure add/delete blocks and context
|
||||
* lines are absent.
|
||||
*/
|
||||
fun emphasis(lines: List<GitDiffLine>): Map<Int, CharSpan> {
|
||||
val result = HashMap<Int, CharSpan>()
|
||||
var i = 0
|
||||
while (i < lines.size) {
|
||||
if (lines[i].type != GitDiffLineType.DELETE) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
val delStart = i
|
||||
while (i < lines.size && lines[i].type == GitDiffLineType.DELETE) i++
|
||||
val delEnd = i
|
||||
val addStart = i
|
||||
while (i < lines.size && lines[i].type == GitDiffLineType.ADD) i++
|
||||
val addEnd = i
|
||||
|
||||
val pairs = minOf(delEnd - delStart, addEnd - addStart)
|
||||
for (k in 0 until pairs) {
|
||||
val delIdx = delStart + k
|
||||
val addIdx = addStart + k
|
||||
val (oldSpan, newSpan) = changedSpans(lines[delIdx].content, lines[addIdx].content)
|
||||
if (!oldSpan.isEmpty) result[delIdx] = oldSpan
|
||||
if (!newSpan.isEmpty) result[addIdx] = newSpan
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip34Git.patch
|
||||
|
||||
/**
|
||||
* Computes a line-level unified diff between two texts using Myers' O(ND)
|
||||
* difference algorithm, producing the same [GitDiffHunk] model the embedded
|
||||
* patch parser uses. This lets the app render a diff for pull requests that
|
||||
* reference a clone URL + commit instead of embedding a `git format-patch`.
|
||||
*/
|
||||
object LineDiff {
|
||||
private class Op(
|
||||
val type: GitDiffLineType,
|
||||
val oldIndex: Int, // 0-based index into old lines, -1 for inserts
|
||||
val newIndex: Int, // 0-based index into new lines, -1 for deletes
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds unified-diff hunks between [oldLines] and [newLines], with [context]
|
||||
* unchanged lines around each change (git's default is 3).
|
||||
*/
|
||||
fun hunks(
|
||||
oldLines: List<String>,
|
||||
newLines: List<String>,
|
||||
context: Int = 3,
|
||||
): List<GitDiffHunk> {
|
||||
val ops = computeOps(oldLines, newLines)
|
||||
return groupHunks(ops, oldLines, newLines, context)
|
||||
}
|
||||
|
||||
/** Myers O(ND) diff: returns an ordered op list (CONTEXT / DELETE / ADD). */
|
||||
private fun computeOps(
|
||||
a: List<String>,
|
||||
b: List<String>,
|
||||
): List<Op> {
|
||||
val n = a.size
|
||||
val m = b.size
|
||||
if (n == 0 && m == 0) return emptyList()
|
||||
val max = n + m
|
||||
val offset = max
|
||||
val trace = ArrayList<IntArray>()
|
||||
val v = IntArray(2 * max + 1)
|
||||
|
||||
var found = false
|
||||
for (d in 0..max) {
|
||||
trace.add(v.copyOf())
|
||||
var k = -d
|
||||
while (k <= d) {
|
||||
val idx = k + offset
|
||||
var x =
|
||||
if (k == -d || (k != d && v[idx - 1] < v[idx + 1])) {
|
||||
v[idx + 1]
|
||||
} else {
|
||||
v[idx - 1] + 1
|
||||
}
|
||||
var y = x - k
|
||||
while (x < n && y < m && a[x] == b[y]) {
|
||||
x++
|
||||
y++
|
||||
}
|
||||
v[idx] = x
|
||||
if (x >= n && y >= m) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
k += 2
|
||||
}
|
||||
if (found) break
|
||||
}
|
||||
|
||||
// Backtrack to recover the edit script.
|
||||
val reversed = ArrayList<Op>()
|
||||
var x = n
|
||||
var y = m
|
||||
for (d in trace.indices.reversed()) {
|
||||
val vv = trace[d]
|
||||
val k = x - y
|
||||
val idx = k + offset
|
||||
val prevK =
|
||||
if (k == -d || (k != d && vv[idx - 1] < vv[idx + 1])) {
|
||||
k + 1
|
||||
} else {
|
||||
k - 1
|
||||
}
|
||||
val prevIdx = prevK + offset
|
||||
val prevX = vv[prevIdx]
|
||||
val prevY = prevX - prevK
|
||||
|
||||
while (x > prevX && y > prevY) {
|
||||
reversed.add(Op(GitDiffLineType.CONTEXT, x - 1, y - 1))
|
||||
x--
|
||||
y--
|
||||
}
|
||||
if (d > 0) {
|
||||
if (x == prevX) {
|
||||
reversed.add(Op(GitDiffLineType.ADD, -1, y - 1))
|
||||
} else {
|
||||
reversed.add(Op(GitDiffLineType.DELETE, x - 1, -1))
|
||||
}
|
||||
}
|
||||
x = prevX
|
||||
y = prevY
|
||||
}
|
||||
reversed.reverse()
|
||||
return reversed
|
||||
}
|
||||
|
||||
private fun groupHunks(
|
||||
ops: List<Op>,
|
||||
oldLines: List<String>,
|
||||
newLines: List<String>,
|
||||
context: Int,
|
||||
): List<GitDiffHunk> {
|
||||
if (ops.none { it.type != GitDiffLineType.CONTEXT }) return emptyList()
|
||||
|
||||
val changeIndexes = ops.indices.filter { ops[it].type != GitDiffLineType.CONTEXT }
|
||||
val hunks = ArrayList<GitDiffHunk>()
|
||||
|
||||
var i = 0
|
||||
while (i < changeIndexes.size) {
|
||||
val start = changeIndexes[i]
|
||||
var end = start
|
||||
// Extend the hunk while the next change is within 2*context of the previous.
|
||||
var j = i
|
||||
while (j + 1 < changeIndexes.size && changeIndexes[j + 1] - changeIndexes[j] <= 2 * context + 1) {
|
||||
j++
|
||||
end = changeIndexes[j]
|
||||
}
|
||||
val from = (start - context).coerceAtLeast(0)
|
||||
val to = (end + context).coerceAtMost(ops.size - 1)
|
||||
|
||||
val lines = ArrayList<GitDiffLine>()
|
||||
var oldStart = -1
|
||||
var newStart = -1
|
||||
var oldCount = 0
|
||||
var newCount = 0
|
||||
for (idx in from..to) {
|
||||
val op = ops[idx]
|
||||
val oldNo = if (op.oldIndex >= 0) op.oldIndex + 1 else null
|
||||
val newNo = if (op.newIndex >= 0) op.newIndex + 1 else null
|
||||
val content = if (op.type == GitDiffLineType.ADD) newLines[op.newIndex] else oldLines[op.oldIndex]
|
||||
lines.add(GitDiffLine(op.type, content, oldNo, newNo))
|
||||
if (op.type != GitDiffLineType.ADD) {
|
||||
if (oldStart < 0) oldStart = op.oldIndex + 1
|
||||
oldCount++
|
||||
}
|
||||
if (op.type != GitDiffLineType.DELETE) {
|
||||
if (newStart < 0) newStart = op.newIndex + 1
|
||||
newCount++
|
||||
}
|
||||
}
|
||||
if (oldStart < 0) oldStart = 0
|
||||
if (newStart < 0) newStart = 0
|
||||
val header = "@@ -$oldStart,$oldCount +$newStart,$newCount @@"
|
||||
hunks.add(GitDiffHunk(header, oldStart, newStart, lines))
|
||||
|
||||
i = j + 1
|
||||
}
|
||||
return hunks
|
||||
}
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip34Git.patch
|
||||
|
||||
/** How a file changed within a diff. */
|
||||
enum class GitFileChange {
|
||||
ADD,
|
||||
DELETE,
|
||||
MODIFY,
|
||||
RENAME,
|
||||
}
|
||||
|
||||
/** The role of a single line inside a hunk. */
|
||||
enum class GitDiffLineType {
|
||||
CONTEXT,
|
||||
ADD,
|
||||
DELETE,
|
||||
}
|
||||
|
||||
class GitDiffLine(
|
||||
val type: GitDiffLineType,
|
||||
val content: String,
|
||||
val oldNumber: Int?,
|
||||
val newNumber: Int?,
|
||||
)
|
||||
|
||||
class GitDiffHunk(
|
||||
val header: String,
|
||||
val oldStart: Int,
|
||||
val newStart: Int,
|
||||
val lines: List<GitDiffLine>,
|
||||
)
|
||||
|
||||
class GitDiffFile(
|
||||
val oldPath: String?,
|
||||
val newPath: String?,
|
||||
val change: GitFileChange,
|
||||
val isBinary: Boolean,
|
||||
val hunks: List<GitDiffHunk>,
|
||||
) {
|
||||
val displayPath: String get() = newPath ?: oldPath ?: "?"
|
||||
val additions: Int get() = hunks.sumOf { hunk -> hunk.lines.count { it.type == GitDiffLineType.ADD } }
|
||||
val deletions: Int get() = hunks.sumOf { hunk -> hunk.lines.count { it.type == GitDiffLineType.DELETE } }
|
||||
}
|
||||
|
||||
/** A parsed patch: the human-readable commit message and the changed files. */
|
||||
class ParsedPatch(
|
||||
val message: String,
|
||||
val files: List<GitDiffFile>,
|
||||
) {
|
||||
val hasDiff: Boolean get() = files.isNotEmpty()
|
||||
val totalAdditions: Int get() = files.sumOf { it.additions }
|
||||
val totalDeletions: Int get() = files.sumOf { it.deletions }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a `git format-patch` / unified-diff string (the `content` of a NIP-34
|
||||
* patch event, kind 1617) into a commit message plus a structured list of file
|
||||
* diffs. Tolerant of partial input: anything that isn't a recognizable diff is
|
||||
* returned as the [ParsedPatch.message].
|
||||
*/
|
||||
object UnifiedDiffParser {
|
||||
private val DIFF_GIT_PREFIX = "diff --git "
|
||||
private val HUNK_PREFIX = "@@"
|
||||
private val MBOX_FROM = Regex("^From [0-9a-fA-F]{7,40}\\b.*")
|
||||
private val HEADER_KEYS =
|
||||
setOf(
|
||||
"from",
|
||||
"date",
|
||||
"subject",
|
||||
"mime-version",
|
||||
"content-type",
|
||||
"content-transfer-encoding",
|
||||
"message-id",
|
||||
"in-reply-to",
|
||||
"references",
|
||||
)
|
||||
|
||||
fun parse(patch: String): ParsedPatch {
|
||||
val lines = patch.split("\n")
|
||||
val firstDiff = lines.indexOfFirst { it.startsWith(DIFF_GIT_PREFIX) }
|
||||
|
||||
if (firstDiff < 0) {
|
||||
return ParsedPatch(cleanMessage(lines), emptyList())
|
||||
}
|
||||
|
||||
val message = cleanMessage(lines.subList(0, firstDiff))
|
||||
val files = parseFiles(lines, firstDiff)
|
||||
return ParsedPatch(message, files)
|
||||
}
|
||||
|
||||
private fun parseFiles(
|
||||
lines: List<String>,
|
||||
from: Int,
|
||||
): List<GitDiffFile> {
|
||||
// Split into per-file blocks at each "diff --git" line.
|
||||
val blocks = ArrayList<IntRange>()
|
||||
var blockStart = -1
|
||||
for (i in from until lines.size) {
|
||||
if (lines[i].startsWith(DIFF_GIT_PREFIX)) {
|
||||
if (blockStart >= 0) blocks.add(blockStart until i)
|
||||
blockStart = i
|
||||
}
|
||||
}
|
||||
if (blockStart >= 0) blocks.add(blockStart until lines.size)
|
||||
|
||||
return blocks.map { parseFile(lines, it) }
|
||||
}
|
||||
|
||||
private fun parseFile(
|
||||
lines: List<String>,
|
||||
range: IntRange,
|
||||
): GitDiffFile {
|
||||
var oldPath: String?
|
||||
var newPath: String?
|
||||
run {
|
||||
val header = lines[range.first].removePrefix(DIFF_GIT_PREFIX)
|
||||
val sep = header.indexOf(" b/")
|
||||
if (sep >= 0) {
|
||||
oldPath =
|
||||
header
|
||||
.substring(0, sep)
|
||||
.removePrefix("a/")
|
||||
.trim()
|
||||
.ifBlank { null }
|
||||
newPath =
|
||||
header
|
||||
.substring(sep + 1)
|
||||
.removePrefix("b/")
|
||||
.trim()
|
||||
.ifBlank { null }
|
||||
} else {
|
||||
oldPath = null
|
||||
newPath = null
|
||||
}
|
||||
}
|
||||
|
||||
var change = GitFileChange.MODIFY
|
||||
var isBinary = false
|
||||
|
||||
var i = range.first + 1
|
||||
while (i <= range.last && !lines[i].startsWith(HUNK_PREFIX)) {
|
||||
val line = lines[i]
|
||||
when {
|
||||
line.startsWith("new file mode") -> change = GitFileChange.ADD
|
||||
line.startsWith("deleted file mode") -> change = GitFileChange.DELETE
|
||||
line.startsWith("rename from ") -> {
|
||||
change = GitFileChange.RENAME
|
||||
oldPath = line.removePrefix("rename from ").trim()
|
||||
}
|
||||
line.startsWith("rename to ") -> {
|
||||
change = GitFileChange.RENAME
|
||||
newPath = line.removePrefix("rename to ").trim()
|
||||
}
|
||||
line.startsWith("--- ") -> oldPath = resolveSidePath(line.removePrefix("--- "), oldPath)
|
||||
line.startsWith("+++ ") -> newPath = resolveSidePath(line.removePrefix("+++ "), newPath)
|
||||
line.startsWith("Binary files") || line.startsWith("GIT binary patch") -> isBinary = true
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
val hunks = if (isBinary) emptyList() else parseHunks(lines, i, range.last)
|
||||
if (oldPath == null && newPath != null && change == GitFileChange.MODIFY) change = GitFileChange.ADD
|
||||
if (newPath == null && oldPath != null && change == GitFileChange.MODIFY) change = GitFileChange.DELETE
|
||||
return GitDiffFile(oldPath, newPath, change, isBinary, hunks)
|
||||
}
|
||||
|
||||
private fun parseHunks(
|
||||
lines: List<String>,
|
||||
from: Int,
|
||||
last: Int,
|
||||
): List<GitDiffHunk> {
|
||||
val hunks = ArrayList<GitDiffHunk>()
|
||||
var i = from
|
||||
while (i <= last) {
|
||||
if (!lines[i].startsWith(HUNK_PREFIX)) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
val header = lines[i]
|
||||
val (oldStart, oldLen, newStart, newLen) = parseHunkHeader(header)
|
||||
val hunkLines = ArrayList<GitDiffLine>()
|
||||
var oldNo = oldStart
|
||||
var newNo = newStart
|
||||
// Bound the hunk by its declared line counts so trailing content (e.g. the
|
||||
// format-patch "-- " signature) is never mistaken for a deletion.
|
||||
var remainingOld = oldLen
|
||||
var remainingNew = newLen
|
||||
i++
|
||||
while (i <= last && (remainingOld > 0 || remainingNew > 0)) {
|
||||
val line = lines[i]
|
||||
if (line.startsWith(HUNK_PREFIX) || line.startsWith(DIFF_GIT_PREFIX)) break
|
||||
when {
|
||||
line.startsWith("+") -> {
|
||||
hunkLines.add(GitDiffLine(GitDiffLineType.ADD, line.substring(1), null, newNo))
|
||||
newNo++
|
||||
remainingNew--
|
||||
}
|
||||
line.startsWith("-") -> {
|
||||
hunkLines.add(GitDiffLine(GitDiffLineType.DELETE, line.substring(1), oldNo, null))
|
||||
oldNo++
|
||||
remainingOld--
|
||||
}
|
||||
line.startsWith("\\") -> {} // "\ No newline at end of file"
|
||||
else -> {
|
||||
val text = if (line.startsWith(" ")) line.substring(1) else line
|
||||
hunkLines.add(GitDiffLine(GitDiffLineType.CONTEXT, text, oldNo, newNo))
|
||||
oldNo++
|
||||
newNo++
|
||||
remainingOld--
|
||||
remainingNew--
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
hunks.add(GitDiffHunk(header, oldStart, newStart, hunkLines))
|
||||
}
|
||||
return hunks
|
||||
}
|
||||
|
||||
private data class HunkHeader(
|
||||
val oldStart: Int,
|
||||
val oldLen: Int,
|
||||
val newStart: Int,
|
||||
val newLen: Int,
|
||||
)
|
||||
|
||||
/** Parses `@@ -oldStart,oldLen +newStart,newLen @@ section`. Omitted lengths default to 1. */
|
||||
private fun parseHunkHeader(header: String): HunkHeader {
|
||||
// header looks like: @@ -12,7 +12,9 @@ optional context
|
||||
val body = header.removePrefix("@@").substringBefore("@@").trim()
|
||||
var oldStart = 0
|
||||
var oldLen = 1
|
||||
var newStart = 0
|
||||
var newLen = 1
|
||||
for (token in body.split(' ')) {
|
||||
when {
|
||||
token.startsWith("-") -> {
|
||||
val v = token.removePrefix("-")
|
||||
oldStart = v.substringBefore(',').toIntOrNull() ?: 0
|
||||
oldLen = if (',' in v) v.substringAfter(',').toIntOrNull() ?: 1 else 1
|
||||
}
|
||||
token.startsWith("+") -> {
|
||||
val v = token.removePrefix("+")
|
||||
newStart = v.substringBefore(',').toIntOrNull() ?: 0
|
||||
newLen = if (',' in v) v.substringAfter(',').toIntOrNull() ?: 1 else 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return HunkHeader(oldStart, oldLen, newStart, newLen)
|
||||
}
|
||||
|
||||
/** Resolves a `--- ` / `+++ ` target: `/dev/null` clears the side; a real path sets it; blank keeps [current]. */
|
||||
private fun resolveSidePath(
|
||||
raw: String,
|
||||
current: String?,
|
||||
): String? {
|
||||
val path = raw.trim()
|
||||
if (path == "/dev/null") return null
|
||||
val cleaned = path.removePrefix("a/").removePrefix("b/")
|
||||
return cleaned.ifBlank { current }
|
||||
}
|
||||
|
||||
/** Extracts the commit-message body from a format-patch preamble, dropping mbox/email headers and the diffstat. */
|
||||
private fun cleanMessage(lines: List<String>): String {
|
||||
var i = 0
|
||||
if (i < lines.size && MBOX_FROM.matches(lines[i])) i++
|
||||
|
||||
val looksLikeHeaders = i < lines.size && lines[i].substringBefore(':').lowercase() in HEADER_KEYS
|
||||
if (looksLikeHeaders) {
|
||||
while (i < lines.size && lines[i].isNotBlank()) i++
|
||||
if (i < lines.size && lines[i].isBlank()) i++
|
||||
}
|
||||
|
||||
val body = StringBuilder()
|
||||
while (i < lines.size) {
|
||||
if (lines[i].trimEnd() == "---") break
|
||||
body.append(lines[i]).append('\n')
|
||||
i++
|
||||
}
|
||||
return body.toString().trim()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip34Git.git
|
||||
|
||||
/**
|
||||
* Applies a git delta ([delta]) against a [base] object payload, reconstructing
|
||||
* the target object. This is the format used by `OBJ_OFS_DELTA` / `OBJ_REF_DELTA`
|
||||
* packfile entries.
|
||||
*
|
||||
* Layout: a base-size varint, a target-size varint, then a stream of
|
||||
* instructions — copy (high bit set, offset/size assembled from the selected
|
||||
* following bytes) or insert (a literal run of `cmd` bytes).
|
||||
*/
|
||||
object GitDelta {
|
||||
fun apply(
|
||||
base: ByteArray,
|
||||
delta: ByteArray,
|
||||
): ByteArray {
|
||||
var pos = 0
|
||||
|
||||
val baseSize = readVarInt(delta) { pos }.also { pos = it.second }.first
|
||||
require(baseSize == base.size) {
|
||||
"delta base size mismatch: header says $baseSize, base is ${base.size}"
|
||||
}
|
||||
val targetSize = readVarInt(delta) { pos }.also { pos = it.second }.first
|
||||
|
||||
val out = ByteArray(targetSize)
|
||||
var outPos = 0
|
||||
|
||||
while (pos < delta.size) {
|
||||
val cmd = delta[pos++].toInt() and 0xFF
|
||||
if (cmd and 0x80 != 0) {
|
||||
// copy from base
|
||||
var copyOffset = 0
|
||||
var copySize = 0
|
||||
if (cmd and 0x01 != 0) copyOffset = copyOffset or (delta[pos++].toInt() and 0xFF)
|
||||
if (cmd and 0x02 != 0) copyOffset = copyOffset or ((delta[pos++].toInt() and 0xFF) shl 8)
|
||||
if (cmd and 0x04 != 0) copyOffset = copyOffset or ((delta[pos++].toInt() and 0xFF) shl 16)
|
||||
if (cmd and 0x08 != 0) copyOffset = copyOffset or ((delta[pos++].toInt() and 0xFF) shl 24)
|
||||
if (cmd and 0x10 != 0) copySize = copySize or (delta[pos++].toInt() and 0xFF)
|
||||
if (cmd and 0x20 != 0) copySize = copySize or ((delta[pos++].toInt() and 0xFF) shl 8)
|
||||
if (cmd and 0x40 != 0) copySize = copySize or ((delta[pos++].toInt() and 0xFF) shl 16)
|
||||
if (copySize == 0) copySize = 0x10000
|
||||
base.copyInto(out, outPos, copyOffset, copyOffset + copySize)
|
||||
outPos += copySize
|
||||
} else if (cmd != 0) {
|
||||
// insert literal: the next `cmd` bytes
|
||||
delta.copyInto(out, outPos, pos, pos + cmd)
|
||||
outPos += cmd
|
||||
pos += cmd
|
||||
} else {
|
||||
throw IllegalArgumentException("invalid delta opcode 0x00")
|
||||
}
|
||||
}
|
||||
|
||||
require(outPos == targetSize) { "delta produced $outPos bytes, expected $targetSize" }
|
||||
return out
|
||||
}
|
||||
|
||||
/** Little-endian base-128 varint as used in delta size headers. */
|
||||
private inline fun readVarInt(
|
||||
data: ByteArray,
|
||||
startPos: () -> Int,
|
||||
): Pair<Int, Int> {
|
||||
var pos = startPos()
|
||||
var value = 0
|
||||
var shift = 0
|
||||
while (true) {
|
||||
val b = data[pos++].toInt() and 0xFF
|
||||
value = value or ((b and 0x7F) shl shift)
|
||||
if (b and 0x80 == 0) break
|
||||
shift += 7
|
||||
}
|
||||
return value to pos
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip34Git.git
|
||||
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitDiffFile
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitFileChange
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.LineDiff
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.ParsedPatch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.PriorityQueue
|
||||
|
||||
/**
|
||||
* High-level read-only browser for a git repository served over smart-HTTP.
|
||||
*
|
||||
* [open] downloads a shallow, blob-less snapshot of the default branch tip (one
|
||||
* request fetches the commit and every tree), returning a [GitRepoSnapshot] that
|
||||
* can walk directories offline and lazily fetch individual file blobs on demand.
|
||||
*/
|
||||
class GitHttpClient(
|
||||
okHttpClient: (String) -> OkHttpClient,
|
||||
) {
|
||||
private val transport = GitSmartHttpTransport(okHttpClient)
|
||||
|
||||
/**
|
||||
* Connects to [cloneUrl] and loads the tree of [ref] (default: the server's
|
||||
* HEAD branch). Only `http(s)` git endpoints are supported.
|
||||
*/
|
||||
suspend fun open(
|
||||
cloneUrl: String,
|
||||
ref: String? = null,
|
||||
): GitRepoSnapshot {
|
||||
require(cloneUrl.startsWith("http://") || cloneUrl.startsWith("https://")) {
|
||||
"unsupported git transport (only http/https): $cloneUrl"
|
||||
}
|
||||
|
||||
val caps = transport.fetchCapabilities(cloneUrl)
|
||||
if (!caps.supportsLsRefs || !caps.supportsFetch) {
|
||||
throw GitHttpException("server does not speak git protocol v2 (ls-refs/fetch)")
|
||||
}
|
||||
|
||||
val refs = transport.lsRefs(cloneUrl, caps)
|
||||
val head = selectHead(refs, ref) ?: throw GitHttpException("could not resolve a branch to browse")
|
||||
val branch = head.symrefTarget?.removePrefix("refs/heads/") ?: ref?.removePrefix("refs/heads/") ?: ref
|
||||
|
||||
val branches = refs.mapNotNull { it.name.removePrefix("refs/heads/").takeIf { _ -> it.name.startsWith("refs/heads/") } }.distinct().sorted()
|
||||
val tags = refs.mapNotNull { it.name.removePrefix("refs/tags/").takeIf { _ -> it.name.startsWith("refs/tags/") } }.distinct().sorted()
|
||||
|
||||
val pack =
|
||||
transport.fetchPack(
|
||||
cloneUrl = cloneUrl,
|
||||
caps = caps,
|
||||
wants = listOf(head.oid),
|
||||
deepen = 1,
|
||||
// When the server can't filter, a shallow fetch already pulls every blob, so the
|
||||
// snapshot is fully self-contained and no lazy per-file fetch is needed.
|
||||
filter = "blob:none",
|
||||
)
|
||||
val objects = Packfile.parse(pack)
|
||||
|
||||
val commit = objects[head.oid] ?: throw GitHttpException("tip commit ${head.oid} missing from pack")
|
||||
if (commit.type != GitObjectType.COMMIT) throw GitHttpException("tip ${head.oid} is not a commit")
|
||||
val rootTreeOid =
|
||||
GitObjectParser.parseCommitTree(commit.data)
|
||||
?: throw GitHttpException("commit ${head.oid} has no tree")
|
||||
|
||||
val trees = HashMap<String, List<GitTreeEntry>>()
|
||||
val blobs = HashMap<String, ByteArray>()
|
||||
for ((oid, obj) in objects) {
|
||||
when (obj.type) {
|
||||
GitObjectType.TREE -> trees[oid] = GitObjectParser.parseTree(obj.data)
|
||||
GitObjectType.BLOB -> blobs[oid] = obj.data
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
val tipCommit = runCatching { GitObjectParser.parseCommit(head.oid, commit.data) }.getOrNull()
|
||||
|
||||
return GitRepoSnapshot(
|
||||
cloneUrl = cloneUrl,
|
||||
headCommit = head.oid,
|
||||
branch = branch,
|
||||
branches = branches,
|
||||
tags = tags,
|
||||
rootTreeOid = rootTreeOid,
|
||||
trees = trees,
|
||||
blobs = blobs,
|
||||
transport = transport,
|
||||
caps = caps,
|
||||
tipCommit = tipCommit,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads up to [depth] commits of history starting at [startCommit] (or the
|
||||
* server's HEAD), most recent first. Uses a shallow `tree:0` fetch so only the
|
||||
* commit objects come down — no trees or blobs. The shallow boundary caps how
|
||||
* far back the log goes.
|
||||
*/
|
||||
suspend fun loadHistory(
|
||||
cloneUrl: String,
|
||||
startCommit: String?,
|
||||
depth: Int = 50,
|
||||
): List<GitCommit> {
|
||||
require(cloneUrl.startsWith("http://") || cloneUrl.startsWith("https://")) {
|
||||
"unsupported git transport (only http/https): $cloneUrl"
|
||||
}
|
||||
val caps = transport.fetchCapabilities(cloneUrl)
|
||||
if (!caps.supportsFetch) throw GitHttpException("server does not speak git protocol v2 (fetch)")
|
||||
|
||||
val start =
|
||||
startCommit?.takeIf { it.isNotBlank() }
|
||||
?: run {
|
||||
if (!caps.supportsLsRefs) throw GitHttpException("server can't list refs")
|
||||
val refs = transport.lsRefs(cloneUrl, caps)
|
||||
selectHead(refs, null)?.oid ?: throw GitHttpException("could not resolve HEAD")
|
||||
}
|
||||
|
||||
val pack =
|
||||
transport.fetchPack(
|
||||
cloneUrl = cloneUrl,
|
||||
caps = caps,
|
||||
wants = listOf(start),
|
||||
deepen = depth,
|
||||
// tree:0 → commits only; if the server can't filter, blob:none still works (we ignore the trees).
|
||||
filter = if (caps.supportsFilter) "tree:0" else "blob:none",
|
||||
)
|
||||
val objects = Packfile.parse(pack)
|
||||
val commits = HashMap<String, GitCommit>()
|
||||
for (obj in objects.values) {
|
||||
if (obj.type == GitObjectType.COMMIT) {
|
||||
val commit = GitObjectParser.parseCommit(obj.oid, obj.data)
|
||||
commits[commit.oid] = commit
|
||||
}
|
||||
}
|
||||
|
||||
// Walk parents most-recent-first (like `git log`), bounded by depth and the shallow set.
|
||||
val result = ArrayList<GitCommit>()
|
||||
val visited = HashSet<String>()
|
||||
val frontier = PriorityQueue<GitCommit>(compareByDescending { it.authorTimeSec })
|
||||
commits[start]?.let {
|
||||
frontier.add(it)
|
||||
visited.add(start)
|
||||
}
|
||||
while (frontier.isNotEmpty() && result.size < depth) {
|
||||
val commit = frontier.poll()
|
||||
result.add(commit)
|
||||
for (parent in commit.parents) {
|
||||
if (parent !in visited) {
|
||||
commits[parent]?.let {
|
||||
frontier.add(it)
|
||||
visited.add(parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the diff a pull request introduces: the changes between
|
||||
* [baseCommit] (or, when null, the server's HEAD) and [headCommit]. Fetches
|
||||
* both commit trees, finds the changed files by oid, batch-fetches the
|
||||
* differing blobs and runs a line diff on each. Returns a [ParsedPatch] ready
|
||||
* for the same renderer the embedded-patch path uses.
|
||||
*/
|
||||
suspend fun computeDiff(
|
||||
cloneUrl: String,
|
||||
headCommit: String,
|
||||
baseCommit: String?,
|
||||
): ParsedPatch {
|
||||
require(cloneUrl.startsWith("http://") || cloneUrl.startsWith("https://")) {
|
||||
"unsupported git transport (only http/https): $cloneUrl"
|
||||
}
|
||||
val caps = transport.fetchCapabilities(cloneUrl)
|
||||
if (!caps.supportsFetch) throw GitHttpException("server does not speak git protocol v2 (fetch)")
|
||||
|
||||
val base =
|
||||
baseCommit?.takeIf { it.isNotBlank() }
|
||||
?: run {
|
||||
if (!caps.supportsLsRefs) throw GitHttpException("no merge base and server can't list refs")
|
||||
val refs = transport.lsRefs(cloneUrl, caps)
|
||||
selectHead(refs, null)?.oid ?: throw GitHttpException("could not resolve a base commit")
|
||||
}
|
||||
|
||||
// 1. Both commit trees (and, when the server can't filter, every blob too).
|
||||
val treePack =
|
||||
transport.fetchPack(
|
||||
cloneUrl = cloneUrl,
|
||||
caps = caps,
|
||||
wants = listOf(headCommit, base).distinct(),
|
||||
deepen = 1,
|
||||
filter = "blob:none",
|
||||
)
|
||||
val objects = Packfile.parse(treePack)
|
||||
|
||||
val headTreeOid = commitTreeOf(objects, headCommit)
|
||||
val baseTreeOid = commitTreeOf(objects, base)
|
||||
val trees = HashMap<String, List<GitTreeEntry>>()
|
||||
val blobs = HashMap<String, ByteArray>()
|
||||
for ((oid, obj) in objects) {
|
||||
when (obj.type) {
|
||||
GitObjectType.TREE -> trees[oid] = GitObjectParser.parseTree(obj.data)
|
||||
GitObjectType.BLOB -> blobs[oid] = obj.data
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
val headFiles = collectFiles(trees, headTreeOid)
|
||||
val baseFiles = collectFiles(trees, baseTreeOid)
|
||||
|
||||
// 2. Classify changes and gather the blob oids we still need.
|
||||
data class Change(
|
||||
val path: String,
|
||||
val oldOid: String?,
|
||||
val newOid: String?,
|
||||
val change: GitFileChange,
|
||||
)
|
||||
val changes = ArrayList<Change>()
|
||||
for (path in (headFiles.keys + baseFiles.keys).toSortedSet()) {
|
||||
val oldOid = baseFiles[path]
|
||||
val newOid = headFiles[path]
|
||||
when {
|
||||
oldOid == null && newOid != null -> changes.add(Change(path, null, newOid, GitFileChange.ADD))
|
||||
newOid == null && oldOid != null -> changes.add(Change(path, oldOid, null, GitFileChange.DELETE))
|
||||
oldOid != null && newOid != null && oldOid != newOid ->
|
||||
changes.add(Change(path, oldOid, newOid, GitFileChange.MODIFY))
|
||||
}
|
||||
}
|
||||
|
||||
val needed = changes.flatMap { listOfNotNull(it.oldOid, it.newOid) }.filter { it !in blobs }.distinct()
|
||||
if (needed.isNotEmpty() && caps.supportsFilter) {
|
||||
val blobPack = transport.fetchPack(cloneUrl, caps, needed, deepen = null, filter = "blob:none")
|
||||
for ((oid, obj) in Packfile.parse(blobPack)) {
|
||||
if (obj.type == GitObjectType.BLOB) blobs[oid] = obj.data
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Line-diff every changed file.
|
||||
val files =
|
||||
changes.map { change ->
|
||||
val oldBytes = change.oldOid?.let { blobs[it] }
|
||||
val newBytes = change.newOid?.let { blobs[it] }
|
||||
val binary = (oldBytes?.let(::isBinary) == true) || (newBytes?.let(::isBinary) == true)
|
||||
val hunks =
|
||||
if (binary) {
|
||||
emptyList()
|
||||
} else {
|
||||
LineDiff.hunks(toLines(oldBytes), toLines(newBytes))
|
||||
}
|
||||
GitDiffFile(
|
||||
oldPath = if (change.change == GitFileChange.ADD) null else change.path,
|
||||
newPath = if (change.change == GitFileChange.DELETE) null else change.path,
|
||||
change = change.change,
|
||||
isBinary = binary,
|
||||
hunks = hunks,
|
||||
)
|
||||
}
|
||||
|
||||
return ParsedPatch(message = "", files = files)
|
||||
}
|
||||
|
||||
private fun commitTreeOf(
|
||||
objects: Map<String, GitObject>,
|
||||
commitOid: String,
|
||||
): String {
|
||||
val commit = objects[commitOid] ?: throw GitHttpException("commit $commitOid missing from pack")
|
||||
if (commit.type != GitObjectType.COMMIT) throw GitHttpException("$commitOid is not a commit")
|
||||
return GitObjectParser.parseCommitTree(commit.data) ?: throw GitHttpException("commit $commitOid has no tree")
|
||||
}
|
||||
|
||||
private fun collectFiles(
|
||||
trees: Map<String, List<GitTreeEntry>>,
|
||||
rootTreeOid: String,
|
||||
): Map<String, String> {
|
||||
val out = HashMap<String, String>()
|
||||
|
||||
fun walk(
|
||||
treeOid: String,
|
||||
prefix: String,
|
||||
) {
|
||||
val entries = trees[treeOid] ?: return
|
||||
for (entry in entries) {
|
||||
val path = if (prefix.isEmpty()) entry.name else "$prefix/${entry.name}"
|
||||
when {
|
||||
entry.isFolder -> walk(entry.oid, path)
|
||||
entry.isSubmodule -> {} // submodule pointers aren't file content
|
||||
else -> out[path] = entry.oid
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(rootTreeOid, "")
|
||||
return out
|
||||
}
|
||||
|
||||
private fun toLines(bytes: ByteArray?): List<String> {
|
||||
if (bytes == null || bytes.isEmpty()) return emptyList()
|
||||
val text = bytes.decodeToString()
|
||||
val parts = text.split("\n")
|
||||
return if (text.endsWith("\n")) parts.dropLast(1) else parts
|
||||
}
|
||||
|
||||
private fun isBinary(bytes: ByteArray): Boolean {
|
||||
val limit = minOf(bytes.size, 8000)
|
||||
for (i in 0 until limit) if (bytes[i].toInt() == 0) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Picks the ref to browse: an explicit [ref] if given, otherwise HEAD. */
|
||||
private fun selectHead(
|
||||
refs: List<GitRef>,
|
||||
ref: String?,
|
||||
): GitRef? {
|
||||
if (ref != null) {
|
||||
return refs.firstOrNull { it.name == ref } ?: refs.firstOrNull { it.name == "refs/heads/$ref" }
|
||||
}
|
||||
refs.firstOrNull { it.name == "HEAD" }?.let { return it }
|
||||
return refs.firstOrNull { it.name == "refs/heads/main" }
|
||||
?: refs.firstOrNull { it.name == "refs/heads/master" }
|
||||
?: refs.firstOrNull { it.name.startsWith("refs/heads/") }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An offline-navigable snapshot of a repository tree plus lazy blob access.
|
||||
* Directory listings are resolved from the in-memory tree map; file contents are
|
||||
* fetched on demand (and cached) unless they were already pulled up-front.
|
||||
*/
|
||||
class GitRepoSnapshot(
|
||||
val cloneUrl: String,
|
||||
val headCommit: String,
|
||||
val branch: String?,
|
||||
val branches: List<String> = emptyList(),
|
||||
val tags: List<String> = emptyList(),
|
||||
private val rootTreeOid: String,
|
||||
private val trees: Map<String, List<GitTreeEntry>>,
|
||||
blobs: Map<String, ByteArray>,
|
||||
private val transport: GitSmartHttpTransport,
|
||||
private val caps: GitCapabilities,
|
||||
/** The tip commit object, parsed up-front so the UI can show it without another fetch. */
|
||||
val tipCommit: GitCommit? = null,
|
||||
) {
|
||||
private val blobs = HashMap<String, ByteArray>(blobs)
|
||||
private val blobMutex = Mutex()
|
||||
|
||||
/** Entries at the repository root, folders first. */
|
||||
fun rootEntries(): List<GitTreeEntry> = sortForDisplay(trees[rootTreeOid].orEmpty())
|
||||
|
||||
/**
|
||||
* Every blob (file) path reachable from the tip tree, depth-first. Folders and submodules
|
||||
* are not included. Drives the home screen's language breakdown; the whole tree is already
|
||||
* in memory (the snapshot is fetched with `blob:none`, so all trees came down).
|
||||
*/
|
||||
fun walkFileNames(): List<String> {
|
||||
val out = ArrayList<String>()
|
||||
val stack = ArrayDeque<Pair<String, String>>()
|
||||
stack.addLast(rootTreeOid to "")
|
||||
val seen = HashSet<String>()
|
||||
while (stack.isNotEmpty()) {
|
||||
val (treeOid, prefix) = stack.removeLast()
|
||||
if (!seen.add(treeOid + "@" + prefix)) continue
|
||||
val entries = trees[treeOid] ?: continue
|
||||
for (e in entries) {
|
||||
if (e.isFolder) {
|
||||
stack.addLast(e.oid to "$prefix${e.name}/")
|
||||
} else if (!e.isSubmodule) {
|
||||
out.add("$prefix${e.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Entries inside the directory at [path] (a list of path segments). Returns
|
||||
* null if the path doesn't resolve to a directory.
|
||||
*/
|
||||
fun entriesAt(path: List<String>): List<GitTreeEntry>? {
|
||||
var treeOid = rootTreeOid
|
||||
for (segment in path) {
|
||||
val entry = trees[treeOid]?.firstOrNull { it.name == segment && it.isFolder } ?: return null
|
||||
treeOid = entry.oid
|
||||
}
|
||||
return trees[treeOid]?.let { sortForDisplay(it) }
|
||||
}
|
||||
|
||||
/** Resolves the entry of a file (or folder) at the full [path], or null. */
|
||||
fun entryAt(path: List<String>): GitTreeEntry? {
|
||||
if (path.isEmpty()) return null
|
||||
val parent = entriesAt(path.dropLast(1)) ?: return null
|
||||
return parent.firstOrNull { it.name == path.last() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns up to [limit] full file paths whose file name contains [query]
|
||||
* (case-insensitive), searching the whole tree. Folders are walked but not
|
||||
* matched. The tree is fully present (blob-less), so this is offline.
|
||||
*/
|
||||
fun searchFiles(
|
||||
query: String,
|
||||
limit: Int = 100,
|
||||
): List<List<String>> {
|
||||
if (query.isBlank()) return emptyList()
|
||||
val needle = query.lowercase()
|
||||
val result = ArrayList<List<String>>()
|
||||
|
||||
fun walk(
|
||||
treeOid: String,
|
||||
prefix: List<String>,
|
||||
) {
|
||||
if (result.size >= limit) return
|
||||
val entries = trees[treeOid] ?: return
|
||||
for (entry in entries) {
|
||||
if (result.size >= limit) return
|
||||
val path = prefix + entry.name
|
||||
if (entry.isFolder) {
|
||||
walk(entry.oid, path)
|
||||
} else if (entry.name.lowercase().contains(needle)) {
|
||||
result.add(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(rootTreeOid, emptyList())
|
||||
return result
|
||||
}
|
||||
|
||||
fun hasBlob(oid: String): Boolean = blobs.containsKey(oid)
|
||||
|
||||
/** Returns the bytes of a blob, fetching (and caching) it if not already present. */
|
||||
suspend fun readBlob(oid: String): ByteArray {
|
||||
blobs[oid]?.let { return it }
|
||||
return blobMutex.withLock {
|
||||
blobs[oid]?.let { return it }
|
||||
val pack =
|
||||
transport.fetchPack(
|
||||
cloneUrl = cloneUrl,
|
||||
caps = caps,
|
||||
wants = listOf(oid),
|
||||
deepen = null,
|
||||
filter = "blob:none",
|
||||
)
|
||||
val obj =
|
||||
Packfile.parse(pack)[oid]
|
||||
?: throw GitHttpException("blob $oid missing from fetch response")
|
||||
obj.data.also { blobs[oid] = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sortForDisplay(entries: List<GitTreeEntry>): List<GitTreeEntry> =
|
||||
entries.sortedWith(
|
||||
compareByDescending<GitTreeEntry> { it.isFolder }.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
|
||||
)
|
||||
}
|
||||
Binary file not shown.
+151
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip34Git.git
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.coroutines.executeAsync
|
||||
|
||||
/** A ref returned by `ls-refs`: an oid, its full name, and (for symrefs) its target. */
|
||||
class GitRef(
|
||||
val oid: String,
|
||||
val name: String,
|
||||
val symrefTarget: String? = null,
|
||||
)
|
||||
|
||||
/** Parsed protocol-v2 server capabilities. */
|
||||
class GitCapabilities(
|
||||
val agent: String?,
|
||||
val objectFormat: String,
|
||||
val supportsLsRefs: Boolean,
|
||||
val fetchFeatures: Set<String>,
|
||||
) {
|
||||
/** Some servers advertise `fetch` with no feature list; track that we saw the command. */
|
||||
var rawHadFetch: Boolean = false
|
||||
|
||||
val supportsFetch: Boolean get() = fetchFeatures.isNotEmpty() || rawHadFetch
|
||||
val supportsFilter: Boolean get() = fetchFeatures.contains("filter")
|
||||
val supportsShallow: Boolean get() = fetchFeatures.contains("shallow")
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level git smart-HTTP **protocol v2** transport (`git-upload-pack` only;
|
||||
* we never write). The three exchanges are `info/refs` (capabilities),
|
||||
* `ls-refs`, and `fetch`. Wire encoding/decoding lives in [GitUploadPackV2].
|
||||
*
|
||||
* [okHttpClient] is the shared per-URL client provider, so every request inherits
|
||||
* the app's proxy / Tor routing and onion-rewrite interceptors.
|
||||
*/
|
||||
class GitSmartHttpTransport(
|
||||
private val okHttpClient: (String) -> OkHttpClient,
|
||||
) {
|
||||
suspend fun fetchCapabilities(cloneUrl: String): GitCapabilities =
|
||||
withContext(Dispatchers.IO) {
|
||||
val url = "${base(cloneUrl)}/info/refs?service=git-upload-pack"
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.header("Git-Protocol", "version=2")
|
||||
.header("Accept", "*/*")
|
||||
.get()
|
||||
.build()
|
||||
GitUploadPackV2.parseCapabilities(PktLineCodec.parse(execute(url, request)))
|
||||
}
|
||||
|
||||
suspend fun lsRefs(
|
||||
cloneUrl: String,
|
||||
caps: GitCapabilities,
|
||||
): List<GitRef> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val body = GitUploadPackV2.lsRefsRequest(caps.objectFormat)
|
||||
GitUploadPackV2.parseRefs(PktLineCodec.parse(postUploadPack(cloneUrl, body)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a `fetch` and returns the raw packfile bytes (sideband demuxed).
|
||||
*
|
||||
* @param wants object ids to request.
|
||||
* @param deepen shallow depth (1 = tip only). Null for a full fetch.
|
||||
* @param filter a partial-clone filter spec such as `blob:none` (omit file
|
||||
* contents) or `tree:0` (omit trees and blobs). Applied only when the server
|
||||
* advertises `filter`.
|
||||
*/
|
||||
suspend fun fetchPack(
|
||||
cloneUrl: String,
|
||||
caps: GitCapabilities,
|
||||
wants: List<String>,
|
||||
deepen: Int?,
|
||||
filter: String?,
|
||||
): ByteArray =
|
||||
withContext(Dispatchers.IO) {
|
||||
require(wants.isNotEmpty()) { "fetch requires at least one want" }
|
||||
val body =
|
||||
GitUploadPackV2.fetchRequest(
|
||||
objectFormat = caps.objectFormat,
|
||||
wants = wants,
|
||||
deepen = if (caps.supportsShallow) deepen else null,
|
||||
filter = filter?.takeIf { caps.supportsFilter },
|
||||
)
|
||||
GitUploadPackV2.extractPack(PktLineCodec.parse(postUploadPack(cloneUrl, body)))
|
||||
}
|
||||
|
||||
private suspend fun postUploadPack(
|
||||
cloneUrl: String,
|
||||
body: ByteArray,
|
||||
): ByteArray {
|
||||
val url = "${base(cloneUrl)}/git-upload-pack"
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.header("Git-Protocol", "version=2")
|
||||
.header("Accept", "application/x-git-upload-pack-result")
|
||||
.post(body.toRequestBody(GIT_UPLOAD_PACK_REQUEST))
|
||||
.build()
|
||||
return execute(url, request)
|
||||
}
|
||||
|
||||
private suspend fun execute(
|
||||
url: String,
|
||||
request: Request,
|
||||
): ByteArray =
|
||||
okHttpClient(url).newCall(request).executeAsync().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw GitHttpException("HTTP ${response.code} from $url")
|
||||
}
|
||||
response.body.bytes()
|
||||
}
|
||||
|
||||
private fun base(cloneUrl: String): String = cloneUrl.trim().trimEnd('/')
|
||||
|
||||
companion object {
|
||||
private val GIT_UPLOAD_PACK_REQUEST = "application/x-git-upload-pack-request".toMediaType()
|
||||
}
|
||||
}
|
||||
|
||||
class GitHttpException(
|
||||
message: String,
|
||||
) : Exception(message)
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip34Git.git
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Pure (transport-free) encoders and decoders for the git `git-upload-pack`
|
||||
* protocol-v2 exchanges. Kept separate from [GitSmartHttpTransport] so the wire
|
||||
* formats can be unit-tested against captured server bytes without any network.
|
||||
*/
|
||||
object GitUploadPackV2 {
|
||||
fun lsRefsRequest(objectFormat: String): ByteArray =
|
||||
PktLineCodec.build {
|
||||
write(PktLineCodec.dataLine("command=ls-refs\n"))
|
||||
write(PktLineCodec.dataLine("object-format=$objectFormat\n"))
|
||||
write(PktLineCodec.DELIM)
|
||||
write(PktLineCodec.dataLine("symrefs\n"))
|
||||
write(PktLineCodec.dataLine("peel\n"))
|
||||
write(PktLineCodec.dataLine("ref-prefix HEAD\n"))
|
||||
write(PktLineCodec.dataLine("ref-prefix refs/heads/\n"))
|
||||
write(PktLineCodec.dataLine("ref-prefix refs/tags/\n"))
|
||||
write(PktLineCodec.FLUSH)
|
||||
}
|
||||
|
||||
fun fetchRequest(
|
||||
objectFormat: String,
|
||||
wants: List<String>,
|
||||
deepen: Int?,
|
||||
filter: String?,
|
||||
): ByteArray =
|
||||
PktLineCodec.build {
|
||||
write(PktLineCodec.dataLine("command=fetch\n"))
|
||||
write(PktLineCodec.dataLine("object-format=$objectFormat\n"))
|
||||
write(PktLineCodec.DELIM)
|
||||
write(PktLineCodec.dataLine("no-progress\n"))
|
||||
wants.forEach { write(PktLineCodec.dataLine("want $it\n")) }
|
||||
if (deepen != null) write(PktLineCodec.dataLine("deepen $deepen\n"))
|
||||
if (filter != null) write(PktLineCodec.dataLine("filter $filter\n"))
|
||||
write(PktLineCodec.dataLine("done\n"))
|
||||
write(PktLineCodec.FLUSH)
|
||||
}
|
||||
|
||||
fun parseCapabilities(lines: List<PktLine>): GitCapabilities {
|
||||
var agent: String? = null
|
||||
var objectFormat = "sha1"
|
||||
var supportsLsRefs = false
|
||||
var fetchFeatures = emptySet<String>()
|
||||
var hadFetch = false
|
||||
|
||||
for (line in lines) {
|
||||
if (line !is PktLine.Data) continue
|
||||
val text = line.text()
|
||||
if (text.startsWith("# service=")) continue
|
||||
val eq = text.indexOf('=')
|
||||
val key = if (eq >= 0) text.substring(0, eq) else text
|
||||
val value = if (eq >= 0) text.substring(eq + 1) else ""
|
||||
when (key) {
|
||||
"agent" -> agent = value
|
||||
"object-format" -> if (value.isNotBlank()) objectFormat = value.trim()
|
||||
"ls-refs" -> supportsLsRefs = true
|
||||
"fetch" -> {
|
||||
hadFetch = true
|
||||
fetchFeatures =
|
||||
value
|
||||
.split(' ')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return GitCapabilities(agent, objectFormat, supportsLsRefs, fetchFeatures).also { it.rawHadFetch = hadFetch }
|
||||
}
|
||||
|
||||
fun parseRefs(lines: List<PktLine>): List<GitRef> {
|
||||
val refs = ArrayList<GitRef>()
|
||||
for (line in lines) {
|
||||
if (line !is PktLine.Data) continue
|
||||
val text = line.text()
|
||||
if (text.isEmpty()) continue
|
||||
val parts = text.split(' ')
|
||||
if (parts.size < 2) continue
|
||||
var symref: String? = null
|
||||
for (i in 2 until parts.size) {
|
||||
val attr = parts[i]
|
||||
if (attr.startsWith("symref-target:")) symref = attr.substringAfter("symref-target:")
|
||||
}
|
||||
refs.add(GitRef(parts[0], parts[1], symref))
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
/** Demuxes the sideband-64k `packfile` section of a fetch response into raw pack bytes. */
|
||||
fun extractPack(lines: List<PktLine>): ByteArray {
|
||||
val pack = ByteArrayOutputStream()
|
||||
val errors = StringBuilder()
|
||||
var inPack = false
|
||||
for (line in lines) {
|
||||
when (line) {
|
||||
is PktLine.Flush -> if (inPack) break
|
||||
is PktLine.Delim -> {} // section separator
|
||||
is PktLine.ResponseEnd -> if (inPack) break
|
||||
is PktLine.Data -> {
|
||||
if (!inPack) {
|
||||
if (line.text() == "packfile") inPack = true
|
||||
continue
|
||||
}
|
||||
val payload = line.payload
|
||||
if (payload.isEmpty()) continue
|
||||
when (payload[0].toInt() and 0xFF) {
|
||||
1 -> pack.write(payload, 1, payload.size - 1) // pack data
|
||||
2 -> {} // progress
|
||||
3 -> errors.append(payload.decodeToString(1, payload.size)) // fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (errors.isNotEmpty()) throw GitHttpException("git server error: $errors")
|
||||
val bytes = pack.toByteArray()
|
||||
if (bytes.size < 4) throw GitHttpException("empty packfile in fetch response")
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip34Git.git
|
||||
|
||||
import java.security.MessageDigest
|
||||
import java.util.zip.Inflater
|
||||
|
||||
/**
|
||||
* Parser for a git version-2 packfile (`PACK` magic). Inflates each object,
|
||||
* resolves `OBJ_OFS_DELTA` / `OBJ_REF_DELTA` chains, and computes each object's
|
||||
* SHA-1 oid so callers can look objects up by id.
|
||||
*
|
||||
* We never request thin packs, so every delta base is present inside the same
|
||||
* pack; `OFS_DELTA` bases always precede their delta, and `REF_DELTA` bases are
|
||||
* resolved through a pre-built oid index.
|
||||
*/
|
||||
object Packfile {
|
||||
private const val OBJ_COMMIT = 1
|
||||
private const val OBJ_TREE = 2
|
||||
private const val OBJ_BLOB = 3
|
||||
private const val OBJ_TAG = 4
|
||||
private const val OBJ_OFS_DELTA = 6
|
||||
private const val OBJ_REF_DELTA = 7
|
||||
|
||||
private class Raw(
|
||||
val offset: Int,
|
||||
val type: Int,
|
||||
val data: ByteArray,
|
||||
val baseOffset: Int,
|
||||
val baseOid: String?,
|
||||
)
|
||||
|
||||
/** Parses [pack] and returns the resolved objects indexed by their oid (hex). */
|
||||
fun parse(pack: ByteArray): Map<String, GitObject> {
|
||||
require(pack.size >= 12) { "packfile too short" }
|
||||
require(pack[0] == 'P'.code.toByte() && pack[1] == 'A'.code.toByte() && pack[2] == 'C'.code.toByte() && pack[3] == 'K'.code.toByte()) {
|
||||
"missing PACK signature"
|
||||
}
|
||||
val version = readUInt32(pack, 4)
|
||||
require(version == 2L || version == 3L) { "unsupported packfile version $version" }
|
||||
val count = readUInt32(pack, 8).toInt()
|
||||
|
||||
val raws = ArrayList<Raw>(count)
|
||||
val byOffset = HashMap<Int, Raw>(count * 2)
|
||||
var p = 12
|
||||
repeat(count) {
|
||||
val start = p
|
||||
var b = pack[p++].toInt() and 0xFF
|
||||
val type = (b ushr 4) and 0x07
|
||||
var size = (b and 0x0F).toLong()
|
||||
var shift = 4
|
||||
while (b and 0x80 != 0) {
|
||||
b = pack[p++].toInt() and 0xFF
|
||||
size = size or ((b and 0x7F).toLong() shl shift)
|
||||
shift += 7
|
||||
}
|
||||
|
||||
var baseOffset = -1
|
||||
var baseOid: String? = null
|
||||
when (type) {
|
||||
OBJ_OFS_DELTA -> {
|
||||
b = pack[p++].toInt() and 0xFF
|
||||
var rel = (b and 0x7F).toLong()
|
||||
while (b and 0x80 != 0) {
|
||||
b = pack[p++].toInt() and 0xFF
|
||||
rel = ((rel + 1) shl 7) or (b and 0x7F).toLong()
|
||||
}
|
||||
baseOffset = (start - rel).toInt()
|
||||
}
|
||||
OBJ_REF_DELTA -> {
|
||||
baseOid = toHex(pack, p, 20)
|
||||
p += 20
|
||||
}
|
||||
}
|
||||
|
||||
val (data, consumed) = inflate(pack, p, size.toInt())
|
||||
p += consumed
|
||||
|
||||
val raw = Raw(start, type, data, baseOffset, baseOid)
|
||||
raws.add(raw)
|
||||
byOffset[start] = raw
|
||||
}
|
||||
|
||||
return resolve(raws, byOffset)
|
||||
}
|
||||
|
||||
private fun resolve(
|
||||
raws: List<Raw>,
|
||||
byOffset: Map<Int, Raw>,
|
||||
): Map<String, GitObject> {
|
||||
val memo = HashMap<Int, GitObject>(raws.size * 2)
|
||||
val oidToOffset = HashMap<String, Int>(raws.size * 2)
|
||||
|
||||
// Pre-index oids of non-delta objects so REF_DELTA bases can be found
|
||||
// regardless of pack ordering.
|
||||
for (r in raws) {
|
||||
if (r.type != OBJ_OFS_DELTA && r.type != OBJ_REF_DELTA) {
|
||||
oidToOffset[oidOf(baseType(r.type), r.data)] = r.offset
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveAt(offset: Int): GitObject {
|
||||
memo[offset]?.let { return it }
|
||||
val r = byOffset[offset] ?: throw IllegalStateException("delta base offset $offset not in pack")
|
||||
val obj =
|
||||
when (r.type) {
|
||||
OBJ_OFS_DELTA -> {
|
||||
val base = resolveAt(r.baseOffset)
|
||||
val data = GitDelta.apply(base.data, r.data)
|
||||
GitObject(base.type, data, oidOf(base.type, data))
|
||||
}
|
||||
OBJ_REF_DELTA -> {
|
||||
val baseOffset =
|
||||
oidToOffset[r.baseOid]
|
||||
?: throw IllegalStateException("REF_DELTA base ${r.baseOid} missing from pack")
|
||||
val base = resolveAt(baseOffset)
|
||||
val data = GitDelta.apply(base.data, r.data)
|
||||
GitObject(base.type, data, oidOf(base.type, data))
|
||||
}
|
||||
else -> GitObject(baseType(r.type), r.data, oidOf(baseType(r.type), r.data))
|
||||
}
|
||||
memo[offset] = obj
|
||||
oidToOffset[obj.oid] = offset
|
||||
return obj
|
||||
}
|
||||
|
||||
val result = HashMap<String, GitObject>(raws.size * 2)
|
||||
for (r in raws) {
|
||||
val obj = resolveAt(r.offset)
|
||||
result[obj.oid] = obj
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun baseType(type: Int): GitObjectType =
|
||||
when (type) {
|
||||
OBJ_COMMIT -> GitObjectType.COMMIT
|
||||
OBJ_TREE -> GitObjectType.TREE
|
||||
OBJ_BLOB -> GitObjectType.BLOB
|
||||
OBJ_TAG -> GitObjectType.TAG
|
||||
else -> throw IllegalArgumentException("not a base object type: $type")
|
||||
}
|
||||
|
||||
private fun oidOf(
|
||||
type: GitObjectType,
|
||||
data: ByteArray,
|
||||
): String {
|
||||
val digest = MessageDigest.getInstance("SHA-1")
|
||||
digest.update("${type.header} ${data.size}".encodeToByteArray())
|
||||
digest.update(0)
|
||||
digest.update(data)
|
||||
return digest.digest().toHex()
|
||||
}
|
||||
|
||||
private fun inflate(
|
||||
src: ByteArray,
|
||||
offset: Int,
|
||||
expectedSize: Int,
|
||||
): Pair<ByteArray, Int> {
|
||||
val inflater = Inflater()
|
||||
try {
|
||||
inflater.setInput(src, offset, src.size - offset)
|
||||
val out = ByteArray(expectedSize)
|
||||
var got = 0
|
||||
// Inflate until the stream is fully consumed (not just until we have all
|
||||
// output bytes): the trailing adler32 checksum must be read too, otherwise
|
||||
// bytesRead() is short and the next packed object's offset is misaligned.
|
||||
val scratch = ByteArray(64)
|
||||
while (!inflater.finished()) {
|
||||
if (got < expectedSize) {
|
||||
val n = inflater.inflate(out, got, expectedSize - got)
|
||||
if (n == 0) {
|
||||
if (inflater.needsInput() || inflater.needsDictionary()) break
|
||||
} else {
|
||||
got += n
|
||||
}
|
||||
} else {
|
||||
val n = inflater.inflate(scratch, 0, scratch.size)
|
||||
if (n == 0 && (inflater.needsInput() || inflater.needsDictionary())) break
|
||||
}
|
||||
}
|
||||
if (got != expectedSize) {
|
||||
throw IllegalStateException("inflate short read: $got of $expectedSize")
|
||||
}
|
||||
return out to inflater.bytesRead.toInt()
|
||||
} finally {
|
||||
inflater.end()
|
||||
}
|
||||
}
|
||||
|
||||
private fun readUInt32(
|
||||
data: ByteArray,
|
||||
offset: Int,
|
||||
): Long {
|
||||
var v = 0L
|
||||
for (i in 0 until 4) v = (v shl 8) or (data[offset + i].toLong() and 0xFF)
|
||||
return v
|
||||
}
|
||||
|
||||
private fun toHex(
|
||||
data: ByteArray,
|
||||
offset: Int,
|
||||
len: Int,
|
||||
): String {
|
||||
val sb = StringBuilder(len * 2)
|
||||
for (i in 0 until len) {
|
||||
val v = data[offset + i].toInt() and 0xFF
|
||||
sb.append(HEX[v ushr 4])
|
||||
sb.append(HEX[v and 0x0F])
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String {
|
||||
val sb = StringBuilder(size * 2)
|
||||
for (b in this) {
|
||||
val v = b.toInt() and 0xFF
|
||||
sb.append(HEX[v ushr 4])
|
||||
sb.append(HEX[v and 0x0F])
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private val HEX = "0123456789abcdef".toCharArray()
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip34Git.git
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* git `pkt-line` framing used by the smart-HTTP transport.
|
||||
*
|
||||
* Each frame is a 4-byte ASCII hexadecimal length prefix followed by that many
|
||||
* bytes (the length count includes the 4 prefix bytes). Three lengths are
|
||||
* reserved as control frames:
|
||||
*
|
||||
* - `0000` flush-pkt
|
||||
* - `0001` delim-pkt (protocol v2 section separator)
|
||||
* - `0002` response-end-pkt
|
||||
*
|
||||
* See `gitprotocol-common(5)` and `gitprotocol-v2(5)`.
|
||||
*/
|
||||
sealed interface PktLine {
|
||||
object Flush : PktLine
|
||||
|
||||
object Delim : PktLine
|
||||
|
||||
object ResponseEnd : PktLine
|
||||
|
||||
/** A data frame. [payload] excludes the length prefix. */
|
||||
class Data(
|
||||
val payload: ByteArray,
|
||||
) : PktLine {
|
||||
/** Convenience for text frames, trailing `\n` stripped. */
|
||||
fun text(): String = payload.decodeToString().trimEnd('\n')
|
||||
}
|
||||
}
|
||||
|
||||
object PktLineCodec {
|
||||
/** Encodes a text payload as a single data frame (a trailing `\n` is the git convention). */
|
||||
fun dataLine(text: String): ByteArray = dataLine(text.encodeToByteArray())
|
||||
|
||||
/** Encodes a binary payload as a single data frame. */
|
||||
fun dataLine(payload: ByteArray): ByteArray {
|
||||
val len = payload.size + 4
|
||||
require(len <= 0xFFFF) { "pkt-line payload too large: ${payload.size}" }
|
||||
val out = ByteArray(len)
|
||||
writeLengthPrefix(out, len)
|
||||
payload.copyInto(out, 4)
|
||||
return out
|
||||
}
|
||||
|
||||
val FLUSH: ByteArray = "0000".encodeToByteArray()
|
||||
val DELIM: ByteArray = "0001".encodeToByteArray()
|
||||
|
||||
private fun writeLengthPrefix(
|
||||
out: ByteArray,
|
||||
len: Int,
|
||||
) {
|
||||
val hex = len.toString(16).padStart(4, '0')
|
||||
for (i in 0 until 4) out[i] = hex[i].code.toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a full pkt-line stream into frames. The smart-HTTP responses we
|
||||
* consume are small enough to read fully into memory before decoding.
|
||||
*/
|
||||
fun parse(bytes: ByteArray): List<PktLine> {
|
||||
val result = ArrayList<PktLine>()
|
||||
var i = 0
|
||||
while (i + 4 <= bytes.size) {
|
||||
val len = parseHex4(bytes, i)
|
||||
when (len) {
|
||||
0 -> {
|
||||
result.add(PktLine.Flush)
|
||||
i += 4
|
||||
}
|
||||
1 -> {
|
||||
result.add(PktLine.Delim)
|
||||
i += 4
|
||||
}
|
||||
2 -> {
|
||||
result.add(PktLine.ResponseEnd)
|
||||
i += 4
|
||||
}
|
||||
else -> {
|
||||
require(len >= 4) { "invalid pkt-line length $len at offset $i" }
|
||||
val end = i + len
|
||||
require(end <= bytes.size) { "truncated pkt-line: need $end have ${bytes.size}" }
|
||||
result.add(PktLine.Data(bytes.copyOfRange(i + 4, end)))
|
||||
i = end
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun parseHex4(
|
||||
bytes: ByteArray,
|
||||
offset: Int,
|
||||
): Int {
|
||||
var value = 0
|
||||
for (i in 0 until 4) {
|
||||
val c = bytes[offset + i].toInt().toChar()
|
||||
val digit =
|
||||
when (c) {
|
||||
in '0'..'9' -> c - '0'
|
||||
in 'a'..'f' -> c - 'a' + 10
|
||||
in 'A'..'F' -> c - 'A' + 10
|
||||
else -> throw IllegalArgumentException("invalid pkt-line length char '$c' at offset ${offset + i}")
|
||||
}
|
||||
value = (value shl 4) or digit
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Builds a request body by concatenating pre-encoded frames. */
|
||||
fun build(block: ByteArrayOutputStream.() -> Unit): ByteArray = ByteArrayOutputStream().apply(block).toByteArray()
|
||||
}
|
||||
+48
@@ -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)
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip34Git.git
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
import java.util.Base64
|
||||
|
||||
class GitPackfileDeltaTest {
|
||||
// A real pack of two big.txt versions produced with:
|
||||
// git pack-objects --stdout --delta-base-offset --window=10 --depth=10
|
||||
// It contains one full blob and one OBJ_OFS_DELTA against it.
|
||||
private val deltaPack =
|
||||
"UEFDSwAAAAIAAAACt4sDeJxl2LGOHFUURdHcX9Gf0Peequ6ukMBIlpADQCK2wAGSPQ5A4vdBZGc5fE68Zc+cVe/9cPv4/rfbjx9+/uXX208fPr5/9/u3r1+/vd2+/Pn2+Xav09Rp65Q6HXU66/So07NOrzpd/bcT0zXTOdM900HTRdNJ003TUdNV21XLv1FXbVdtV21XbVdtV21XbVelq9JV4b+uq9JV6ap0VboqXZWuOrrq6Kqjqw5+orrq6Kqjq46uOrrq6Kqzq86uOrvq7KqTH/SuOrvq7Kqzq86uenTVo6seXfXoqkdXPfj966pHVz266tFVz656dtWzq55d9eyqZ1c9mYWuenbVs6teXfXqqldXvbrq1VWvrnp11Yu16qpXV11ddXXV1VVXV11ddXXV1VVXV12MqCvKjN7Z0TtDemdJ70zpnS29M6Z31vTOnN7p+27m6XPoXXqn3q137F175569HwZ/VofoY/OH0R9Wf5j9YfeH4R+Wf5j+YfsnQkkf8z/s/wDAIMBAwGDAgMCgwMDAHEpOHxIMFAwWDBgMGgwcDB4MIAwizOmnBn2gMKgwsDC4MMAwyDDQMNgw4DAPv4Xow4cBiEGIgYjBiAGJQYmBicGJefqxRh9UDFYMWAxaDFwMXgxgDGIMZMzLr0n6UGNgY3BjgGOQY6BjsGPAY9BjLj93/d7lgxc/Fj8WPxY/Fj8WPxY/Fj8WP3b8IKcPPxY/Fj8WPxY/Fj8WP9b7gheG724M9Hln8NLgrcFrg/cGLw74sfix+LHxSkMffix+LH4sfix+LH4sfix+LH7s4Z2LPvxY/Fj8WPxY/Fj8WPxY/Fj82NNLIX34sfix+LH4sfix+LH4sfix+LEPb6304cfix+LH4sfix+LH4sfix+LHPr1W04cfix+LH4sfix+LH4sfix+LH/vy3k8ffix+LH4sfix+LH4sfix+LH7s5cOELxM8TeBH8CP4EfwIfgQ/gh/Bj+BHxqcT+vAj+BH8CH4EP4IfwY/gR/Aj69sOffgR/Ah+BD+CH8GP+PLk05NvT989PtHn85PvTz5A+QLlExR+BD+CH8GPHL6O0YcfwY/gR/Aj+BH8CH4EP4IfOX2+ow8/gh/Bj+BH8CP4EfwIfgQ/gh/Bj+BH8CP4EfwIfgQ/gh/Bj+BH8CP4EfwIfgQ/gh/Bj+BH8CP4EfwIfgQ/gh/Bj+BH8CP4EfwIfgQ/gh/Bj/znx6e3P26fbm+f/7l9+fTX3///+bt/ARJv6GvuAYYkeJzbbrjCUDQnMy9VIT9NoSS1okQhrzQ3KbVIwWCjwGQJAKvrCr5mknNvxZN0+vEf6iRSfsYDsdzxuw=="
|
||||
|
||||
private fun bigTxtCommon() = (0..399).joinToString("") { "common line $it\n" }
|
||||
|
||||
@Test
|
||||
fun resolvesOfsDeltaAndComputesOids() {
|
||||
val pack = Base64.getDecoder().decode(deltaPack)
|
||||
val objects = Packfile.parse(pack)
|
||||
|
||||
val common = bigTxtCommon()
|
||||
val expectedHead = "A NEW FIRST LINE\n" + common + "and a new last line\n"
|
||||
val expectedPrev = "line of text number 0\n" + common
|
||||
|
||||
val head = objects["e873ccdfb69d504f8ce2d51255da3c530d191f21"]
|
||||
assertNotNull("full base blob present", head)
|
||||
assertEquals(GitObjectType.BLOB, head!!.type)
|
||||
assertEquals(expectedHead, head.data.decodeToString())
|
||||
|
||||
val prev = objects["eb06ed9c0291ae7fd14aec388d470a1bff363f1e"]
|
||||
assertNotNull("delta blob resolved", prev)
|
||||
assertEquals(GitObjectType.BLOB, prev!!.type)
|
||||
// The oid is recomputed from the reconstructed bytes, so a correct oid key
|
||||
// proves both the delta application and the SHA-1 hashing are right.
|
||||
assertEquals(expectedPrev, prev.data.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appliesHandcraftedDelta() {
|
||||
val base = "the quick brown fox".encodeToByteArray()
|
||||
// delta: copy "the quick " (offset 0, size 10), insert "red ", copy "fox" (offset 16, size 3)
|
||||
val delta =
|
||||
byteArrayOf(
|
||||
base.size.toByte(), // base size varint (19)
|
||||
("the quick ".length + "red ".length + "fox".length).toByte(), // target size (17)
|
||||
// copy op: cmd 0x90 = copy, offset 0 (no offset bytes), size from one byte (0x0A = 10)
|
||||
0x90.toByte(),
|
||||
0x0A,
|
||||
// insert op: literal of 4 bytes
|
||||
0x04,
|
||||
'r'.code.toByte(),
|
||||
'e'.code.toByte(),
|
||||
'd'.code.toByte(),
|
||||
' '.code.toByte(),
|
||||
// copy op: cmd 0x91 = copy, offset from one byte (0x10 = 16), size from one byte (0x03)
|
||||
0x91.toByte(),
|
||||
0x10,
|
||||
0x03,
|
||||
)
|
||||
val result = GitDelta.apply(base, delta)
|
||||
assertEquals("the quick red fox", result.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesTreeEntries() {
|
||||
// Build a tree payload: "100644 README\0<20 bytes>" + "40000 src\0<20 bytes>"
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
out.write("100644 README".encodeToByteArray())
|
||||
out.write(0)
|
||||
out.write(hex("980a0d5f19a64b4b30a87d4206aade58726b60e3"))
|
||||
out.write("40000 src".encodeToByteArray())
|
||||
out.write(0)
|
||||
out.write(hex("b4eecafa9be2f2006ce1b709d6857b07069b4608"))
|
||||
val entries = GitObjectParser.parseTree(out.toByteArray())
|
||||
|
||||
assertEquals(2, entries.size)
|
||||
assertEquals("README", entries[0].name)
|
||||
assertEquals("980a0d5f19a64b4b30a87d4206aade58726b60e3", entries[0].oid)
|
||||
assertEquals(false, entries[0].isFolder)
|
||||
assertEquals("src", entries[1].name)
|
||||
assertEquals(true, entries[1].isFolder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesCommitTreeHeader() {
|
||||
val commit =
|
||||
(
|
||||
"tree b4eecafa9be2f2006ce1b709d6857b07069b4608\n" +
|
||||
"parent 0000000000000000000000000000000000000000\n" +
|
||||
"author Someone <a@b.c> 1 +0000\n\nmessage\n"
|
||||
).encodeToByteArray()
|
||||
assertEquals("b4eecafa9be2f2006ce1b709d6857b07069b4608", GitObjectParser.parseCommitTree(commit))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTripsPktLine() {
|
||||
val frame = PktLineCodec.dataLine("want abc\n")
|
||||
assertArrayEquals("000dwant abc\n".encodeToByteArray(), frame)
|
||||
val parsed = PktLineCodec.parse(frame + PktLineCodec.FLUSH + PktLineCodec.DELIM)
|
||||
assertEquals(3, parsed.size)
|
||||
assertEquals("want abc", (parsed[0] as PktLine.Data).text())
|
||||
assertEquals(PktLine.Flush, parsed[1])
|
||||
assertEquals(PktLine.Delim, parsed[2])
|
||||
}
|
||||
|
||||
private fun hex(s: String): ByteArray = ByteArray(s.length / 2) { ((s[it * 2].digitToInt(16) shl 4) or s[it * 2 + 1].digitToInt(16)).toByte() }
|
||||
}
|
||||
+108
@@ -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())
|
||||
}
|
||||
}
|
||||
+69
@@ -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))
|
||||
}
|
||||
}
|
||||
+112
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+117
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user