Merge pull request #3393 from vitorpamplona/claude/repo-tabs-status-split-p2ducw

Add Open/Closed filter to Git repo Issues and Patches tabs
This commit is contained in:
Vitor Pamplona
2026-06-26 19:40:59 -04:00
committed by GitHub
10 changed files with 519 additions and 30 deletions
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.nip01Core.core.HexKey
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
@@ -67,6 +69,22 @@ object GitStatusIndex {
}
}
/**
* Whether the latest status for [targetId] marks it as closed (kind 1632)
* or applied/resolved/merged (kind 1631). Items with no status event, or
* whose latest status is open (1630) or draft (1633), are considered open.
*
* Reads from the synchronous snapshot in [latestByTarget]; pass an explicit
* [map] to avoid re-reading the value across a batch.
*/
fun isClosedOrResolved(
targetId: HexKey,
map: Map<HexKey, GitStatusEvent>? = latestByTarget.value,
): Boolean {
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
@@ -0,0 +1,210 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo
import androidx.compose.foundation.ExperimentalFoundationApi
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport
import com.vitorpamplona.amethyst.ui.note.LongPressToQuickAction
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.WatchAuthor
import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
import com.vitorpamplona.amethyst.ui.note.clickableNoteModifier
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.note.types.GitStatusPill
import com.vitorpamplona.amethyst.ui.note.types.StatusKind
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.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size40dp
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
/**
* Compact feed renderer for the repository screen's Issues and Patches & PRs tabs.
*
* Unlike the generic [com.vitorpamplona.amethyst.ui.note.NoteCompose], which is tuned
* for items appearing inside a regular feed, this shows a simple one-line-per-item list:
* author picture, name, NIP-05, subject, time and the shared 3-dot options. It still
* layers the same gating NoteCompose applies — event loading ([WatchNoteEvent]),
* mute/block/report hiding ([CheckHiddenFeedWatchBlockAndReport]) and the long-press
* quick-action menu ([LongPressToQuickAction]) — so blocked authors and reported items
* are hidden here exactly as they are elsewhere. No note body or media is rendered, so
* sensitive content never reaches this list in the first place.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GitItemFeedLoaded(
loaded: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
Row(Modifier.fillMaxWidth().animateItem()) {
GitItemRow(
note = item,
isHiddenFeed = items.showHidden,
accountViewModel = accountViewModel,
nav = nav,
)
}
HorizontalDivider(thickness = DividerThickness)
}
}
}
@Composable
private fun GitItemRow(
note: Note,
isHiddenFeed: Boolean,
accountViewModel: AccountViewModel,
nav: INav,
) {
val modifier = Modifier.fillMaxWidth()
WatchNoteEvent(
baseNote = note,
accountViewModel = accountViewModel,
nav = nav,
modifier = modifier,
) {
CheckHiddenFeedWatchBlockAndReport(
note = note,
modifier = modifier,
showHiddenWarning = false,
ignoreAllBlocksAndReports = isHiddenFeed,
accountViewModel = accountViewModel,
nav = nav,
) { _ ->
LongPressToQuickAction(baseNote = note, accountViewModel = accountViewModel, nav = nav) { showPopup ->
GitItemRowContent(
note = note,
modifier = modifier,
showPopup = showPopup,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@Composable
private fun GitItemRowContent(
note: Note,
modifier: Modifier,
showPopup: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
Row(
modifier =
clickableNoteModifier(note, modifier, accountViewModel, showPopup, nav)
.padding(horizontal = 12.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
WatchAuthor(note, accountViewModel) { author ->
UserPicture(
user = author,
size = Size40dp,
accountViewModel = accountViewModel,
nav = nav,
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
NoteUsernameDisplay(note, Modifier.weight(1f), accountViewModel = accountViewModel)
TimeAgo(note)
MoreOptionsButton(note, accountViewModel = accountViewModel, nav = nav)
}
ObserveDisplayNip05Status(note, accountViewModel, nav)
val subject = remember(note.event) { gitSubjectOf(note.event) }
Text(
text = subject ?: stringRes(R.string.git_untitled),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
GitStatusPill(targetIdHex = note.idHex, defaultIfMissing = StatusKind.OPEN)
}
}
}
private 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
}
@@ -26,17 +26,24 @@ 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.pager.HorizontalPager
import androidx.compose.material3.ExperimentalMaterial3Api
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
@@ -54,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.TitleIconModifier
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.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.dal.RepositoryIssuesFeedViewModel
@@ -88,22 +96,36 @@ private fun PrepareGitRepositoryScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val issuesViewModel: RepositoryIssuesFeedViewModel =
val openIssuesViewModel: RepositoryIssuesFeedViewModel =
viewModel(
key = note.idHex + "GitRepoIssues",
factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account),
key = note.idHex + "GitRepoIssuesOpen",
factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false),
)
val patchesViewModel: RepositoryPatchesFeedViewModel =
val closedIssuesViewModel: RepositoryIssuesFeedViewModel =
viewModel(
key = note.idHex + "GitRepoPatches",
factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account),
key = note.idHex + "GitRepoIssuesClosed",
factory = RepositoryIssuesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true),
)
val openPatchesViewModel: RepositoryPatchesFeedViewModel =
viewModel(
key = note.idHex + "GitRepoPatchesOpen",
factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = false),
)
val closedPatchesViewModel: RepositoryPatchesFeedViewModel =
viewModel(
key = note.idHex + "GitRepoPatchesClosed",
factory = RepositoryPatchesFeedViewModel.Factory(note, accountViewModel.account, showClosed = true),
)
GitRepositoryScreen(
note = note,
issuesViewModel = issuesViewModel,
patchesViewModel = patchesViewModel,
openIssuesViewModel = openIssuesViewModel,
closedIssuesViewModel = closedIssuesViewModel,
openPatchesViewModel = openPatchesViewModel,
closedPatchesViewModel = closedPatchesViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -113,13 +135,17 @@ private fun PrepareGitRepositoryScreen(
@Composable
private fun GitRepositoryScreen(
note: AddressableNote,
issuesViewModel: RepositoryIssuesFeedViewModel,
patchesViewModel: RepositoryPatchesFeedViewModel,
openIssuesViewModel: RepositoryIssuesFeedViewModel,
closedIssuesViewModel: RepositoryIssuesFeedViewModel,
openPatchesViewModel: RepositoryPatchesFeedViewModel,
closedPatchesViewModel: RepositoryPatchesFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(issuesViewModel)
WatchLifecycleAndUpdateModel(patchesViewModel)
WatchLifecycleAndUpdateModel(openIssuesViewModel)
WatchLifecycleAndUpdateModel(closedIssuesViewModel)
WatchLifecycleAndUpdateModel(openPatchesViewModel)
WatchLifecycleAndUpdateModel(closedPatchesViewModel)
val event by observeNoteEvent<GitRepositoryEvent>(note, accountViewModel)
@@ -192,18 +218,20 @@ private fun GitRepositoryScreen(
}
1 -> {
RefresheableFeedView(
viewModel = issuesViewModel,
routeForLastRead = null,
StatusSplitFeed(
persistKey = note.idHex + "GitRepoIssuesStatus",
openViewModel = openIssuesViewModel,
closedViewModel = closedIssuesViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
}
2 -> {
RefresheableFeedView(
viewModel = patchesViewModel,
routeForLastRead = null,
StatusSplitFeed(
persistKey = note.idHex + "GitRepoPatchesStatus",
openViewModel = openPatchesViewModel,
closedViewModel = closedPatchesViewModel,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -213,6 +241,57 @@ private fun GitRepositoryScreen(
}
}
/**
* Wraps a feed in an Open / Closed &amp; Resolved segmented selector, swapping between two
* status-scoped feed view models. Each view model already filters by NIP-34 status, so the
* selector only chooses which one is rendered. The selection survives configuration changes
* and tab swipes via [persistKey].
*/
@Composable
private fun StatusSplitFeed(
persistKey: String,
openViewModel: FeedViewModel,
closedViewModel: FeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
var showClosed by rememberSaveable(persistKey) { mutableStateOf(false) }
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))
}
}
RefresheableFeedView(
viewModel = if (showClosed) closedViewModel else openViewModel,
routeForLastRead = null,
accountViewModel = accountViewModel,
nav = nav,
onLoaded = { loaded, listState ->
GitItemFeedLoaded(loaded, listState, accountViewModel, nav)
},
)
}
}
@Composable
private fun TopBarTitle(
event: GitRepositoryEvent?,
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.GitStatusIndex
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
@@ -31,10 +32,11 @@ import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
class RepositoryIssuesFeedFilter(
val repositoryNote: AddressableNote,
val account: Account,
val showClosed: Boolean,
) : AdditiveFeedFilter<Note>() {
private val repositoryAddressId = repositoryNote.address.toValue()
override fun feedKey(): String = account.userProfile().pubkeyHex + "-issues-" + repositoryNote.idHex
override fun feedKey(): String = account.userProfile().pubkeyHex + "-issues-" + (if (showClosed) "closed-" else "open-") + repositoryNote.idHex
override fun feed(): List<Note> {
val result =
@@ -48,7 +50,8 @@ class RepositoryIssuesFeedFilter(
private fun matches(note: Note): Boolean {
val event = note.event as? GitIssueEvent ?: return false
return event.repositoryHex() == repositoryAddressId
if (event.repositoryHex() != repositoryAddressId) return false
return GitStatusIndex.isClosedOrResolved(note.idHex) == showClosed
}
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
@@ -22,19 +22,35 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.GitStatusIndex
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class RepositoryIssuesFeedViewModel(
val note: AddressableNote,
val account: Account,
) : AndroidFeedViewModel(RepositoryIssuesFeedFilter(note, account)) {
val showClosed: Boolean,
) : AndroidFeedViewModel(RepositoryIssuesFeedFilter(note, account, showClosed)) {
init {
// 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() }
}
}
class Factory(
val note: AddressableNote,
val account: Account,
val showClosed: Boolean,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = RepositoryIssuesFeedViewModel(note, account) as T
override fun <T : ViewModel> create(modelClass: Class<T>): T = RepositoryIssuesFeedViewModel(note, account, showClosed) as T
}
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.GitStatusIndex
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
@@ -32,10 +33,11 @@ import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
class RepositoryPatchesFeedFilter(
val repositoryNote: AddressableNote,
val account: Account,
val showClosed: Boolean,
) : AdditiveFeedFilter<Note>() {
private val repositoryAddressId = repositoryNote.address.toValue()
override fun feedKey(): String = account.userProfile().pubkeyHex + "-patches-" + repositoryNote.idHex
override fun feedKey(): String = account.userProfile().pubkeyHex + "-patches-" + (if (showClosed) "closed-" else "open-") + repositoryNote.idHex
override fun feed(): List<Note> {
val result =
@@ -49,11 +51,14 @@ class RepositoryPatchesFeedFilter(
private fun matches(note: Note): Boolean {
val event = note.event ?: return false
return when (event) {
is GitPatchEvent -> event.repositoryAddress()?.toValue() == repositoryAddressId
is GitPullRequestEvent -> event.repositoryAddress()?.toValue() == repositoryAddressId
else -> false
}
val belongsToRepo =
when (event) {
is GitPatchEvent -> event.repositoryAddress()?.toValue() == repositoryAddressId
is GitPullRequestEvent -> event.repositoryAddress()?.toValue() == repositoryAddressId
else -> false
}
if (!belongsToRepo) return false
return GitStatusIndex.isClosedOrResolved(note.idHex) == showClosed
}
override fun sort(items: Set<Note>): List<Note> = items.sortedByDefaultFeedOrder()
@@ -22,19 +22,35 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.dal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.GitStatusIndex
import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class RepositoryPatchesFeedViewModel(
val note: AddressableNote,
val account: Account,
) : AndroidFeedViewModel(RepositoryPatchesFeedFilter(note, account)) {
val showClosed: Boolean,
) : AndroidFeedViewModel(RepositoryPatchesFeedFilter(note, account, showClosed)) {
init {
// 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() }
}
}
class Factory(
val note: AddressableNote,
val account: Account,
val showClosed: Boolean,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = RepositoryPatchesFeedViewModel(note, account) as T
override fun <T : ViewModel> create(modelClass: Class<T>): T = RepositoryPatchesFeedViewModel(note, account, showClosed) as T
}
}
+3
View File
@@ -2547,6 +2547,9 @@
<string name="git_repo_tab_overview">Overview</string>
<string name="git_repo_tab_issues">Issues</string>
<string name="git_repo_tab_patches">Patches &amp; PRs</string>
<string name="git_repo_filter_open">Open</string>
<string name="git_repo_filter_closed">Closed &amp; Resolved</string>
<string name="git_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>
@@ -115,11 +115,41 @@ class GitPatchEvent(
/** `true` if this event is tagged `["t", "root-revision"]` (root of a revision series). */
fun isRootRevision(): Boolean = tags.any { HashtagTag.isTagged(it, ROOT_REVISION) }
/**
* Human-readable patch title. NIP-34 patches carry the raw `git format-patch`
* output in [content]; the title lives in the RFC-5322 `Subject:` header
* (e.g. `Subject: [PATCH 2/3] Fix the thing`). Returns that subject with any
* `[PATCH …]` bracket prefix removed and folded continuation lines unwrapped,
* or `null` when no `Subject:` header is present.
*/
fun subject(): String? {
val builder = StringBuilder()
var found = false
for (line in content.lineSequence()) {
if (!found) {
if (line.startsWith(SUBJECT_HEADER)) {
builder.append(line.substring(SUBJECT_HEADER.length).trim())
found = true
}
} else if (line.startsWith(" ") || line.startsWith("\t")) {
// RFC-5322 folded header: continuation lines begin with whitespace.
builder.append(' ').append(line.trim())
} else {
break
}
}
if (!found) return null
return PATCH_PREFIX.replace(builder.toString().trim(), "").trim().ifBlank { null }
}
companion object {
const val KIND = 1617
const val ROOT = "root"
const val ROOT_REVISION = "root-revision"
private const val SUBJECT_HEADER = "Subject:"
private val PATCH_PREFIX = Regex("^\\[PATCH[^]]*]\\s*")
/**
* Build a NIP-34 kind-1617 patch event with all required tags.
*
@@ -0,0 +1,109 @@
/*
* 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 kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class GitPatchEventSubjectTest {
private fun patchWith(content: String) =
GitPatchEvent(
id = "00",
pubKey = "00",
createdAt = 0,
tags = emptyArray(),
content = content,
sig = "00",
)
@Test
fun stripsPatchSeriesPrefix() {
val content =
"""
From 9e8f7a6b Mon Sep 17 00:00:00 2001
From: Alice <alice@example.com>
Date: Mon, 1 Jan 2024 00:00:00 +0000
Subject: [PATCH 2/3] Fix the broken thing
The body of the patch goes here.
""".trimIndent()
assertEquals("Fix the broken thing", patchWith(content).subject())
}
@Test
fun handlesPlainPatchPrefix() {
val content =
"""
From 9e8f7a6b Mon Sep 17 00:00:00 2001
Subject: [PATCH] Add a feature
diff --git a/x b/x
""".trimIndent()
assertEquals("Add a feature", patchWith(content).subject())
}
@Test
fun unfoldsContinuationLines() {
val content =
"""
Subject: [PATCH] A very long subject line that the mail
formatter folded across two physical lines
body
""".trimIndent()
assertEquals(
"A very long subject line that the mail formatter folded across two physical lines",
patchWith(content).subject(),
)
}
@Test
fun keepsSubjectWithoutBracketPrefix() {
val content =
"""
Subject: Just a plain subject
body
""".trimIndent()
assertEquals("Just a plain subject", patchWith(content).subject())
}
@Test
fun returnsNullWhenNoSubjectHeader() {
val content =
"""
diff --git a/x b/x
index 000..111
""".trimIndent()
assertNull(patchWith(content).subject())
}
@Test
fun returnsNullWhenSubjectIsEmpty() {
assertNull(patchWith("Subject: [PATCH] ").subject())
}
}